C++ Text Analysis: High-Performance Tokenization

Compiled Language Power for Text Processing

The cpp/ directory houses optimized C++ algorithms for tokenization, lemmatization, and Chinese text analysis. Exposed to Go via cgo FFI bindings, these functions achieve 3-10x throughput over pure Go implementations.

πŸ“š What is this & why does it exist?

Native C++ extensions for performance-critical text processing. Implements a Double-Array Trie tokenizer with Python bindings via pybind11, providing O(L) lookup for millions of vocabulary entries with cache-friendly memory layout.

πŸ’‘ Real-world analogy

Like a high-speed sorting machine in a postal hub β€” the Python code is the friendly customer counter, but when packages need to be routed millions of times per second, you switch to a custom mechanical sorter (C++ trie). Same job, 50x faster.

Overview

Why C++? Tokenization is called thousands of times per search operation. Go's built-in strings and maps work for simple cases, but Chinese text analysis requires specialized algorithms (DARTS double-array trie, Unicode normalization, part-of-speech tagging). C++ excels at memory-efficient data structures and tight loops.

The cgo boundary adds ~100ns overhead per call, but for batch operations (tokenizing 1,000 tokens), that's 0.01% of total time.

Core Components

ComponentPurposeLanguagePerformance
DARTS TokenizerSegment Chinese text into words, part-of-speech taggingC++~20ns per character
WordNet LemmatizerEnglish stemming (run->running->runs)C++~50ns per word
OpenCCSimplified <-> Traditional Chinese conversionC++~5ns per character
RE2 RegexSafe regex without ReDoS, named capturesC++~1ms per match (worst-case safe)
UTF-8 ValidatorDetect and fix invalid UTF-8 sequencesC++~10ns per byte

Architecture: Go ↔ C++ Bridge

Go Handler/Service
    |
    v
Tokenizer wrapper (tokenizer.go)
    | cgo boundary
    v
C Shim API (rag_analyzer_c_api.h)
    | FFI
    v
C++ Implementation
 β”œβ”€ darts_trie.cpp
 β”œβ”€ tokenizer.cpp
 β”œβ”€ lemmatizer.cpp
 └─ opencc_wrapper.cpp
    |
    v
Return marshaled results to Go

DARTS Double-Array Trie

What is DARTS?

Double-Array Trie Strings (DARTS) is a memory-efficient data structure for dictionary lookup. Instead of tree nodes, it uses two integer arrays (base and check) that fit in CPU cache.

Why DARTS over HashMap?

MetricHashMapDARTS
Lookup TimeO(n) worst-case (hash collision)O(n) deterministic (n = string length)
Memory50 bytes per entry overhead2-4 bytes per entry (just base/check arrays)
Cache LocalityScattered pointer chasingDense arrays, predictable access
Build TimeImmediateOffline via build tool
Load TimeO(n)O(1) binary mmap

DARTS Lookup Algorithm

Given string "hello" and DARTS state machine:

state = 0
for each character c in string:
    idx = base[state] + (c value)
    if check[idx] == state:
        state = idx
    else:
        return NOT_FOUND

if final_state is marked END:
    return FOUND
else:
    return NOT_FOUND

Time complexity: O(string_length * ~20ns), not O(string_length^2) like hashmap worst-case.

Tokenization Pipeline

Input: Raw Text

"δ»Šε€©ε€©ζ°”εΎˆε₯½οΌŒζˆ‘ζƒ³εŽ»ε…¬ε›­γ€‚"
(Today the weather is very nice, I want to go to the park.)

Step 1: Unicode Normalization

Decompose characters, remove accents, normalize spaces.

Step 2: DARTS Lookup

For each substring, check DARTS dictionary. Identify longest matching word at each position (greedy longest-match).

Step 3: Part-of-Speech Tagging

Each matched token is assigned a part-of-speech (noun, verb, adjective, etc.)

Step 4: English Lemmatization

If token contains Latin characters, apply lemmatization rules.

Output: Token List

[
  { "token": "今倩", "pos": "NOUN", "score": 1.0 },
  { "token": "倩气", "pos": "NOUN", "score": 1.0 },
  { "token": "很", "pos": "ADV", "score": 0.8 },
  { "token": "ε₯½", "pos": "ADJ", "score": 1.0 },
]

cgo Integration

C API Definition

