mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-05 07:10:29 +08:00
## 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>
346 lines
9.5 KiB
Go
346 lines
9.5 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 (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type languageDifferentiator struct {
|
|
input string
|
|
english string
|
|
dutch string
|
|
}
|
|
|
|
// saveEngineType saves the current engineTypeProvider and returns a function
|
|
// to restore it. Use this when a test modifies the engine type to avoid
|
|
// leaking global state between tests.
|
|
func saveEngineType() func() {
|
|
original := engineType
|
|
return func() { engineType = original }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// NumTokensFromString tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestNumTokensFromString_Empty(t *testing.T) {
|
|
if got := NumTokensFromString(""); got != 0 {
|
|
t.Errorf("expected 0 for empty string, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestNumTokensFromString_Positive(t *testing.T) {
|
|
for _, s := range []string{"hello world", "你好世界"} {
|
|
if got := NumTokensFromString(s); got <= 0 {
|
|
t.Errorf("NumTokensFromString(%q) = %d, want >0", s, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNumTokensFromString_VariedInputs(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
}{
|
|
{"ascii letters", "hello world"},
|
|
{"chinese characters", "你好世界"},
|
|
{"japanese characters", "こんにちは世界"},
|
|
{"korean characters", "안녕하세요세계"},
|
|
{"emoji", "👋 hello 🌍"},
|
|
{"numbers only", "1234567890"},
|
|
{"special chars", "a+b=c; d!=e"},
|
|
{"newlines and tabs", "line1\nline2\tindented"},
|
|
{"mixed content", "RAGFlow 是一款 开源的 RAG (Retrieval-Augmented Generation) 引擎"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := NumTokensFromString(tt.input)
|
|
if got <= 0 {
|
|
t.Errorf("NumTokensFromString(%q) = %d, want >0", tt.input, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNumTokensFromString_Consistency(t *testing.T) {
|
|
inputs := []string{"hello world", "你好世界", "a+b=c; d!=e"}
|
|
for _, s := range inputs {
|
|
first := NumTokensFromString(s)
|
|
second := NumTokensFromString(s)
|
|
if first != second {
|
|
t.Errorf("NumTokensFromString(%q) is not consistent: %d vs %d", s, first, second)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNumTokensFromString_LongString(t *testing.T) {
|
|
long := strings.Repeat("the quick brown fox jumps over the lazy dog. ", 200)
|
|
got := NumTokensFromString(long)
|
|
if got <= 0 {
|
|
t.Errorf("NumTokensFromString(long_string) = %d, want >0", got)
|
|
}
|
|
}
|
|
|
|
func TestNumTokensFromString_WhitespaceOnly(t *testing.T) {
|
|
for _, s := range []string{" ", "\t", "\n", " "} {
|
|
got := NumTokensFromString(s)
|
|
// Whitespace strings should still produce tokens in BPE encoding
|
|
if got == 0 {
|
|
t.Logf("NumTokensFromString(%q) = %d", s, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// RegisterEngineType tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestRegisterEngineType_Basic(t *testing.T) {
|
|
restore := saveEngineType()
|
|
defer restore()
|
|
|
|
SetEngineType("infinity")
|
|
if got := engineType; got != "infinity" {
|
|
t.Errorf("expected 'infinity', got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRegisterEngineType_Overwrite(t *testing.T) {
|
|
restore := saveEngineType()
|
|
defer restore()
|
|
|
|
SetEngineType("first")
|
|
SetEngineType("second")
|
|
if got := engineType; got != "second" {
|
|
t.Errorf("expected 'second', got %q", got)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tokenize tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestTokenize_InfinityEngine(t *testing.T) {
|
|
restore := saveEngineType()
|
|
defer restore()
|
|
SetEngineType("infinity")
|
|
|
|
inputs := []string{"hello world", "你好 世界", "", "a single word"}
|
|
for _, input := range inputs {
|
|
got, err := Tokenize(input)
|
|
if err != nil {
|
|
t.Errorf("Tokenize(%q) unexpected error: %v", input, err)
|
|
}
|
|
if got != input {
|
|
t.Errorf("Tokenize(%q) = %q, want %q", input, got, input)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTokenize_PoolNotInitialized(t *testing.T) {
|
|
restore := saveEngineType()
|
|
defer restore()
|
|
// Ensure engine type is not "infinity" so we hit the pool path
|
|
SetEngineType("")
|
|
|
|
_, err := Tokenize("hello world")
|
|
if err == nil {
|
|
t.Error("expected error when pool is not initialized, got nil")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FineGrainedTokenize tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestFineGrainedTokenize_InfinityEngine(t *testing.T) {
|
|
restore := saveEngineType()
|
|
defer restore()
|
|
SetEngineType("infinity")
|
|
|
|
inputs := []string{"hello world", "测试 分词", ""}
|
|
for _, input := range inputs {
|
|
got, err := FineGrainedTokenize(input)
|
|
if err != nil {
|
|
t.Errorf("FineGrainedTokenize(%q) unexpected error: %v", input, err)
|
|
}
|
|
if got != input {
|
|
t.Errorf("FineGrainedTokenize(%q) = %q, want %q", input, got, input)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFineGrainedTokenize_PoolNotInitialized(t *testing.T) {
|
|
restore := saveEngineType()
|
|
defer restore()
|
|
SetEngineType("")
|
|
|
|
_, err := FineGrainedTokenize("hello world")
|
|
if err == nil {
|
|
t.Error("expected error when pool is not initialized, got nil")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Error-path tests for functions that require the pool
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestTokenizeWithPosition_PoolNotInitialized(t *testing.T) {
|
|
_, err := TokenizeWithPosition("hello world")
|
|
if err == nil {
|
|
t.Error("expected error when pool is not initialized, got nil")
|
|
}
|
|
}
|
|
|
|
func TestAnalyze_PoolNotInitialized(t *testing.T) {
|
|
_, err := Analyze("hello world")
|
|
if err == nil {
|
|
t.Error("expected error when pool is not initialized, got nil")
|
|
}
|
|
}
|
|
|
|
func TestGetTermFreq_PoolNotInitialized(t *testing.T) {
|
|
got := GetTermFreq("hello")
|
|
if got != 0 {
|
|
t.Errorf("expected 0 when pool is not initialized, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestGetTermTag_PoolNotInitialized(t *testing.T) {
|
|
got := GetTermTag("hello")
|
|
if got != "" {
|
|
t.Errorf("expected empty string when pool is not initialized, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestTokenize_DefaultLanguageResetsAnalyzerState(t *testing.T) {
|
|
restore := saveEngineType()
|
|
defer restore()
|
|
SetEngineType("")
|
|
|
|
if err := Init(&PoolConfig{
|
|
DictPath: "",
|
|
MinSize: 1,
|
|
MaxSize: 1,
|
|
IdleTimeout: 5 * time.Second,
|
|
AcquireTimeout: 5 * time.Second,
|
|
}); err != nil {
|
|
t.Fatalf("Failed to initialize pool: %v", err)
|
|
}
|
|
defer Close()
|
|
|
|
sample := findEnglishDutchDifferentiator(t)
|
|
|
|
dutchGot, err := New("Dutch").Tokenize(sample.input)
|
|
if err != nil {
|
|
t.Fatalf("Tokenize(Dutch, %q) unexpected error: %v", sample.input, err)
|
|
}
|
|
if dutchGot != sample.dutch {
|
|
t.Fatalf("Tokenize(Dutch, %q) = %q, want %q", sample.input, dutchGot, sample.dutch)
|
|
}
|
|
|
|
defaultGot, err := Tokenize(sample.input)
|
|
if err != nil {
|
|
t.Fatalf("Tokenize(default, %q) unexpected error: %v", sample.input, err)
|
|
}
|
|
if defaultGot != sample.english {
|
|
t.Fatalf("Tokenize(default, %q) = %q, want explicit English result %q", sample.input, defaultGot, sample.english)
|
|
}
|
|
if defaultGot == dutchGot {
|
|
t.Fatalf("Tokenize(default, %q) unexpectedly inherited Dutch analyzer state: %q", sample.input, defaultGot)
|
|
}
|
|
}
|
|
|
|
func findEnglishDutchDifferentiator(t *testing.T) languageDifferentiator {
|
|
t.Helper()
|
|
|
|
candidates := []string{
|
|
"running",
|
|
"jumps",
|
|
"ponies",
|
|
"studies",
|
|
"wolves",
|
|
"relational",
|
|
"conditionally",
|
|
}
|
|
|
|
for _, input := range candidates {
|
|
english, err := New("English").Tokenize(input)
|
|
if err != nil {
|
|
t.Fatalf("Tokenize(English, %q) unexpected error: %v", input, err)
|
|
}
|
|
dutch, err := New("Dutch").Tokenize(input)
|
|
if err != nil {
|
|
t.Fatalf("Tokenize(Dutch, %q) unexpected error: %v", input, err)
|
|
}
|
|
if english != dutch {
|
|
return languageDifferentiator{
|
|
input: input,
|
|
english: english,
|
|
dutch: dutch,
|
|
}
|
|
}
|
|
}
|
|
|
|
t.Skip("no differentiating tokenizer sample found for English vs Dutch")
|
|
return languageDifferentiator{}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Global state tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestGetPoolStats_Nil(t *testing.T) {
|
|
// Note: globalPool is nil by default in unit tests (pool not initialized)
|
|
stats := GetPoolStats()
|
|
if stats == nil {
|
|
t.Fatal("GetPoolStats returned nil")
|
|
}
|
|
init, ok := stats["initialized"]
|
|
if !ok {
|
|
t.Fatal("missing 'initialized' key")
|
|
}
|
|
if init.(bool) {
|
|
t.Error("expected initialized=false when pool is nil")
|
|
}
|
|
}
|
|
|
|
func TestIsInitialized_Default(t *testing.T) {
|
|
if IsInitialized() {
|
|
t.Error("expected IsInitialized() = false when pool is not initialized")
|
|
}
|
|
}
|
|
|
|
func TestClose_Nil(t *testing.T) {
|
|
// Close should be safe to call with nil globalPool
|
|
Close() // no panic = pass
|
|
}
|
|
|
|
func TestClose_NilGlobalPool(t *testing.T) {
|
|
// Call Close directly after ensuring globalPool is nil
|
|
// (concurrent test may have initialized it, so handle gracefully)
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Errorf("Close() panicked: %v", r)
|
|
}
|
|
}()
|
|
Close()
|
|
}
|