Files
ragflow/internal/tokenizer/tokenizer_concurrent_test.go
Jack e997fd655a fix(tokenizer): load cl100k BPE table from disk instead of failing silently offline (#17712)
## Summary

RAGFlow's Go tokenizer silently returned **0 tokens for every string**
whenever the `cl100k_base` BPE table could not be loaded — which is the
normal case for an offline/air-gapped Go server. This PR makes the
loader resolve the table from disk (where RAGFlow actually ships it) and
fail loudly when it is genuinely missing.

## Root cause

`tiktoken-go`'s stock loader downloads the encoding table over HTTP and
caches it under `TIKTOKEN_CACHE_DIR`. That does not work for RAGFlow:

- `TIKTOKEN_CACHE_DIR` is exported **only inside the Python process**
(`common/token_utils.py`). `docker/entrypoint.sh` launches the Go binary
(`bin/ragflow_server`) from a shell, so the Go process never inherits
the variable.
- The Dockerfile *does* ship the table (under its sha1 name in the
working directory), but nothing told the Go side to look there.
- Reaching `openaipublic.blob.core.windows.net` at runtime is not an
option for air-gapped installs, and is unreliable where that host is
blocked.

The failure was **silent**: `NumTokensFromString` returns `0` when the
encoder fails to build, and a `sync.Once` memoizes that error for the
process lifetime. Every token count became `0`, so chunk merging never
crossed its token budget and an entire document collapsed into a single
chunk. Python has no such failure mode because its encoder is built at
import time (a missing table aborts startup instead of degrading).

## Fix

Register a local-only `BpeLoader` via `tiktoken.SetBpeLoader`
(`internal/tokenizer/bpe_loader.go`) that resolves the table from disk
**only**, in priority order:

1. `TIKTOKEN_CACHE_DIR` / `DATA_GYM_CACHE_DIR` (honored so operators who
already configured one keep working).
2. The working directory, the executable's directory, and all of their
ancestors — matching the Dockerfile layout (table under its sha1 name in
the install root).
3. A `ragflow_deps/<basename>` checkout produced by
`ragflow_deps/download_deps.py`.

It **never performs network I/O**. When nothing is found it returns an
error listing every path it tried (pointing at `download_deps.py` or
`TIKTOKEN_CACHE_DIR`), so a genuinely missing table fails loudly instead
of degrading to zero.

## Test plan

- `internal/tokenizer/bpe_loader_test.go` (unit tier, runs under `bash
build.sh --test ./internal/tokenizer/...`):
- Loader reads from `TIKTOKEN_CACHE_DIR`, `DATA_GYM_CACHE_DIR`, the
sha1-named file in the working dir, and the bundled `ragflow_deps/`
name.
  - Explicit cache dir wins over the bundled vocab.
  - A malformed table is reported as an error rather than skipped.
- A genuinely missing table reports the candidates it tried (no network
attempt).
- `NumTokensFromString` matches Python-derived anchors (`""`→0,
`"hello"`→1, `"hello world"`→2, `"hello, world!"`→4, `"世界"`→3, `"Hello
世界 🌍"`→8, `"RAGFlow"`→3).

## Notes

- `.github/workflows/tests.yml` currently excludes `internal/tokenizer`
from `go test`, so these tests do not run in CI. The tokenizer fix is
exercised in CI indirectly via the chunker package once a
token-count-sensitive parity case lands (tracked separately). Consider
including `internal/tokenizer` in CI as a follow-up.
- Supported deployments already ship the table (`download_deps.py` →
`ragflow_deps/cl100k_base.tiktoken`; Dockerfile → `<sha1>` in cwd), so
no `ENV` change is required for the fix to take effect. Setting `ENV
TIKTOKEN_CACHE_DIR` in the Dockerfile remains a cheap
belt-and-suspenders hardening that can be done separately.

🤖 Generated with [CodeBuddy Code](https://cnb.cool/codebuddy)

---------

Co-authored-by: CodeBuddy <noreply@codebuddy.ai>
Co-authored-by: CodeBuddy Code <noreply@cnb.cool>
Co-authored-by: CodeBuddy <noreply@tencent.com>
2026-08-03 19:03:08 +08:00

554 lines
15 KiB
Go

//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build manual
package tokenizer
import (
"fmt"
"ragflow/internal/common"
"runtime"
"sync"
"sync/atomic"
"testing"
"time"
"go.uber.org/zap"
)
func init() {
// Initialize logger for tests
if err := common.InitLogger("info", common.FileOutput{}, "tokenizer_test"); err != nil {
fmt.Printf("Failed to initialize logger: %v\n", err)
}
}
// TestConcurrentTokenize tests concurrent tokenization with dynamic pool expansion and shrinking
func TestConcurrentTokenize(t *testing.T) {
// Use small pool to test expansion
cfg := &PoolConfig{
DictPath: "", // uses default or RAGFLOW_DICT_PATH env var
MinSize: 2,
MaxSize: 10,
IdleTimeout: 5 * time.Second,
AcquireTimeout: 5 * time.Second,
}
if err := Init(cfg); err != nil {
t.Fatalf("Failed to initialize pool: %v", err)
}
defer Close()
// Print initial pool stats
stats := GetPoolStats()
t.Logf("Initial pool stats: %+v", stats)
// Test texts
texts := []string{
"Hello world this is a test",
"Natural language processing is amazing",
"Elastic pool handles concurrent requests",
"中文分词测试",
"深度学习与机器学习",
"RAGFlow is an open-source RAG engine",
}
// Phase 1: High concurrency test - should trigger expansion
t.Log("=== Phase 1: High concurrency test (should trigger expansion) ===")
var expansionDetected int32
var wg sync.WaitGroup
numGoroutines := 20
requestsPerGoroutine := 10
start := time.Now()
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < requestsPerGoroutine; j++ {
text := texts[(id+j)%len(texts)]
result, err := Tokenize(text)
if err != nil {
t.Errorf("Goroutine %d request %d failed: %v", id, j, err)
return
}
if result == "" {
t.Errorf("Goroutine %d request %d returned empty result", id, j)
}
// Check pool stats periodically
if j%5 == 0 {
stats := GetPoolStats()
currentSize := stats["current_size"].(int32)
if currentSize > int32(cfg.MinSize) {
atomic.StoreInt32(&expansionDetected, 1)
}
}
}
}(i)
}
wg.Wait()
phase1Duration := time.Since(start)
stats = GetPoolStats()
t.Logf("Phase 1 completed in %v", phase1Duration)
t.Logf("Pool stats after Phase 1: %+v", stats)
if atomic.LoadInt32(&expansionDetected) == 1 {
t.Log("✓ Pool expansion detected during high concurrency")
} else {
t.Log("℗ Pool expansion not detected (may need more concurrency)")
}
currentSize := stats["current_size"].(int32)
if currentSize > int32(cfg.MinSize) {
t.Logf("✓ Current pool size (%d) is greater than minSize (%d)", currentSize, cfg.MinSize)
}
// Phase 2: Wait for idle timeout - should trigger shrinking
t.Log("=== Phase 2: Waiting for idle timeout (should trigger shrinking) ===")
t.Logf("Waiting %v for idle instances to timeout...", cfg.IdleTimeout)
time.Sleep(cfg.IdleTimeout + 2*time.Second)
stats = GetPoolStats()
t.Logf("Pool stats after Phase 2 (waiting): %+v", stats)
currentSize = stats["current_size"].(int32)
if currentSize <= int32(cfg.MinSize) {
t.Logf("✓ Pool shrunk back to minSize or below: current=%d, min=%d", currentSize, cfg.MinSize)
} else {
t.Logf("℗ Pool not yet shrunk: current=%d, min=%d (may need more time)", currentSize, cfg.MinSize)
}
// Phase 3: Moderate concurrency after shrink - should trigger expansion again
t.Log("=== Phase 3: Moderate concurrency after shrink (should trigger re-expansion) ===")
var reExpansionDetected int32
start = time.Now()
for i := 0; i < numGoroutines/2; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < requestsPerGoroutine/2; j++ {
text := texts[(id+j)%len(texts)]
_, err := Tokenize(text)
if err != nil {
t.Errorf("Phase 3 goroutine %d request %d failed: %v", id, j, err)
return
}
if j%3 == 0 {
stats := GetPoolStats()
currentSize := stats["current_size"].(int32)
if currentSize > int32(cfg.MinSize) {
atomic.StoreInt32(&reExpansionDetected, 1)
}
}
}
}(i)
}
wg.Wait()
phase3Duration := time.Since(start)
stats = GetPoolStats()
t.Logf("Phase 3 completed in %v", phase3Duration)
t.Logf("Pool stats after Phase 3: %+v", stats)
if atomic.LoadInt32(&reExpansionDetected) == 1 {
t.Log("✓ Pool re-expansion detected after shrink")
}
t.Log("=== Test completed successfully ===")
}
func TestConcurrentTokenizeLanguageIsolation(t *testing.T) {
restore := saveEngineType()
defer restore()
SetEngineType("")
cfg := &PoolConfig{
DictPath: "",
MinSize: 2,
MaxSize: 8,
IdleTimeout: 3 * time.Second,
AcquireTimeout: 5 * time.Second,
}
if err := Init(cfg); err != nil {
t.Fatalf("Failed to initialize pool: %v", err)
}
defer Close()
sample := findEnglishDutchDifferentiator(t)
const goroutinesPerLang = 8
const requestsPerGoroutine = 20
var wg sync.WaitGroup
start := make(chan struct{})
errors := make(chan string, goroutinesPerLang*requestsPerGoroutine*2)
run := func(tok Tokenizer, lang, want string) {
defer wg.Done()
<-start
for i := 0; i < requestsPerGoroutine; i++ {
got, err := tok.Tokenize(sample.input)
if err != nil {
errors <- fmt.Sprintf("lang=%s req=%d unexpected error: %v", lang, i, err)
return
}
if got != want {
errors <- fmt.Sprintf("lang=%s req=%d got %q want %q", lang, i, got, want)
return
}
}
}
for i := 0; i < goroutinesPerLang; i++ {
wg.Add(2)
go run(New("English"), "English", sample.english)
go run(New("Dutch"), "Dutch", sample.dutch)
}
close(start)
wg.Wait()
close(errors)
for err := range errors {
t.Error(err)
}
if t.Failed() {
t.Fatalf("concurrent language isolation failed for input %q (English=%q Dutch=%q)", sample.input, sample.english, sample.dutch)
}
}
// TestConcurrentTokenizeWithPosition tests concurrent tokenization with position info
func TestConcurrentTokenizeWithPosition(t *testing.T) {
cfg := &PoolConfig{
DictPath: "", // uses default or RAGFLOW_DICT_PATH env var
MinSize: 2,
MaxSize: 8,
IdleTimeout: 3 * time.Second,
AcquireTimeout: 5 * time.Second,
}
if err := Init(cfg); err != nil {
t.Fatalf("Failed to initialize pool: %v", err)
}
defer Close()
text := "This is a test sentence for position tracking"
var wg sync.WaitGroup
numGoroutines := 15
t.Log("=== Testing TokenizeWithPosition concurrently ===")
start := time.Now()
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 5; j++ {
tokens, err := TokenizeWithPosition(text)
if err != nil {
t.Errorf("Goroutine %d request %d failed: %v", id, j, err)
return
}
if len(tokens) == 0 {
t.Errorf("Goroutine %d request %d returned empty tokens", id, j)
return
}
// Verify position info
for _, token := range tokens {
if token.Text == "" {
t.Errorf("Goroutine %d request %d returned empty token text", id, j)
return
}
if token.EndOffset <= token.Offset {
t.Errorf("Goroutine %d request %d has invalid position: offset=%d, end=%d",
id, j, token.Offset, token.EndOffset)
return
}
}
}
}(i)
}
wg.Wait()
duration := time.Since(start)
stats := GetPoolStats()
t.Logf("Completed %d goroutines x 5 requests in %v", numGoroutines, duration)
t.Logf("Final pool stats: %+v", stats)
t.Log("✓ TokenizeWithPosition concurrent test passed")
}
// TestPoolExhaustion tests pool exhaustion and timeout behavior
func TestPoolExhaustion(t *testing.T) {
// Very small pool to test exhaustion
cfg := &PoolConfig{
DictPath: "", // uses default or RAGFLOW_DICT_PATH env var
MinSize: 1,
MaxSize: 2,
IdleTimeout: 10 * time.Second,
AcquireTimeout: 500 * time.Millisecond, // Short timeout for faster test
}
if err := Init(cfg); err != nil {
t.Fatalf("Failed to initialize pool: %v", err)
}
defer Close()
t.Log("=== Testing pool exhaustion behavior ===")
stats := GetPoolStats()
t.Logf("Initial pool stats: %+v", stats)
// Use all available instances
var wg sync.WaitGroup
barrier := make(chan struct{})
errors := make(chan error, 10)
// Launch goroutines that hold instances
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
<-barrier // Wait for signal to start
_, err := Tokenize("test text")
if err != nil {
errors <- fmt.Errorf("goroutine %d: %w", id, err)
}
}(i)
}
// Release all goroutines at once to create contention
close(barrier)
// Wait for all to complete
wg.Wait()
close(errors)
timeoutCount := 0
for err := range errors {
if err != nil {
t.Logf("Expected error from limited pool: %v", err)
timeoutCount++
}
}
stats = GetPoolStats()
t.Logf("Final pool stats: %+v", stats)
t.Logf("Timeout errors: %d (expected with small pool)", timeoutCount)
if timeoutCount > 0 {
t.Log("✓ Pool correctly returned timeout errors when exhausted")
} else {
t.Log("℗ No timeout errors (pool handled all requests, may be too fast)")
}
}
// TestFineGrainedTokenizeConcurrent tests concurrent fine-grained tokenization
func TestFineGrainedTokenizeConcurrent(t *testing.T) {
cfg := &PoolConfig{
DictPath: "", // uses default or RAGFLOW_DICT_PATH env var
MinSize: 2,
MaxSize: 6,
IdleTimeout: 3 * time.Second,
AcquireTimeout: 5 * time.Second,
}
if err := Init(cfg); err != nil {
t.Fatalf("Failed to initialize pool: %v", err)
}
defer Close()
tokens := "hello world 中文测试"
var wg sync.WaitGroup
numGoroutines := 10
t.Log("=== Testing FineGrainedTokenize concurrently ===")
start := time.Now()
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 5; j++ {
result, err := FineGrainedTokenize(tokens)
if err != nil {
t.Errorf("Goroutine %d request %d failed: %v", id, j, err)
return
}
if result == "" {
t.Errorf("Goroutine %d request %d returned empty result", id, j)
}
}
}(i)
}
wg.Wait()
duration := time.Since(start)
stats := GetPoolStats()
t.Logf("Completed %d goroutines x 5 requests in %v", numGoroutines, duration)
t.Logf("Final pool stats: %+v", stats)
t.Log("✓ FineGrainedTokenize concurrent test passed")
}
// TestTermFreqAndTagConcurrent tests concurrent term frequency and tag lookups
func TestTermFreqAndTagConcurrent(t *testing.T) {
cfg := &PoolConfig{
DictPath: "", // uses default or RAGFLOW_DICT_PATH env var
MinSize: 2,
MaxSize: 6,
IdleTimeout: 3 * time.Second,
AcquireTimeout: 5 * time.Second,
}
if err := Init(cfg); err != nil {
t.Fatalf("Failed to initialize pool: %v", err)
}
defer Close()
terms := []string{"hello", "world", "中文", "test", "natural"}
var wg sync.WaitGroup
numGoroutines := 10
t.Log("=== Testing GetTermFreq and GetTermTag concurrently ===")
start := time.Now()
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 10; j++ {
term := terms[(id+j)%len(terms)]
freq := GetTermFreq(term)
tag := GetTermTag(term)
// We don't validate the results as terms may or may not exist in dictionary
// Just ensuring no panics or errors
_ = freq
_ = tag
}
}(i)
}
wg.Wait()
duration := time.Since(start)
stats := GetPoolStats()
t.Logf("Completed %d goroutines x 10 requests in %v", numGoroutines, duration)
t.Logf("Final pool stats: %+v", stats)
t.Log("✓ GetTermFreq and GetTermTag concurrent test passed")
}
// BenchmarkTokenize benchmarks the tokenization performance
func BenchmarkTokenize(b *testing.B) {
cfg := &PoolConfig{
DictPath: "", // uses default or RAGFLOW_DICT_PATH env var
MinSize: runtime.NumCPU() * 2,
MaxSize: runtime.NumCPU() * 4,
IdleTimeout: 5 * time.Minute,
AcquireTimeout: 10 * time.Second,
}
if err := Init(cfg); err != nil {
b.Fatalf("Failed to initialize pool: %v", err)
}
defer Close()
text := "This is a benchmark test for tokenization performance with natural language processing"
// Warm up
for i := 0; i < 100; i++ {
Tokenize(text)
}
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_, err := Tokenize(text)
if err != nil {
b.Errorf("Tokenize failed: %v", err)
}
}
})
stats := GetPoolStats()
b.Logf("Final pool stats: %+v", stats)
}
// BenchmarkTokenizeWithPosition benchmarks position-aware tokenization
func BenchmarkTokenizeWithPosition(b *testing.B) {
cfg := &PoolConfig{
DictPath: "", // uses default or RAGFLOW_DICT_PATH env var
MinSize: runtime.NumCPU() * 2,
MaxSize: runtime.NumCPU() * 4,
IdleTimeout: 5 * time.Minute,
AcquireTimeout: 10 * time.Second,
}
if err := Init(cfg); err != nil {
b.Fatalf("Failed to initialize pool: %v", err)
}
defer Close()
text := "This is a benchmark test for position-aware tokenization"
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_, err := TokenizeWithPosition(text)
if err != nil {
b.Errorf("TokenizeWithPosition failed: %v", err)
}
}
})
}
// ExampleGetPoolStats demonstrates getting pool statistics
func ExampleGetPoolStats() {
cfg := &PoolConfig{
DictPath: "", // uses default or RAGFLOW_DICT_PATH env var
MinSize: 2,
MaxSize: 10,
IdleTimeout: 5 * time.Minute,
AcquireTimeout: 10 * time.Second,
}
if err := Init(cfg); err != nil {
fmt.Printf("Failed to initialize: %v\n", err)
return
}
defer Close()
stats := GetPoolStats()
fmt.Printf("Pool initialized: %v\n", stats["initialized"])
fmt.Printf("Current size: %d\n", stats["current_size"])
fmt.Printf("Min size: %d\n", stats["min_size"])
fmt.Printf("Max size: %d\n", stats["max_size"])
// Output will vary based on actual initialization
}
// logPoolStats logs pool statistics using the zap logger
func logPoolStats(msg string) {
stats := GetPoolStats()
common.Info(msg,
zap.Bool("initialized", stats["initialized"].(bool)),
zap.Int32("current_size", stats["current_size"].(int32)),
zap.Int("min_size", stats["min_size"].(int)),
zap.Int("max_size", stats["max_size"].(int)),
zap.String("idle_timeout", stats["idle_timeout"].(string)),
zap.Int("instances_available", stats["instances_available"].(int)),
)
}