// rag_analyzer_c_api.h
extern "C" {
    struct TokenResult {
        char* token;
        char* pos;
        float score;
    };
    
    struct TokenizeResult {
        TokenResult* tokens;
        int count;
    };
    
    TokenizeResult tokenize(const char* text, int len);
    void free_result(TokenizeResult result);
}

Go Bindings

// tokenizer.go
package binding

import "C"

func Tokenize(text string) ([]Token, error) {
    cText := C.CString(text)
    defer C.free(unsafe.Pointer(cText))
    
    result := C.tokenize(cText, C.int(len(text)))
    defer C.free_result(result)
    
    tokens := make([]Token, result.count)
    for i := 0; i < int(result.count); i++ {
        tokens[i] = Token{
            Word: C.GoString(result.tokens[i].token),
            POS: C.GoString(result.tokens[i].pos),
            Score: float32(result.tokens[i].score),
        }
    }
    return tokens, nil
}

Files Overview

DARTS Algorithm Deep Dive

Complete walkthrough: construction, lookup, memory layout, compression techniques.

Tokenizer Implementation

Chinese segmentation algorithm, greedy longest-match, POS tagging integration.

Lemmatizer

English stemming rules (suffix stripping), WordNet integration.

OpenCC Wrapper

Simplified/Traditional Chinese conversion, tone-aware variants.

RE2 Integration

Safe regex matching without ReDoS. Named capture groups, anchors.

UTF-8 Validation

Detect invalid sequences, repair or reject bad input.

Performance Tuning

Memory Layout

DARTS arrays are mmap'ed from disk, keeping heap pressure low. CPP tokenizer avoids dynamic allocations in the hot path.

Batch Processing

Call tokenize() once per batch (multiple sentences) rather than once per sentence to amortize cgo overhead.

Caching Tokenization Results

Service layer caches tokenized results per query string (embedding lookup cache).

Building & Dependencies

The C++ code depends on:

Build via CMake:

cd internal/cpp
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build
# Outputs: librag_analyzer.a, librag_analyzer.so

Integration Checklist

cgo Best Practices

Memory Management

C++ allocates, Go frees. Never let Go garbage collect C-allocated memory:

// WRONG: Go pointer to C memory, may crash if GC moves Go object
var cStr *C.char = C.malloc(100)
goStr := C.GoString(cStr)  // dangerous!

// CORRECT: Store C pointer, free explicitly
cStr := C.malloc(100)
defer C.free(unsafe.Pointer(cStr))
goStr := C.GoString(cStr)

Build Configuration

Compile C++ with optimization flags:

// In go file
// #cgo CFLAGS: -I/usr/include/rag_analyzer -O3
// #cgo LDFLAGS: -L/usr/lib -lrag_analyzer
// #include "rag_analyzer.h"
import "C"

go build  // automatically applies CFLAGS/LDFLAGS

Error Handling Across Boundary

C++ exceptions don't cross the cgo boundary. Use return codes:

// C API
typedef struct {
    int code;  // 0 = success, 1 = error
    char* message;
} Result;

// Go wrapper
result := C.tokenize(cText)
defer C.free(unsafe.Pointer(result.message))

if result.code != 0 {
    return nil, errors.New(C.GoString(result.message))
}

Integration Patterns

Lazy Initialization

Load DARTS dictionary once at startup:

var (
    dartsOnce sync.Once
    dartsTrie *Trie
)

func GetDARTS() *Trie {
    dartsOnce.Do(func() {
        dartsTrie = C.load_darts("/path/to/darts.bin")
    })
    return dartsTrie
}

Timeout for C++ Calls

Long-running C++ operations can block Go scheduler. Use goroutines + channels:

func TokenizeWithTimeout(ctx context.Context, text string, timeout time.Duration) ([]Token, error) {
    result := make(chan []Token, 1)
    errChan := make(chan error, 1)
    
    go func() {
        tokens, err := Tokenize(text)  // may be slow
        if err != nil {
            errChan <- err
        } else {
            result <- tokens
        }
    }()
    
    select {
    case <-ctx.Done():
        return nil, ctx.Err()
    case tokens := <-result:
        return tokens, nil
    case err := <-errChan:
        return nil, err
    case <-time.After(timeout):
        return nil, errors.New("tokenization timeout")
    }
}

Further Reading