diff --git a/internal/agent/canvas/factory_context.go b/internal/agent/canvas/factory_context.go
new file mode 100644
index 0000000000..a76d39a0f5
--- /dev/null
+++ b/internal/agent/canvas/factory_context.go
@@ -0,0 +1,41 @@
+//
+// 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.
+//
+
+package canvas
+
+import (
+ "context"
+
+ "ragflow/internal/agent/runtime"
+)
+
+type componentFactoryContextKey struct{}
+
+// WithComponentFactory attaches a per-pipeline component factory override
+// to ctx. BuildWorkflow uses it when constructing node bodies so one
+// pipeline instance can resolve components independently of the process-wide
+// runtime.DefaultFactory.
+func WithComponentFactory(ctx context.Context, factory runtime.ComponentFactory) context.Context {
+ if factory == nil {
+ return ctx
+ }
+ return context.WithValue(ctx, componentFactoryContextKey{}, factory)
+}
+
+func componentFactoryFromContext(ctx context.Context) runtime.ComponentFactory {
+ factory, _ := ctx.Value(componentFactoryContextKey{}).(runtime.ComponentFactory)
+ return factory
+}
diff --git a/internal/agent/canvas/loop_subgraph.go b/internal/agent/canvas/loop_subgraph.go
index 51ea1c9a38..90f04a8f41 100644
--- a/internal/agent/canvas/loop_subgraph.go
+++ b/internal/agent/canvas/loop_subgraph.go
@@ -241,7 +241,7 @@ func buildSubWorkflow(
if name == "" {
return nil, fmt.Errorf("canvas: loop %q member %q has empty component_name", loopID, cpnID)
}
- body, err := buildNodeBody(cpnID, name, c.Components[cpnID].Obj.Params)
+ body, err := buildNodeBody(ctx, cpnID, name, c.Components[cpnID].Obj.Params)
if err != nil {
return nil, err
}
diff --git a/internal/agent/canvas/node_body.go b/internal/agent/canvas/node_body.go
index 9077b37acf..80ac74ae4d 100644
--- a/internal/agent/canvas/node_body.go
+++ b/internal/agent/canvas/node_body.go
@@ -74,7 +74,7 @@ type nodeBodyFn = func(ctx context.Context, in map[string]any) (map[string]any,
// Outputs bucket. UserFillUpNodeBody tags its output itself so the
// interrupt-driven branch still attributes the resume payload to the
// right cpn.
-func buildNodeBody(cpnID, name string, params map[string]any) (nodeBodyFn, error) {
+func buildNodeBody(ctx context.Context, cpnID, name string, params map[string]any) (nodeBodyFn, error) {
if isLegacyNoOp(name) {
return legacyNoOpBody(cpnID), nil
}
@@ -89,7 +89,7 @@ func buildNodeBody(cpnID, name string, params map[string]any) (nodeBodyFn, error
if strings.EqualFold(name, "UserFillUp") {
return UserFillUpNodeBody(cpnID, params), nil
}
- if factory := runtime.DefaultFactory(); factory != nil {
+ if factory := resolveComponentFactory(ctx); factory != nil {
comp, err := factory(name, params)
if err != nil {
return nil, fmt.Errorf("canvas: component %q (%s): factory: %w", cpnID, name, err)
@@ -118,6 +118,14 @@ func buildNodeBody(cpnID, name string, params map[string]any) (nodeBodyFn, error
// components (legacyNoOpNames). It echoes the input and tags
// __legacy_noop__ so downstream debuggers can tell the node fired but
// did nothing.
+
+func resolveComponentFactory(ctx context.Context) runtime.ComponentFactory {
+ if factory := componentFactoryFromContext(ctx); factory != nil {
+ return factory
+ }
+ return runtime.DefaultFactory()
+}
+
func legacyNoOpBody(cpnID string) nodeBodyFn {
return func(_ context.Context, in map[string]any) (map[string]any, error) {
out := make(map[string]any, len(in)+2)
diff --git a/internal/agent/canvas/node_body_per_class_timeout_integration_test.go b/internal/agent/canvas/node_body_per_class_timeout_integration_test.go
index 846cdcfc41..7e2c48045a 100644
--- a/internal/agent/canvas/node_body_per_class_timeout_integration_test.go
+++ b/internal/agent/canvas/node_body_per_class_timeout_integration_test.go
@@ -112,7 +112,7 @@ func TestBuildNodeBody_PerClassTimeout_ExeSQL_3s(t *testing.T) {
}
})
- body, err := buildNodeBody("cpn-exe", "ExeSQL", nil)
+ body, err := buildNodeBody(context.Background(), "cpn-exe", "ExeSQL", nil)
if err != nil {
t.Fatalf("buildNodeBody: %v", err)
}
@@ -162,7 +162,7 @@ func TestBuildNodeBody_PerClassTimeout_TavilySearch_12s(t *testing.T) {
}
})
- body, err := buildNodeBody("cpn-tav", "TavilySearch", nil)
+ body, err := buildNodeBody(context.Background(), "cpn-tav", "TavilySearch", nil)
if err != nil {
t.Fatalf("buildNodeBody: %v", err)
}
@@ -209,7 +209,7 @@ func TestBuildNodeBody_PerClassTimeout_UnknownClass_UniformFallback(t *testing.T
}
})
- body, err := buildNodeBody("cpn-cust", "CustomComponent", nil)
+ body, err := buildNodeBody(context.Background(), "cpn-cust", "CustomComponent", nil)
if err != nil {
t.Fatalf("buildNodeBody: %v", err)
}
@@ -251,7 +251,7 @@ func TestBuildNodeBody_PerClassTimeout_PerClassEnvOverride(t *testing.T) {
}
})
- body, err := buildNodeBody("cpn-exe-ovr", "ExeSQL", nil)
+ body, err := buildNodeBody(context.Background(), "cpn-exe-ovr", "ExeSQL", nil)
if err != nil {
t.Fatalf("buildNodeBody: %v", err)
}
diff --git a/internal/agent/canvas/scheduler.go b/internal/agent/canvas/scheduler.go
index 0b2c827f24..1e4f40fe8b 100644
--- a/internal/agent/canvas/scheduler.go
+++ b/internal/agent/canvas/scheduler.go
@@ -486,7 +486,7 @@ func BuildWorkflow(ctx context.Context, c *Canvas) (*compose.Workflow[map[string
if name == "" {
return nil, fmt.Errorf("canvas: component %q has empty component_name", cpnID)
}
- body, err := buildNodeBody(cpnID, name, c.Components[cpnID].Obj.Params)
+ body, err := buildNodeBody(ctx, cpnID, name, c.Components[cpnID].Obj.Params)
if err != nil {
return nil, err
}
diff --git a/internal/dao/task_test.go b/internal/dao/task_test.go
index c4e12e371a..e4d8093545 100644
--- a/internal/dao/task_test.go
+++ b/internal/dao/task_test.go
@@ -17,6 +17,8 @@
package dao
import (
+ "fmt"
+ "strings"
"testing"
"github.com/glebarez/sqlite"
@@ -29,12 +31,18 @@ import (
func setupTaskTestDB(t *testing.T) *gorm.DB {
t.Helper()
- db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
+ dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()))
+ db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
TranslateError: true,
})
if err != nil {
t.Fatalf("failed to open sqlite: %v", err)
}
+ sqlDB, err := db.DB()
+ if err != nil {
+ t.Fatalf("failed to get sql DB: %v", err)
+ }
+ sqlDB.SetMaxOpenConns(1)
// Migrate task table (Task depends on Document for the doc_id FK,
// but SQLite doesn't enforce FKs by default)
diff --git a/internal/ingestion/component/slice3_test.go b/internal/ingestion/component/slice3_test.go
index 46c59a7906..44d05e3d19 100644
--- a/internal/ingestion/component/slice3_test.go
+++ b/internal/ingestion/component/slice3_test.go
@@ -23,6 +23,7 @@ package component
import (
"context"
+ pipe "ragflow/internal/ingestion/pipeline"
"strings"
"testing"
)
@@ -31,7 +32,7 @@ import (
// rag/flow/tokenizer.py:111 fallback. A chunk with only
// content_with_weight (no text) must tokenize the fallback text.
func TestTokenizer_FallsBackToContentWithWeight(t *testing.T) {
- requireTokenizerPool(t)
+ pipe.RequireTokenizerPool(t)
c := &TokenizerComponent{}
c.param.SearchMethod = []string{"full_text"}
c.param.Fields = []string{"text"}
@@ -62,7 +63,7 @@ func TestTokenizer_FallsBackToContentWithWeight(t *testing.T) {
// for the fallback. When both "text" and "content_with_weight"
// are present, "text" wins.
func TestTokenizer_DoesNotChangeChunkText(t *testing.T) {
- requireTokenizerPool(t)
+ pipe.RequireTokenizerPool(t)
c := &TokenizerComponent{}
c.param.SearchMethod = []string{"full_text"}
c.param.Fields = []string{"text"}
diff --git a/internal/ingestion/component/tokenizer.go b/internal/ingestion/component/tokenizer.go
index d041a18235..a49b0641ed 100644
--- a/internal/ingestion/component/tokenizer.go
+++ b/internal/ingestion/component/tokenizer.go
@@ -92,7 +92,9 @@ import (
"context"
"encoding/json"
"fmt"
+ "log"
"regexp"
+ "slices"
"strings"
"time"
@@ -109,6 +111,10 @@ const ComponentNameTokenizer = "Tokenizer"
// can shrink it; production wiring uses 60s.
var tokenizerTimeout = 60 * time.Second
+// tokenizerEmbeddingBatchSize mirrors Python's
+// settings.EMBEDDING_BATCH_SIZE default.
+var tokenizerEmbeddingBatchSize = 16
+
// titleExtRE strips a trailing file-extension (e.g. ".pdf") from the
// upstream document name before tokenizing it. Mirrors the python
// `re.sub(r"\.[a-zA-Z]+$", "", name)` in tokenizer.py:137.
@@ -120,23 +126,21 @@ var titleExtRE = regexp.MustCompile(`\.[a-zA-Z]+$`)
// tokenizer.py:79.
var htmlTableRE = regexp.MustCompile(`?(table|td|caption|tr|th)( [^<>]{0,12})?>`)
-// Embedder is the testability seam for the embedding branch. The
-// production wiring injects an implementation that resolves an
-// embedding model via `service.ModelProviderService.GetEmbeddingModel`
-// and calls its `ModelDriver.Embed`. Tests inject a stub.
-//
-// Returning one vector per input text (length len(texts), each
-// vector non-empty) is the contract; nil/error halts the component.
-type Embedder interface {
- Encode(texts []string) ([][]float64, error)
+// EmbeddingResult carries a vector plus the model-reported token usage
+// for that input batch entry.
+type EmbeddingResult struct {
+ Vector []float64
+ TokenCount int
}
-// EncodeFunc is the package-level injection point. nil means
-// "embedding disabled" — the component skips the embedding branch
-// (matching the python behaviour when `search_method` omits
-// "embedding"). Production sets this once in `main()`; tests can
-// swap it with a stub via the test helpers in `tokenizer_test.go`.
-var EncodeFunc func(tenantID, embdID string) Embedder
+// Embedder is the testability seam for the embedding branch.
+type Embedder interface {
+ MaxTokens() int
+ Encode(texts []string) ([]EmbeddingResult, error)
+}
+
+// EmbedderResolver resolves the embedder for one tokenizer invocation.
+type EmbedderResolver func(tenantID, kbID, modelID string) (Embedder, error)
// TokenizerComponent computes token counts and (optionally) embedding
// vectors for an upstream chunk list. Mirrors python
@@ -162,13 +166,18 @@ var EncodeFunc func(tenantID, embdID string) Embedder
// output_format — always "chunks" (matches python set_output)
// _created_time / _elapsed_time — TrackElapsed bookkeeping
type TokenizerComponent struct {
- param schema.TokenizerParam
+ param schema.TokenizerParam
+ resolver EmbedderResolver
}
// NewTokenizerComponent constructs a TokenizerComponent from DSL
// params. Mirrors python `TokenizerParam` defaults (search_method =
// ["full_text","embedding"], filename_embd_weight=0.1, fields=["text"]).
func NewTokenizerComponent(params map[string]any) (runtime.Component, error) {
+ return NewTokenizerComponentWithResolver(params, nil)
+}
+
+func NewTokenizerComponentWithResolver(params map[string]any, resolver EmbedderResolver) (runtime.Component, error) {
p := schema.TokenizerParam{}.Defaults()
if params != nil {
if v, ok := params["search_method"]; ok {
@@ -214,14 +223,15 @@ func NewTokenizerComponent(params map[string]any) (runtime.Component, error) {
if err := p.Validate(); err != nil {
return nil, fmt.Errorf("Tokenizer: param check: %w", err)
}
- return &TokenizerComponent{param: p}, nil
+ return &TokenizerComponent{param: p, resolver: resolver}, nil
}
// Inputs returns the parameter metadata.
func (c *TokenizerComponent) Inputs() map[string]string {
return map[string]string{
"tenant_id": "Tenant identifier used to resolve the embedding model (mirrors python self._canvas._tenant_id).",
- "model_id": "Optional explicit embedding-model override. Falls back to EncodeFunc resolution when unset.",
+ "kb_id": "Optional knowledgebase identifier used to resolve the bound embedding model when model_id is unset.",
+ "model_id": "Optional explicit embedding-model override.",
"output_format": "Upstream payload discriminator: json / markdown / text / html / chunks.",
"chunks": "List of chunk maps when output_format == \"chunks\".",
"json": "Structured parser payload when output_format == \"json\" or unset.",
@@ -253,7 +263,7 @@ func (c *TokenizerComponent) Parallelism() int { return 1 }
//
// Failure modes:
//
-// - "embedding" requested but EncodeFunc is nil → returns an
+// - "embedding" requested but resolver is nil → returns an
// error (fail-loud: same contract as python when LLMBundle is
// unconstructable).
// - Empty chunks list → returns an empty chunks output without
@@ -264,6 +274,7 @@ func (c *TokenizerComponent) Parallelism() int { return 1 }
// `full_text` is in `search_method`.
func (c *TokenizerComponent) Invoke(ctx context.Context, inputs map[string]any) (map[string]any, error) {
tenantID := getStringOr(inputs, "tenant_id", "")
+ kbID := getStringOr(inputs, "kb_id", "")
modelID := getStringOr(inputs, "model_id", "")
upstream, err := decodeTokenizerFromUpstream(inputs)
if err != nil {
@@ -273,18 +284,9 @@ func (c *TokenizerComponent) Invoke(ctx context.Context, inputs map[string]any)
name := upstream.Name
titleStem := titleExtRE.ReplaceAllString(name, "")
- // TrackElapsed wraps the whole pipeline (tokenize + embed) so the
- // upstream caller sees consistent _created_time / _elapsed_time
- // stamps matching python `ProcessBase` (helpers.go TrackElapsed).
return runtime.TrackElapsed("Tokenizer", func() (map[string]any, error) {
- // content_with_weight fallback — populate each chunk's
- // "text" from the python-equivalent field when empty.
- // Done before tokenizeChunks so the chunker's emitted
- // text is the authoritative input.
normalizeChunkTextFallback(chunks)
- // full_text pass — tokenize each chunk's text fields. Mirrors
- // python tokenizer.py:130-185.
if contains(c.param.SearchMethod, "full_text") {
if err := tokenizeChunks(chunks, titleStem); err != nil {
return nil, err
@@ -296,69 +298,145 @@ func (c *TokenizerComponent) Invoke(ctx context.Context, inputs map[string]any)
"chunks": schema.ChunkDocsToMaps(chunks),
}
- // embedding pass — batched single call (plan §AD-5a).
if contains(c.param.SearchMethod, "embedding") {
- if EncodeFunc == nil {
- return nil, fmt.Errorf("Tokenizer: embedding requested but EncodeFunc is unset")
- }
- embedder := EncodeFunc(tenantID, modelID)
- if embedder == nil {
- return nil, fmt.Errorf("Tokenizer: embedding requested but encoder resolution returned nil")
- }
-
- // Build the batched text list + index pairs.
- texts := make([]string, 0, len(chunks))
- pairs := make([]int, 0, len(chunks))
- for i, ck := range chunks {
- txt := concatFields(ck, c.param.Fields)
- txt = htmlTableRE.ReplaceAllString(txt, " ")
- txt = strings.TrimSpace(txt)
- if txt == "" {
- continue
- }
- texts = append(texts, txt)
- pairs = append(pairs, i)
- }
-
- if len(texts) > 0 {
- var (
- vects [][]float64
- encErr error
- )
- timeoutErr := runtime.WithTimeout(ctx, tokenizerTimeout, func(timeoutCtx context.Context) error {
- vects, encErr = embedder.Encode(texts)
- return encErr
- })
- if timeoutErr != nil {
- return nil, fmt.Errorf("Tokenizer: encode: %w", timeoutErr)
- }
- if len(vects) != len(pairs) {
- return nil, fmt.Errorf("Tokenizer: encode returned %d vectors for %d chunks", len(vects), len(pairs))
- }
- for k, idx := range pairs {
- ck := &chunks[idx]
- v := vects[k]
- if err := ck.SetExtraValue(fmt.Sprintf("q_%d_vec", len(v)), v); err != nil {
- return nil, fmt.Errorf("Tokenizer: vector marshal: %w", err)
- }
- }
- // token_count: best-effort approximation matching the
- // python contract — the Go Embedder doesn't surface
- // per-call token usage, so we sum
- // `NumTokensFromString` for each chunk text.
- tokenCount := 0
- for _, t := range texts {
- tokenCount += tokenizer.NumTokensFromString(t)
- }
- out["embedding_token_consumption"] = tokenCount
- out["chunks"] = schema.ChunkDocsToMaps(chunks)
+ chunks, tokenCount, err := c.embedChunks(ctx, tenantID, kbID, modelID, name, chunks)
+ if err != nil {
+ return nil, err
}
+ out["embedding_token_consumption"] = tokenCount
+ out["chunks"] = schema.ChunkDocsToMaps(chunks)
+ }
+ if err := validateTokenizerOutputs(chunks, c.param.SearchMethod, c.param.Fields); err != nil {
+ return nil, err
}
return out, nil
})
}
+func (c *TokenizerComponent) embedChunks(ctx context.Context, tenantID, kbID, modelID, name string, chunks []schema.ChunkDoc) ([]schema.ChunkDoc, int, error) {
+ if len(chunks) == 0 {
+ return chunks, 0, nil
+ }
+ if c.resolver == nil {
+ return nil, 0, fmt.Errorf("Tokenizer: embedding requested but resolver is unset")
+ }
+ embedder, err := c.resolver(tenantID, kbID, modelID)
+ if err != nil {
+ return nil, 0, fmt.Errorf("Tokenizer: resolve embedder: %w", err)
+ }
+ if embedder == nil {
+ return nil, 0, fmt.Errorf("Tokenizer: embedding requested but encoder resolution returned nil")
+ }
+
+ texts := make([]string, 0, len(chunks))
+ pairs := make([]int, 0, len(chunks))
+ for i, ck := range chunks {
+ txt := concatFields(ck, c.param.Fields)
+ txt = htmlTableRE.ReplaceAllString(txt, " ")
+ txt = strings.TrimSpace(txt)
+ if txt == "" {
+ continue
+ }
+ texts = append(texts, truncateForEmbedding(txt, embedder.MaxTokens()))
+ pairs = append(pairs, i)
+ }
+ if len(texts) == 0 {
+ return chunks, 0, nil
+ }
+
+ trimmedName := strings.TrimSpace(name)
+ var (
+ titleVec []float64
+ tokenCount int
+ hasTitleVec bool
+ )
+ if trimmedName == "" {
+ log.Printf("Tokenizer: empty name provided from upstream, embedding will skip title weighting")
+ } else {
+ titleResults, err := encodeWithTimeout(ctx, embedder, []string{trimmedName})
+ if err != nil {
+ return nil, 0, fmt.Errorf("Tokenizer: encode title: %w", err)
+ }
+ if len(titleResults) != 1 {
+ return nil, 0, fmt.Errorf("Tokenizer: encode title returned %d vectors for 1 chunk", len(titleResults))
+ }
+ titleVec = titleResults[0].Vector
+ tokenCount = titleResults[0].TokenCount
+ hasTitleVec = true
+ }
+
+ contentResults := make([]EmbeddingResult, 0, len(texts))
+ for start := 0; start < len(texts); start += tokenizerEmbeddingBatchSize {
+ end := start + tokenizerEmbeddingBatchSize
+ if end > len(texts) {
+ end = len(texts)
+ }
+ batchResults, err := encodeWithTimeout(ctx, embedder, texts[start:end])
+ if err != nil {
+ return nil, 0, fmt.Errorf("Tokenizer: encode: %w", err)
+ }
+ if len(batchResults) != end-start {
+ return nil, 0, fmt.Errorf("Tokenizer: encode returned %d vectors for %d chunks", len(batchResults), end-start)
+ }
+ for _, result := range batchResults {
+ tokenCount += result.TokenCount
+ }
+ contentResults = append(contentResults, batchResults...)
+ }
+
+ titleWeight := c.param.FilenameEmbdWeight
+ for i, idx := range pairs {
+ merged := append([]float64(nil), contentResults[i].Vector...)
+ if hasTitleVec {
+ merged, err = mergeEmbeddingVectors(titleVec, contentResults[i].Vector, titleWeight)
+ if err != nil {
+ return nil, 0, fmt.Errorf("Tokenizer: merge vectors: %w", err)
+ }
+ }
+ if err := chunks[idx].SetExtraValue(fmt.Sprintf("q_%d_vec", len(merged)), merged); err != nil {
+ return nil, 0, fmt.Errorf("Tokenizer: vector marshal: %w", err)
+ }
+ }
+ return chunks, tokenCount, nil
+}
+
+func encodeWithTimeout(ctx context.Context, embedder Embedder, texts []string) ([]EmbeddingResult, error) {
+ var (
+ results []EmbeddingResult
+ encErr error
+ )
+ timeoutErr := runtime.WithTimeout(ctx, tokenizerTimeout, func(timeoutCtx context.Context) error {
+ results, encErr = embedder.Encode(texts)
+ return encErr
+ })
+ if timeoutErr != nil {
+ return nil, timeoutErr
+ }
+ return results, nil
+}
+
+func truncateForEmbedding(text string, maxTokens int) string {
+ if maxTokens <= 10 {
+ return text
+ }
+ return tokenizer.TrimContentToTokenLimit(text, maxTokens-10)
+}
+
+func mergeEmbeddingVectors(titleVec, contentVec []float64, titleWeight float64) ([]float64, error) {
+ if len(titleVec) == 0 || len(contentVec) == 0 {
+ return nil, fmt.Errorf("empty embedding vector")
+ }
+ if len(titleVec) != len(contentVec) {
+ return nil, fmt.Errorf("unexpected embedding dimensions")
+ }
+ merged := make([]float64, len(titleVec))
+ for i := range titleVec {
+ merged[i] = titleWeight*titleVec[i] + (1-titleWeight)*contentVec[i]
+ }
+ return merged, nil
+}
+
func decodeTokenizerFromUpstream(inputs map[string]any) (schema.TokenizerFromUpstream, error) {
var out schema.TokenizerFromUpstream
if inputs == nil {
@@ -572,6 +650,58 @@ func concatFields(ck schema.ChunkDoc, fields []string) string {
return b.String()
}
+func validateTokenizerOutputs(chunks []schema.ChunkDoc, searchMethods, fields []string) error {
+ needFullText := contains(searchMethods, "full_text")
+ needEmbedding := contains(searchMethods, "embedding")
+ if !needFullText && !needEmbedding {
+ return nil
+ }
+ for i := range chunks {
+ if needFullText && requiresFullTextTokens(chunks[i]) {
+ if strings.TrimSpace(chunks[i].ContentLtks) == "" || strings.TrimSpace(chunks[i].ContentSmLtks) == "" {
+ return fmt.Errorf("Tokenizer: chunk[%d] missing full_text tokens", i)
+ }
+ }
+ if needEmbedding && requiresEmbeddingVector(chunks[i], fields) {
+ if !hasEmbeddingVector(chunks[i]) {
+ return fmt.Errorf("Tokenizer: chunk[%d] missing embedding vector", i)
+ }
+ }
+ }
+ return nil
+}
+
+func requiresFullTextTokens(ck schema.ChunkDoc) bool {
+ return strings.TrimSpace(ck.Summary) != "" || strings.TrimSpace(ck.Text) != ""
+}
+
+func requiresEmbeddingVector(ck schema.ChunkDoc, fields []string) bool {
+ return strings.TrimSpace(cleanEmbeddingText(concatFields(ck, fields))) != ""
+}
+
+func cleanEmbeddingText(text string) string {
+ return strings.TrimSpace(htmlTableRE.ReplaceAllString(text, " "))
+}
+
+func hasEmbeddingVector(ck schema.ChunkDoc) bool {
+ if len(ck.Extra) == 0 {
+ return false
+ }
+ for key, raw := range ck.Extra {
+ if !strings.HasPrefix(key, "q_") || !strings.HasSuffix(key, "_vec") {
+ continue
+ }
+ var vec []float64
+ if err := json.Unmarshal(raw, &vec); err != nil {
+ continue
+ }
+ if len(vec) > 0 {
+ return true
+ }
+ }
+ return false
+}
+
func getStringOr(m map[string]any, key, def string) string {
if v, ok := getStringLocal(m, key); ok && v != "" {
return v
@@ -598,12 +728,7 @@ func getStringLocal(m map[string]any, key string) (string, bool) {
}
func contains(s []string, v string) bool {
- for _, x := range s {
- if x == v {
- return true
- }
- }
- return false
+ return slices.Contains(s, v)
}
func intPtr(v int) *int { return &v }
diff --git a/internal/ingestion/component/tokenizer_test.go b/internal/ingestion/component/tokenizer_test.go
index f1bdc66516..478a64e495 100644
--- a/internal/ingestion/component/tokenizer_test.go
+++ b/internal/ingestion/component/tokenizer_test.go
@@ -1,3 +1,6 @@
+//go:build integration
+// +build integration
+
//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
@@ -17,16 +20,21 @@
package component
import (
+ "bytes"
"context"
"errors"
"fmt"
+ "log"
+ "math"
"os"
+ "reflect"
"strings"
"sync/atomic"
"testing"
"time"
"ragflow/internal/agent/runtime"
+ "ragflow/internal/ingestion/component/schema"
"ragflow/internal/tokenizer"
)
@@ -70,41 +78,77 @@ func requireTokenizerPool(t *testing.T) {
}
// stubEmbedder records every call and returns canned vectors.
-// Matches the Embedder contract: len(vectors) == len(texts).
+// Matches the Embedder contract: len(results) == len(texts).
type stubEmbedder struct {
- calls atomic.Int32
- dim int
- delay time.Duration
- err error
+ calls atomic.Int32
+ dim int
+ maxTokens int
+ delay time.Duration
+ err error
+ callInputs [][]string
+ resultsByCall []embeddingCallResult
+ callTokens []int
}
-func (s *stubEmbedder) Encode(texts []string) ([][]float64, error) {
+type embeddingCallResult struct {
+ vectors [][]float64
+ tokenCount int
+}
+
+func (s *stubEmbedder) MaxTokens() int {
+ return s.maxTokens
+}
+
+func (s *stubEmbedder) Encode(texts []string) ([]EmbeddingResult, error) {
s.calls.Add(1)
+ copied := append([]string(nil), texts...)
+ s.callInputs = append(s.callInputs, copied)
if s.delay > 0 {
time.Sleep(s.delay)
}
if s.err != nil {
return nil, s.err
}
- out := make([][]float64, len(texts))
+ callIdx := int(s.calls.Load()) - 1
+ var cfg embeddingCallResult
+ if callIdx < len(s.resultsByCall) {
+ cfg = s.resultsByCall[callIdx]
+ }
+ out := make([]EmbeddingResult, len(texts))
for i := range texts {
- v := make([]float64, s.dim)
- v[0] = float64(i + 1) // mark the index so callers can verify alignment
- out[i] = v
+ var v []float64
+ if i < len(cfg.vectors) {
+ v = append([]float64(nil), cfg.vectors[i]...)
+ } else {
+ v = make([]float64, s.dim)
+ v[0] = float64(i + 1)
+ }
+ tokenCount := len(texts[i])
+ if callIdx < len(s.callTokens) {
+ tokenCount = s.callTokens[callIdx]
+ } else if cfg.tokenCount > 0 {
+ tokenCount = cfg.tokenCount
+ }
+ out[i] = EmbeddingResult{Vector: v, TokenCount: tokenCount}
}
return out, nil
}
-// withStubEmbedder installs a stub Embedder and restores the previous
-// EncodeFunc on cleanup. Returns the stub so the test can assert on
-// call count / latency.
-func withStubEmbedder(t *testing.T, dim int) *stubEmbedder {
+// newStubEmbedder returns a stub embedder for instance-level resolver injection.
+func newStubEmbedder(dim int) *stubEmbedder {
+ return &stubEmbedder{dim: dim}
+}
+
+// withStubEmbedder constructs a TokenizerComponent with an instance-scoped
+// resolver backed by a stub embedder.
+func withStubEmbedder(t *testing.T, dim int) (*TokenizerComponent, *stubEmbedder) {
t.Helper()
- stub := &stubEmbedder{dim: dim}
- prev := EncodeFunc
- EncodeFunc = func(_, _ string) Embedder { return stub }
- t.Cleanup(func() { EncodeFunc = prev })
- return stub
+ stub := newStubEmbedder(dim)
+ comp, err := NewTokenizerComponentWithResolver(nil, func(_, _, _ string) (Embedder, error) { return stub, nil })
+ if err != nil {
+ t.Fatalf("NewTokenizerComponentWithResolver: %v", err)
+ }
+ return comp.(*TokenizerComponent), stub
}
// TestTokenizerComponent_Registered verifies init() enrollment
@@ -136,9 +180,10 @@ func TestTokenizerComponent_Registered(t *testing.T) {
func TestTokenizerComponent_Invoke_HappyPath(t *testing.T) {
requireTokenizerPool(t)
const dim = 4
- withStubEmbedder(t, dim)
+ c, stub := withStubEmbedder(t, dim)
- c, err := NewTokenizerComponent(map[string]any{})
+ _ = stub
+ var err error
if err != nil {
t.Fatalf("NewTokenizerComponent: %v", err)
}
@@ -185,8 +230,8 @@ func TestTokenizerComponent_Invoke_HappyPath(t *testing.T) {
if len(v) != dim {
t.Errorf("chunk[%d].%s len = %d, want %d", i, key, len(v), dim)
}
- if v[0] != float64(i+1) {
- t.Errorf("chunk[%d].%s[0] = %v, want %d (index alignment)", i, key, v[0], i+1)
+ if v[0] == 0 {
+ t.Errorf("chunk[%d].%s[0] = %v, want non-zero", i, key, v[0])
}
}
if out["output_format"] != "chunks" {
@@ -203,8 +248,9 @@ func TestTokenizerComponent_Invoke_HappyPath(t *testing.T) {
// TestTokenizerComponent_Invoke_EmptyChunks covers the no-op branch:
// empty chunk list → empty output, no panic, no encoder call.
func TestTokenizerComponent_Invoke_EmptyChunks(t *testing.T) {
- stub := withStubEmbedder(t, 4)
- c, err := NewTokenizerComponent(map[string]any{})
+ c, stub := withStubEmbedder(t, 4)
+ _ = stub
+ var err error
if err != nil {
t.Fatalf("NewTokenizerComponent: %v", err)
}
@@ -223,6 +269,9 @@ func TestTokenizerComponent_Invoke_EmptyChunks(t *testing.T) {
if stub.calls.Load() != 0 {
t.Errorf("embedder called %d times on empty input, want 0", stub.calls.Load())
}
+ if got := out["embedding_token_consumption"]; got != 0 {
+ t.Errorf("embedding_token_consumption = %v, want 0", got)
+ }
if out["output_format"] != "chunks" {
t.Errorf("output_format = %v, want chunks", out["output_format"])
}
@@ -232,8 +281,8 @@ func TestTokenizerComponent_Invoke_EmptyChunks(t *testing.T) {
// branch: nil chunks list is treated as zero-length (matches
// python `kwargs.get("chunks")` with None).
func TestTokenizerComponent_Invoke_NilChunks(t *testing.T) {
- withStubEmbedder(t, 4)
- c, _ := NewTokenizerComponent(map[string]any{})
+ c, stub := withStubEmbedder(t, 4)
+ _ = stub
out, err := c.Invoke(context.Background(), map[string]any{
"output_format": "chunks",
})
@@ -253,8 +302,8 @@ func TestTokenizerComponent_Invoke_NilChunks(t *testing.T) {
// finite).
func TestTokenizerComponent_Invoke_Unicode(t *testing.T) {
requireTokenizerPool(t)
- withStubEmbedder(t, 4)
- c, _ := NewTokenizerComponent(map[string]any{})
+ c, stub := withStubEmbedder(t, 4)
+ _ = stub
inputs := []string{
"中文测试文本",
@@ -292,7 +341,7 @@ func TestTokenizerComponent_Invoke_Unicode(t *testing.T) {
func TestTokenizerComponent_Invoke_TextPayload(t *testing.T) {
requireTokenizerPool(t)
- withStubEmbedder(t, 4)
+ _, _ = withStubEmbedder(t, 4)
c, _ := NewTokenizerComponent(map[string]any{
"search_method": []any{"full_text"},
})
@@ -318,7 +367,7 @@ func TestTokenizerComponent_Invoke_TextPayload(t *testing.T) {
func TestTokenizerComponent_Invoke_JSONPayload(t *testing.T) {
requireTokenizerPool(t)
- withStubEmbedder(t, 4)
+ _, _ = withStubEmbedder(t, 4)
c, _ := NewTokenizerComponent(map[string]any{
"search_method": []any{"full_text"},
})
@@ -340,25 +389,25 @@ func TestTokenizerComponent_Invoke_JSONPayload(t *testing.T) {
}
// TestTokenizerComponent_Invoke_BatchedEmbedding asserts the
-// embedding client is called ONCE with all chunks (not fanned per
-// chunk — plan §AD-5a). 3 chunks → 1 call.
+// embedding client is called once for the title plus once for the
+// chunk batch when all chunks fit into a single batch.
func TestTokenizerComponent_Invoke_BatchedEmbedding(t *testing.T) {
requireTokenizerPool(t)
- stub := withStubEmbedder(t, 8)
- c, _ := NewTokenizerComponent(map[string]any{})
+ c, stub := withStubEmbedder(t, 8)
chunks := []map[string]any{
{"text": "one"},
{"text": "two"},
{"text": "three"},
}
if _, err := c.Invoke(context.Background(), map[string]any{
+ "name": "doc.txt",
"output_format": "chunks",
"chunks": chunks,
}); err != nil {
t.Fatalf("Invoke: %v", err)
}
- if got := stub.calls.Load(); got != 1 {
- t.Errorf("embedder calls = %d, want 1 (single batched call)", got)
+ if got := stub.calls.Load(); got != 2 {
+ t.Errorf("embedder calls = %d, want 2 (title + single content batch)", got)
}
}
@@ -367,7 +416,7 @@ func TestTokenizerComponent_Invoke_BatchedEmbedding(t *testing.T) {
// call, but tokenized fields present.
func TestTokenizerComponent_Invoke_FullTextOnly(t *testing.T) {
requireTokenizerPool(t)
- stub := withStubEmbedder(t, 4)
+ _, stub := withStubEmbedder(t, 4)
c, _ := NewTokenizerComponent(map[string]any{
"search_method": []any{"full_text"},
})
@@ -390,25 +439,79 @@ func TestTokenizerComponent_Invoke_FullTextOnly(t *testing.T) {
}
}
-// TestTokenizerComponent_Invoke_EmbedNoEncodeFunc covers the
-// "embedding requested but EncodeFunc is nil" branch — must
-// return a clear error, not panic.
-func TestTokenizerComponent_Invoke_EmbedNoEncodeFunc(t *testing.T) {
- requireTokenizerPool(t)
- prev := EncodeFunc
- EncodeFunc = nil
- t.Cleanup(func() { EncodeFunc = prev })
+func TestTokenizerComponent_Invoke_EmbeddingOnly(t *testing.T) {
+ cIntf, err := NewTokenizerComponentWithResolver(map[string]any{
+ "search_method": []any{"embedding"},
+ }, func(_, _, _ string) (Embedder, error) {
+ return newStubEmbedder(4), nil
+ })
+ if err != nil {
+ t.Fatalf("NewTokenizerComponentWithResolver: %v", err)
+ }
+ out, err := cIntf.(*TokenizerComponent).Invoke(context.Background(), map[string]any{
+ "name": "doc.pdf",
+ "output_format": "chunks",
+ "chunks": []map[string]any{{"text": "alpha bravo"}},
+ })
+ if err != nil {
+ t.Fatalf("Invoke: %v", err)
+ }
+ got, _ := out["chunks"].([]map[string]any)
+ if len(got) != 1 {
+ t.Fatalf("chunks len = %d, want 1", len(got))
+ }
+ if got[0]["q_4_vec"] == nil {
+ t.Fatalf("q_4_vec missing: %v", got[0])
+ }
+ if got[0]["content_ltks"] != nil || got[0]["content_sm_ltks"] != nil {
+ t.Fatalf("embedding-only mode should not emit full-text tokens: %v", got[0])
+ }
+ if out["embedding_token_consumption"] == nil {
+ t.Fatal("embedding_token_consumption missing")
+ }
+}
- c, _ := NewTokenizerComponent(map[string]any{})
+func TestTokenizerComponent_Invoke_FullTextAndEmbedding(t *testing.T) {
+ requireTokenizerPool(t)
+ c, _ := withStubEmbedder(t, 4)
+ out, err := c.Invoke(context.Background(), map[string]any{
+ "name": "doc.pdf",
+ "output_format": "chunks",
+ "chunks": []map[string]any{{"text": "alpha bravo"}},
+ })
+ if err != nil {
+ t.Fatalf("Invoke: %v", err)
+ }
+ got, _ := out["chunks"].([]map[string]any)
+ if len(got) != 1 {
+ t.Fatalf("chunks len = %d, want 1", len(got))
+ }
+ if got[0]["content_ltks"] == nil || got[0]["content_sm_ltks"] == nil {
+ t.Fatalf("full-text tokens missing: %v", got[0])
+ }
+ if got[0]["q_4_vec"] == nil {
+ t.Fatalf("embedding vector missing: %v", got[0])
+ }
+ if out["embedding_token_consumption"] == nil {
+ t.Fatal("embedding_token_consumption missing")
+ }
+}
+
+// TestTokenizerComponent_Invoke_EmbedNoResolver covers the
+// "embedding requested but resolver is unset" branch — must
+// return a clear error, not panic.
+func TestTokenizerComponent_Invoke_EmbedNoResolver(t *testing.T) {
+ requireTokenizerPool(t)
+ c, _ := NewTokenizerComponent(nil)
_, err := c.Invoke(context.Background(), map[string]any{
"output_format": "chunks",
"chunks": []map[string]any{{"text": "alpha"}},
})
if err == nil {
- t.Fatal("expected error when embedding requested without EncodeFunc, got nil")
+ t.Fatal("expected error when embedding requested without resolver, got nil")
}
- if !strings.Contains(err.Error(), "EncodeFunc") {
- t.Errorf("error should mention EncodeFunc: %v", err)
+ if !strings.Contains(err.Error(), "resolver") {
+ t.Errorf("error should mention resolver: %v", err)
}
}
@@ -416,10 +519,9 @@ func TestTokenizerComponent_Invoke_EmbedNoEncodeFunc(t *testing.T) {
// propagation of an error from the embedding driver.
func TestTokenizerComponent_Invoke_EmbedderError(t *testing.T) {
requireTokenizerPool(t)
- stub := withStubEmbedder(t, 4)
+ c, stub := withStubEmbedder(t, 4)
stub.err = errors.New("simulated upstream error")
- c, _ := NewTokenizerComponent(map[string]any{})
_, err := c.Invoke(context.Background(), map[string]any{
"output_format": "chunks",
"chunks": []map[string]any{{"text": "alpha"}},
@@ -436,19 +538,17 @@ func TestTokenizerComponent_Invoke_EmbedderError(t *testing.T) {
// "embedder returned wrong number of vectors" defensive branch.
func TestTokenizerComponent_Invoke_EncoderCountMismatch(t *testing.T) {
requireTokenizerPool(t)
- stub := withStubEmbedder(t, 4)
+ _, stub := withStubEmbedder(t, 4)
// Inject an embedder that returns the wrong number of vectors
// regardless of input.
wrong := &countMismatchedEmbedder{want: 1}
- prev := EncodeFunc
- EncodeFunc = func(_, _ string) Embedder { return wrong }
- t.Cleanup(func() {
- EncodeFunc = prev
- _ = stub
- })
-
- c, _ := NewTokenizerComponent(map[string]any{})
- _, err := c.Invoke(context.Background(), map[string]any{
+ cIntf, err := NewTokenizerComponentWithResolver(nil, func(_, _, _ string) (Embedder, error) { return wrong, nil })
+ if err != nil {
+ t.Fatalf("NewTokenizerComponentWithResolver: %v", err)
+ }
+ c := cIntf.(*TokenizerComponent)
+ _ = stub
+ _, err = c.Invoke(context.Background(), map[string]any{
"output_format": "chunks",
"chunks": []map[string]any{{"text": "a"}, {"text": "b"}, {"text": "c"}},
})
@@ -462,10 +562,12 @@ func TestTokenizerComponent_Invoke_EncoderCountMismatch(t *testing.T) {
type countMismatchedEmbedder struct{ want int }
-func (c *countMismatchedEmbedder) Encode(texts []string) ([][]float64, error) {
- out := make([][]float64, c.want)
+func (c *countMismatchedEmbedder) MaxTokens() int { return 0 }
+
+func (c *countMismatchedEmbedder) Encode(texts []string) ([]EmbeddingResult, error) {
+ out := make([]EmbeddingResult, c.want)
for i := range out {
- out[i] = make([]float64, 4)
+ out[i] = EmbeddingResult{Vector: make([]float64, 4), TokenCount: 1}
}
return out, nil
}
@@ -479,10 +581,9 @@ func TestTokenizerComponent_Invoke_HonorsTimeout(t *testing.T) {
tokenizerTimeout = 50 * time.Millisecond
t.Cleanup(func() { tokenizerTimeout = prevTimeout })
- stub := withStubEmbedder(t, 4)
+ c, stub := withStubEmbedder(t, 4)
stub.delay = 500 * time.Millisecond
- c, _ := NewTokenizerComponent(map[string]any{})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -578,38 +679,29 @@ func TestTokenizerComponent_NewTokenizerComponent_BadParam(t *testing.T) {
// avoids the network round-trip while still exercising the full
// wiring (TrackElapsed, WithTimeout, batched Encode, vector
// stamping).
+
func TestTokenizerComponent_Smoke_EndToEnd(t *testing.T) {
requireTokenizerPool(t)
const dim = 1024
- withStubEmbedder(t, dim)
+ c, _ := withStubEmbedder(t, dim)
- // Build a chunk of ~1000 tokens. Each word ≈ 1 token for English
- // under cl100k_base. We pad with a recognizable sentinel so we
- // can later check tokenization fidelity if desired.
words := make([]string, 0, 1000)
for i := 0; i < 1000; i++ {
words = append(words, "ragflow")
}
chunkText := strings.Join(words, " ")
- // Sanity-check the count is in the expected ballpark (cl100k_base
- // may over- or under-count; we only assert the order of magnitude).
preflightTokens := tokenizer.NumTokensFromString(chunkText)
if preflightTokens < 100 || preflightTokens > 5000 {
t.Logf("preflight token count = %d (acceptable range 100-5000)", preflightTokens)
}
- c, _ := NewTokenizerComponent(map[string]any{})
- chunks := []map[string]any{
- {"text": chunkText},
- }
-
start := time.Now()
out, err := c.Invoke(context.Background(), map[string]any{
"tenant_id": "tenant-smoke",
"model_id": "embd-smoke",
"name": "smoke.pdf",
"output_format": "chunks",
- "chunks": chunks,
+ "chunks": []map[string]any{{"text": chunkText}},
})
elapsed := time.Since(start)
@@ -617,7 +709,7 @@ func TestTokenizerComponent_Smoke_EndToEnd(t *testing.T) {
t.Fatalf("Invoke: %v", err)
}
if elapsed >= 5*time.Second {
- t.Errorf("elapsed %v exceeds §R3 ceiling of 5s", elapsed)
+ t.Errorf("elapsed %v exceeds 5s ceiling", elapsed)
}
got, ok := out["chunks"].([]map[string]any)
@@ -631,7 +723,6 @@ func TestTokenizerComponent_Smoke_EndToEnd(t *testing.T) {
if len(vec) != dim {
t.Errorf("vector len = %d, want %d", len(vec), dim)
}
- // "non-zero vector" assertion: at least one element is non-zero.
nonZero := 0
for _, x := range vec {
if x != 0 {
@@ -639,10 +730,287 @@ func TestTokenizerComponent_Smoke_EndToEnd(t *testing.T) {
}
}
if nonZero == 0 {
- t.Error("vector is all zeros (smoke contract: non-zero vector returned)")
+ t.Error("vector is all zeros")
}
- // No panic == pass; explicitly assert log message.
- t.Logf("smoke complete: chunks=%d elapsed=%v tokens≈%d vec_dim=%d",
+ t.Logf("smoke complete: chunks=%d elapsed=%v tokens=%d vec_dim=%d",
len(got), elapsed, preflightTokens, len(vec))
}
+
+func TestTokenizerComponent_Embedding_MergesTitleAndContentVectors(t *testing.T) {
+ c, stub := withStubEmbedder(t, 2)
+ stub.resultsByCall = []embeddingCallResult{
+ {vectors: [][]float64{{10, 20}}, tokenCount: 7},
+ {vectors: [][]float64{{1, 2}, {3, 4}}, tokenCount: 11},
+ }
+ out, err := c.Invoke(context.Background(), map[string]any{
+ "name": "doc.pdf",
+ "output_format": "chunks",
+ "chunks": []map[string]any{{"text": "alpha"}, {"text": "beta"}},
+ })
+ if err != nil {
+ t.Fatalf("Invoke: %v", err)
+ }
+ got, _ := out["chunks"].([]map[string]any)
+ want0 := []float64{1.9, 3.8}
+ want1 := []float64{3.7, 5.6}
+ if !floatSliceClose(got[0]["q_2_vec"].([]float64), want0) {
+ t.Fatalf("chunk[0] q_2_vec = %v, want %v", got[0]["q_2_vec"], want0)
+ }
+ if !floatSliceClose(got[1]["q_2_vec"].([]float64), want1) {
+ t.Fatalf("chunk[1] q_2_vec = %v, want %v", got[1]["q_2_vec"], want1)
+ }
+}
+
+func TestTokenizerComponent_Embedding_UsesFilenameWeight(t *testing.T) {
+ cIntf, err := NewTokenizerComponentWithResolver(map[string]any{
+ "filename_embd_weight": 0.25,
+ }, func(_, _, _ string) (Embedder, error) {
+ stub := newStubEmbedder(2)
+ stub.resultsByCall = []embeddingCallResult{
+ {vectors: [][]float64{{8, 8}}, tokenCount: 3},
+ {vectors: [][]float64{{2, 2}}, tokenCount: 5},
+ }
+ return stub, nil
+ })
+ if err != nil {
+ t.Fatalf("NewTokenizerComponentWithResolver: %v", err)
+ }
+ c := cIntf.(*TokenizerComponent)
+ out, err := c.Invoke(context.Background(), map[string]any{
+ "name": "doc.pdf",
+ "output_format": "chunks",
+ "chunks": []map[string]any{{"text": "alpha"}},
+ })
+ if err != nil {
+ t.Fatalf("Invoke: %v", err)
+ }
+ got, _ := out["chunks"].([]map[string]any)
+ want := []float64{3.5, 3.5}
+ if !floatSliceClose(got[0]["q_2_vec"].([]float64), want) {
+ t.Fatalf("q_2_vec = %v, want %v", got[0]["q_2_vec"], want)
+ }
+}
+
+func TestTokenizerComponent_Embedding_EmptyNameWarnsAndUsesContentVector(t *testing.T) {
+ c, stub := withStubEmbedder(t, 2)
+ stub.resultsByCall = []embeddingCallResult{{vectors: [][]float64{{2, 4}}, tokenCount: 5}}
+
+ var buf bytes.Buffer
+ prevWriter := log.Writer()
+ prevFlags := log.Flags()
+ log.SetOutput(&buf)
+ log.SetFlags(0)
+ t.Cleanup(func() {
+ log.SetOutput(prevWriter)
+ log.SetFlags(prevFlags)
+ })
+
+ out, err := c.Invoke(context.Background(), map[string]any{
+ "name": " ",
+ "output_format": "chunks",
+ "chunks": []map[string]any{{"text": "alpha"}},
+ })
+ if err != nil {
+ t.Fatalf("Invoke: %v", err)
+ }
+ if got := stub.calls.Load(); got != 1 {
+ t.Fatalf("embedder calls = %d, want 1 (content only)", got)
+ }
+ if !strings.Contains(buf.String(), "empty name provided from upstream") {
+ t.Fatalf("log output = %q, want empty-name warning", buf.String())
+ }
+ got, _ := out["chunks"].([]map[string]any)
+ want := []float64{2, 4}
+ if !floatSliceClose(got[0]["q_2_vec"].([]float64), want) {
+ t.Fatalf("q_2_vec = %v, want %v", got[0]["q_2_vec"], want)
+ }
+ if got := out["embedding_token_consumption"]; got != 5 {
+ t.Fatalf("embedding_token_consumption = %v, want 5", got)
+ }
+}
+
+func TestTokenizerComponent_Embedding_TruncatesByMaxTokensMinus10(t *testing.T) {
+ c, stub := withStubEmbedder(t, 2)
+ stub.maxTokens = 12
+ longText := strings.Repeat("hello world ", 20)
+ if _, err := c.Invoke(context.Background(), map[string]any{
+ "name": "doc.pdf",
+ "output_format": "chunks",
+ "chunks": []map[string]any{{"text": longText}},
+ }); err != nil {
+ t.Fatalf("Invoke: %v", err)
+ }
+ if len(stub.callInputs) != 2 {
+ t.Fatalf("callInputs len = %d, want 2", len(stub.callInputs))
+ }
+ if len(stub.callInputs[1]) != 1 {
+ t.Fatalf("content batch size = %d, want 1", len(stub.callInputs[1]))
+ }
+ if got := stub.callInputs[1][0]; len(got) >= len(longText) {
+ t.Fatalf("content text was not truncated: original=%d got=%d", len(longText), len(got))
+ }
+}
+
+func TestTokenizerComponent_Embedding_SkipsEmptyCleanedTextsButReturnsZeroWhenAllSkipped(t *testing.T) {
+ c, stub := withStubEmbedder(t, 2)
+ out, err := c.Invoke(context.Background(), map[string]any{
+ "name": "doc.pdf",
+ "output_format": "chunks",
+ "chunks": []map[string]any{
+ {"text": "
"},
+ {"text": " "},
+ },
+ })
+ if err != nil {
+ t.Fatalf("Invoke: %v", err)
+ }
+ if got := stub.calls.Load(); got != 0 {
+ t.Fatalf("embedder calls = %d, want 0", got)
+ }
+ if got := out["embedding_token_consumption"]; got != 0 {
+ t.Fatalf("embedding_token_consumption = %v, want 0", got)
+ }
+ got, _ := out["chunks"].([]map[string]any)
+ for i, ck := range got {
+ if _, ok := ck["q_2_vec"]; ok {
+ t.Fatalf("chunk[%d] should not have vector: %v", i, ck)
+ }
+ }
+}
+
+func TestTokenizerComponent_Embedding_SetsTokenConsumptionIncludingTitleCall(t *testing.T) {
+ c, stub := withStubEmbedder(t, 2)
+ stub.callTokens = []int{3, 5, 7}
+ prevBatchSize := tokenizerEmbeddingBatchSize
+ tokenizerEmbeddingBatchSize = 1
+ t.Cleanup(func() { tokenizerEmbeddingBatchSize = prevBatchSize })
+
+ out, err := c.Invoke(context.Background(), map[string]any{
+ "name": "doc.pdf",
+ "output_format": "chunks",
+ "chunks": []map[string]any{{"text": "alpha"}, {"text": "beta"}},
+ })
+ if err != nil {
+ t.Fatalf("Invoke: %v", err)
+ }
+ if got := out["embedding_token_consumption"]; got != 15 {
+ t.Fatalf("embedding_token_consumption = %v, want 15", got)
+ }
+}
+
+func TestTokenizerComponent_Embedding_BatchesByConfiguredBatchSize(t *testing.T) {
+ c, stub := withStubEmbedder(t, 2)
+ prevBatchSize := tokenizerEmbeddingBatchSize
+ tokenizerEmbeddingBatchSize = 2
+ t.Cleanup(func() { tokenizerEmbeddingBatchSize = prevBatchSize })
+
+ if _, err := c.Invoke(context.Background(), map[string]any{
+ "name": "doc.pdf",
+ "output_format": "chunks",
+ "chunks": []map[string]any{
+ {"text": "one"},
+ {"text": "two"},
+ {"text": "three"},
+ {"text": "four"},
+ {"text": "five"},
+ },
+ }); err != nil {
+ t.Fatalf("Invoke: %v", err)
+ }
+ if got := stub.calls.Load(); got != 4 {
+ t.Fatalf("embedder calls = %d, want 4 (1 title + 3 content batches)", got)
+ }
+ wantInputs := [][]string{{"doc.pdf"}, {"one", "two"}, {"three", "four"}, {"five"}}
+ if !reflect.DeepEqual(stub.callInputs, wantInputs) {
+ t.Fatalf("call inputs = %#v, want %#v", stub.callInputs, wantInputs)
+ }
+}
+
+func TestTokenizerComponent_Embedding_ZeroChunksStillEmitsConsumptionZero(t *testing.T) {
+ c, stub := withStubEmbedder(t, 2)
+ out, err := c.Invoke(context.Background(), map[string]any{
+ "name": "doc.pdf",
+ "output_format": "chunks",
+ "chunks": []map[string]any{},
+ })
+ if err != nil {
+ t.Fatalf("Invoke: %v", err)
+ }
+ if got := stub.calls.Load(); got != 0 {
+ t.Fatalf("embedder calls = %d, want 0", got)
+ }
+ if got := out["embedding_token_consumption"]; got != 0 {
+ t.Fatalf("embedding_token_consumption = %v, want 0", got)
+ }
+}
+
+func floatSliceClose(got, want []float64) bool {
+ if len(got) != len(want) {
+ return false
+ }
+ for i := range got {
+ if math.Abs(got[i]-want[i]) > 1e-9 {
+ return false
+ }
+ }
+ return true
+}
+
+func TestValidateTokenizerOutputs_FullTextMissingReturnsError(t *testing.T) {
+ err := validateTokenizerOutputs([]schema.ChunkDoc{{Text: "alpha"}}, []string{"full_text"}, []string{"text"})
+ if err == nil || !strings.Contains(err.Error(), "missing full_text tokens") {
+ t.Fatalf("err = %v, want missing full_text tokens", err)
+ }
+}
+
+func TestValidateTokenizerOutputs_EmbeddingMissingReturnsError(t *testing.T) {
+ err := validateTokenizerOutputs([]schema.ChunkDoc{{Text: "alpha"}}, []string{"embedding"}, []string{"text"})
+ if err == nil || !strings.Contains(err.Error(), "missing embedding vector") {
+ t.Fatalf("err = %v, want missing embedding vector", err)
+ }
+}
+
+func TestValidateTokenizerOutputs_BothModesFailWhenOneMissing(t *testing.T) {
+ ck := schema.ChunkDoc{Text: "alpha", ContentLtks: "tok", ContentSmLtks: "sm"}
+ err := validateTokenizerOutputs([]schema.ChunkDoc{ck}, []string{"full_text", "embedding"}, []string{"text"})
+ if err == nil || !strings.Contains(err.Error(), "missing embedding vector") {
+ t.Fatalf("err = %v, want missing embedding vector", err)
+ }
+}
+
+func TestTokenizerComponent_InstanceResolversDoNotLeakAcrossComponents(t *testing.T) {
+ requireTokenizerPool(t)
+ compAIntf, err := NewTokenizerComponentWithResolver(nil, func(_, _, _ string) (Embedder, error) {
+ stub := newStubEmbedder(2)
+ stub.resultsByCall = []embeddingCallResult{{vectors: [][]float64{{10, 10}}, tokenCount: 1}, {vectors: [][]float64{{1, 1}}, tokenCount: 1}}
+ return stub, nil
+ })
+ if err != nil {
+ t.Fatalf("NewTokenizerComponentWithResolver(A): %v", err)
+ }
+ compBIntf, err := NewTokenizerComponentWithResolver(nil, func(_, _, _ string) (Embedder, error) {
+ stub := newStubEmbedder(2)
+ stub.resultsByCall = []embeddingCallResult{{vectors: [][]float64{{20, 20}}, tokenCount: 1}, {vectors: [][]float64{{2, 2}}, tokenCount: 1}}
+ return stub, nil
+ })
+ if err != nil {
+ t.Fatalf("NewTokenizerComponentWithResolver(B): %v", err)
+ }
+ compA := compAIntf.(*TokenizerComponent)
+ compB := compBIntf.(*TokenizerComponent)
+
+ outA, err := compA.Invoke(context.Background(), map[string]any{"name": "docA", "output_format": "chunks", "chunks": []map[string]any{{"text": "alpha"}}})
+ if err != nil {
+ t.Fatalf("Invoke A: %v", err)
+ }
+ outB, err := compB.Invoke(context.Background(), map[string]any{"name": "docB", "output_format": "chunks", "chunks": []map[string]any{{"text": "beta"}}})
+ if err != nil {
+ t.Fatalf("Invoke B: %v", err)
+ }
+ vecA := outA["chunks"].([]map[string]any)[0]["q_2_vec"].([]float64)
+ vecB := outB["chunks"].([]map[string]any)[0]["q_2_vec"].([]float64)
+ if reflect.DeepEqual(vecA, vecB) {
+ t.Fatalf("instance resolvers leaked: vecA=%v vecB=%v", vecA, vecB)
+ }
+}
diff --git a/internal/ingestion/pipeline/pipeline.go b/internal/ingestion/pipeline/pipeline.go
index def41681c0..0c5e273db2 100644
--- a/internal/ingestion/pipeline/pipeline.go
+++ b/internal/ingestion/pipeline/pipeline.go
@@ -29,8 +29,9 @@ import (
// Pipeline is a compiled ingestion canvas plus task-scoped metadata.
type Pipeline struct {
- taskID string
- canvas *canvas.Canvas
+ taskID string
+ canvas *canvas.Canvas
+ factory runtime.ComponentFactory
}
// NewPipelineFromDSL compiles the canonical ingestion canvas DSL.
@@ -55,6 +56,17 @@ func NewPipelineFromDSL(dsl []byte, taskID string) (*Pipeline, error) {
}, nil
}
+// WithComponentFactory installs an instance-scoped factory override for this
+// pipeline. It is used during canvas compilation so one pipeline run can
+// construct task-specific component instances without mutating the process-wide
+// runtime default factory.
+func (p *Pipeline) WithComponentFactory(factory runtime.ComponentFactory) *Pipeline {
+ if p != nil {
+ p.factory = factory
+ }
+ return p
+}
+
func unwrapCanvasDSL(raw map[string]any) (map[string]any, error) {
if len(raw) == 0 {
return nil, errNilDSL
@@ -117,7 +129,11 @@ func (p *Pipeline) Run(ctx context.Context, inputs map[string]any) (map[string]a
return nil, fmt.Errorf("pipeline: Run: runtime default component factory is not installed")
}
- compiled, err := canvas.Compile(ctx, p.canvas)
+ compileCtx := ctx
+ if p.factory != nil {
+ compileCtx = canvas.WithComponentFactory(compileCtx, p.factory)
+ }
+ compiled, err := canvas.Compile(compileCtx, p.canvas)
if err != nil {
return nil, fmt.Errorf("pipeline: Run: compile canvas: %w", err)
}
diff --git a/internal/ingestion/pipeline/pipeline_test.go b/internal/ingestion/pipeline/pipeline_test.go
index dadd197566..ec0eb86a30 100644
--- a/internal/ingestion/pipeline/pipeline_test.go
+++ b/internal/ingestion/pipeline/pipeline_test.go
@@ -18,6 +18,8 @@ package pipeline
import (
"context"
+ "fmt"
+ "sync"
"testing"
"ragflow/internal/agent/runtime"
@@ -150,3 +152,122 @@ func (e *errCanvasStage) Invoke(_ context.Context, _ map[string]any) (map[string
func (e *errCanvasStage) Parallelism() int { return 1 }
func (e *errCanvasStage) Inputs() map[string]string { return nil }
func (e *errCanvasStage) Outputs() map[string]string { return nil }
+
+type factorySentinelStage struct {
+ marker string
+}
+
+func (s *factorySentinelStage) Invoke(_ context.Context, inputs map[string]any) (map[string]any, error) {
+ out := cloneMapOrEmpty(inputs)
+ out["marker"] = s.marker
+ return out, nil
+}
+
+func TestPipelineRun_InstanceFactoryOverridesDefaultFactory(t *testing.T) {
+ origFactory := runtime.DefaultFactory()
+ runtime.SetDefaultFactory(func(_ string, _ map[string]any) (runtime.Component, error) {
+ return &factorySentinelStage{marker: "default"}, nil
+ })
+ defer runtime.SetDefaultFactory(origFactory)
+
+ pipe, err := NewPipelineFromDSL([]byte(`{
+ "dsl": {
+ "components": {
+ "begin": {"obj": {"component_name": "Begin", "params": {}}, "downstream": ["stage"]},
+ "stage": {"obj": {"component_name": "custom-stage", "params": {}}, "upstream": ["begin"]}
+ },
+ "path": ["begin", "stage"],
+ "graph": {"nodes": []}
+ }
+ }`), "task-instance-factory")
+ if err != nil {
+ t.Fatalf("NewPipelineFromDSL: %v", err)
+ }
+
+ pipe.WithComponentFactory(func(_ string, _ map[string]any) (runtime.Component, error) {
+ return &factorySentinelStage{marker: "instance"}, nil
+ })
+
+ out, err := pipe.Run(context.Background(), map[string]any{"name": "doc"})
+ if err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ stage, ok := out["stage"].(map[string]any)
+ if !ok {
+ t.Fatalf("stage = %T, want map[string]any", out["stage"])
+ }
+ if got := stage["marker"]; got != "instance" {
+ t.Fatalf("stage.marker = %v, want instance", got)
+ }
+}
+
+func TestPipelineRun_TaskScopedFactoriesDoNotLeakAcrossConcurrentPipelines(t *testing.T) {
+ origFactory := runtime.DefaultFactory()
+ runtime.SetDefaultFactory(func(_ string, _ map[string]any) (runtime.Component, error) {
+ return &factorySentinelStage{marker: "default"}, nil
+ })
+ defer runtime.SetDefaultFactory(origFactory)
+
+ newPipe := func(taskID string) *Pipeline {
+ pipe, err := NewPipelineFromDSL([]byte(`{
+ "dsl": {
+ "components": {
+ "begin": {"obj": {"component_name": "Begin", "params": {}}, "downstream": ["stage"]},
+ "stage": {"obj": {"component_name": "custom-stage", "params": {}}, "upstream": ["begin"]}
+ },
+ "path": ["begin", "stage"],
+ "graph": {"nodes": []}
+ }
+ }`), taskID)
+ if err != nil {
+ t.Fatalf("NewPipelineFromDSL(%s): %v", taskID, err)
+ }
+ return pipe
+ }
+
+ pipeA := newPipe("task-A")
+ pipeB := newPipe("task-B")
+ pipeA.WithComponentFactory(func(_ string, _ map[string]any) (runtime.Component, error) {
+ return &factorySentinelStage{marker: "A"}, nil
+ })
+ pipeB.WithComponentFactory(func(_ string, _ map[string]any) (runtime.Component, error) {
+ return &factorySentinelStage{marker: "B"}, nil
+ })
+
+ var wg sync.WaitGroup
+ type result struct {
+ marker string
+ err error
+ }
+ results := make(chan result, 2)
+ run := func(pipe *Pipeline) {
+ defer wg.Done()
+ out, err := pipe.Run(context.Background(), map[string]any{"name": "doc"})
+ if err != nil {
+ results <- result{err: err}
+ return
+ }
+ stage, ok := out["stage"].(map[string]any)
+ if !ok {
+ results <- result{err: fmt.Errorf("stage = %T", out["stage"])}
+ return
+ }
+ results <- result{marker: stage["marker"].(string)}
+ }
+ wg.Add(2)
+ go run(pipeA)
+ go run(pipeB)
+ wg.Wait()
+ close(results)
+
+ got := map[string]int{}
+ for res := range results {
+ if res.err != nil {
+ t.Fatalf("Run: %v", res.err)
+ }
+ got[res.marker]++
+ }
+ if got["A"] != 1 || got["B"] != 1 {
+ t.Fatalf("markers = %#v, want one A and one B", got)
+ }
+}
diff --git a/internal/ingestion/pipeline/real_storage_integration_test.go b/internal/ingestion/pipeline/real_storage_integration_test.go
index f5dd4f69ea..8faa6d31cb 100644
--- a/internal/ingestion/pipeline/real_storage_integration_test.go
+++ b/internal/ingestion/pipeline/real_storage_integration_test.go
@@ -19,6 +19,7 @@ import (
"ragflow/internal/dao"
"ragflow/internal/entity"
componentpkg "ragflow/internal/ingestion/component"
+ _ "ragflow/internal/ingestion/component/chunker"
"ragflow/internal/server"
"ragflow/internal/storage"
@@ -30,7 +31,7 @@ import (
func TestPipelineRun_TemplateGeneral_RealMySQLMinIO_OutputShape(t *testing.T) {
prepareTokenizerResourceForIntegration(t)
- requireTokenizerPool(t)
+ RequireTokenizerPool(t)
cfg := mustLoadRealIntegrationConfig(t)
realDB := mustOpenRealMySQL(t, cfg)
@@ -198,25 +199,12 @@ func prepareTokenizerResourceForIntegration(t *testing.T) {
if os.Getenv("RAGFLOW_DICT_PATH") != "" {
return
}
- srcDir := filepath.Join(repoRootFromPipelineTest(t), ".venv", "lib", "python3.13", "site-packages", "infinity")
- if _, err := os.Stat(filepath.Join(srcDir, "huqie.txt")); err != nil {
- t.Skipf("tokenizer resource source not found at %s: %v", srcDir, err)
+ const systemDictPath = "/usr/share/infinity/resource"
+ if _, err := os.Stat(filepath.Join(systemDictPath, "rag", "huqie.txt")); err != nil {
+ t.Skipf("system tokenizer resource not found at %s: %v", systemDictPath, err)
}
- if _, err := os.Stat(filepath.Join(srcDir, "huqie.txt.trie")); err != nil {
- t.Skipf("tokenizer trie source not found at %s: %v", srcDir, err)
- }
- root := t.TempDir()
- ragDir := filepath.Join(root, "rag")
- if err := os.MkdirAll(ragDir, 0o755); err != nil {
- t.Fatalf("mkdir rag tokenizer dir: %v", err)
- }
- mustSymlink(t, filepath.Join(srcDir, "huqie.txt"), filepath.Join(ragDir, "huqie.txt"))
- mustSymlink(t, filepath.Join(srcDir, "huqie.txt.trie"), filepath.Join(ragDir, "huqie.trie"))
- mustWriteTokenizerPOSDef(t, filepath.Join(srcDir, "huqie.txt"), filepath.Join(ragDir, "pos-id.def"))
- mustPrepareTokenizerWordNet(t, root)
- mustPrepareTokenizerOpenCC(t, root)
- if err := os.Setenv("RAGFLOW_DICT_PATH", root); err != nil {
- t.Fatalf("set RAGFLOW_DICT_PATH: %v", err)
+ if err := os.Setenv("RAGFLOW_DICT_PATH", systemDictPath); err != nil {
+ t.Fatalf("set RAGFLOW_DICT_PATH=%s: %v", systemDictPath, err)
}
t.Cleanup(func() {
_ = os.Unsetenv("RAGFLOW_DICT_PATH")
diff --git a/internal/ingestion/pipeline/template_integration_test.go b/internal/ingestion/pipeline/template_integration_test.go
index bed778414b..b7249876bc 100644
--- a/internal/ingestion/pipeline/template_integration_test.go
+++ b/internal/ingestion/pipeline/template_integration_test.go
@@ -1,19 +1,21 @@
-//
-// 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 embedding
+// +build embedding
+// 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.
+
+// TODO: This test should be part of unit test once "file name" issue for embedding is fixed.
package pipeline
import (
@@ -24,32 +26,35 @@ import (
"os"
"os/exec"
"path/filepath"
- goruntime "runtime"
"sort"
"strings"
"testing"
- "time"
- _ "ragflow/internal/ingestion/component"
+ "ragflow/internal/agent/runtime"
componentpkg "ragflow/internal/ingestion/component"
_ "ragflow/internal/ingestion/component/chunker"
"ragflow/internal/storage"
- "ragflow/internal/tokenizer"
"github.com/signintech/gopdf"
)
type fixedEmbedder struct{}
-func (fixedEmbedder) Encode(texts []string) ([][]float64, error) {
- out := make([][]float64, 0, len(texts))
+func (fixedEmbedder) MaxTokens() int { return 0 }
+
+func (fixedEmbedder) Encode(texts []string) ([]componentpkg.EmbeddingResult, error) {
+ out := make([]componentpkg.EmbeddingResult, 0, len(texts))
for _, text := range texts {
- out = append(out, []float64{float64(len(text)), 1, 2, 3})
+ out = append(out, componentpkg.EmbeddingResult{
+ Vector: []float64{float64(len(text)), 1, 2, 3},
+ TokenCount: len(text),
+ })
}
return out, nil
}
func TestPipelineRun_TemplateGeneral_RealComponents(t *testing.T) {
+
requireTokenizerPool(t)
templatePath := filepath.Join(repoRootFromPipelineTest(t), "agent", "templates", "ingestion_pipeline_general.json")
@@ -75,6 +80,7 @@ func TestPipelineRun_TemplateGeneral_RealComponents(t *testing.T) {
if err != nil {
t.Fatalf("NewPipelineFromDSL: %v", err)
}
+ attachFixedEmbedderFactory(t, pipe)
out, err := pipe.Run(context.Background(), map[string]any{
"doc_id": docID,
})
@@ -106,10 +112,11 @@ func TestPipelineRun_TemplateGeneral_RealComponents(t *testing.T) {
t.Fatalf("chunks[%d].content_sm_ltks missing or empty: %v", i, chunks[i]["content_sm_ltks"])
}
vec := floatSliceFromAny(t, chunks[i]["q_4_vec"])
- if len(vec) != 4 || vec[0] != float64(len(wantText)) {
- t.Fatalf("chunks[%d].q_4_vec = %v, want first=%v", i, vec, float64(len(wantText)))
+ wantFirst := expectedFixedEmbedderFirst("", wantText)
+ if len(vec) != 4 || !approxFloat(vec[0], wantFirst) {
+ t.Fatalf("chunks[%d].q_4_vec = %v, want first=%v", i, vec, wantFirst)
}
- totalTokens += tokenizer.NumTokensFromString(wantText)
+ totalTokens += len(wantText)
}
if got := payload["embedding_token_consumption"]; got != totalTokens {
t.Fatalf("embedding_token_consumption = %v, want %d", got, totalTokens)
@@ -164,6 +171,7 @@ func TestPipelineRun_TemplateOne_RealComponents(t *testing.T) {
if err != nil {
t.Fatalf("NewPipelineFromDSL: %v", err)
}
+ attachFixedEmbedderFactory(t, pipe)
out, err := pipe.Run(context.Background(), map[string]any{
"doc_id": docID,
})
@@ -174,7 +182,7 @@ func TestPipelineRun_TemplateOne_RealComponents(t *testing.T) {
wantTexts := []string{"Alpha paragraph.", "Beta paragraph."}
wantMergedText := "Alpha paragraph.\nBeta paragraph."
- assertTokenizerTerminalChunk(t, payload, wantMergedText)
+ assertTokenizerTerminalChunk(t, payload, "", wantMergedText)
state := stateFromRunOutput(t, out)
fileState, ok := state["File"]
@@ -224,7 +232,7 @@ func TestPipelineRun_TemplateOne_RealComponents(t *testing.T) {
}
}
-func TestPipelineRun_TemplateOne_RealComponents_PDFDeepDocChunking(t *testing.T) {
+func TestPipelineRun_TemplateOne_RealComponents_PDFDeepdocChunking(t *testing.T) {
requireTokenizerPool(t)
t.Setenv("DEEPDOC_URL", "")
t.Setenv("OSSDEEPDOC_URL", "")
@@ -251,6 +259,7 @@ func TestPipelineRun_TemplateOne_RealComponents_PDFDeepDocChunking(t *testing.T)
if err != nil {
t.Fatalf("NewPipelineFromDSL: %v", err)
}
+ attachFixedEmbedderFactory(t, pipe)
out, err := pipe.Run(context.Background(), map[string]any{
"doc_id": docID,
})
@@ -275,11 +284,13 @@ func TestPipelineRun_TemplateOne_RealComponents_PDFDeepDocChunking(t *testing.T)
}
vec := floatSliceFromAny(t, chunks[0]["q_4_vec"])
trimmedChunkText := strings.TrimSpace(chunkText)
- if len(vec) != 4 || vec[0] != float64(len(trimmedChunkText)) {
- t.Fatalf("chunks[0].q_4_vec = %v, want first=%v", vec, float64(len(trimmedChunkText)))
+ wantFirst := expectedFixedEmbedderFirst("", trimmedChunkText)
+ if len(vec) != 4 || !approxFloat(vec[0], wantFirst) {
+ t.Fatalf("chunks[0].q_4_vec = %v, want first=%v", vec, wantFirst)
}
- if got := payload["embedding_token_consumption"]; got != tokenizer.NumTokensFromString(trimmedChunkText) {
- t.Fatalf("embedding_token_consumption = %v, want %d", got, tokenizer.NumTokensFromString(trimmedChunkText))
+ wantTokens := len(trimmedChunkText)
+ if got := payload["embedding_token_consumption"]; got != wantTokens {
+ t.Fatalf("embedding_token_consumption = %v, want %d", got, wantTokens)
}
state := stateFromRunOutput(t, out)
@@ -346,6 +357,7 @@ func TestPipelineRun_TemplateManual_RealComponents(t *testing.T) {
if err != nil {
t.Fatalf("NewPipelineFromDSL: %v", err)
}
+ attachFixedEmbedderFactory(t, pipe)
out, err := pipe.Run(context.Background(), map[string]any{
"doc_id": docID,
})
@@ -376,10 +388,11 @@ func TestPipelineRun_TemplateManual_RealComponents(t *testing.T) {
}
vec := floatSliceFromAny(t, chunks[i]["q_4_vec"])
wantEmbedText := strings.TrimSpace(wantText)
- if len(vec) != 4 || vec[0] != float64(len(wantEmbedText)) {
- t.Fatalf("chunks[%d].q_4_vec = %v, want first=%v", i, vec, float64(len(wantEmbedText)))
+ wantFirst := expectedFixedEmbedderFirst("", wantEmbedText)
+ if len(vec) != 4 || !approxFloat(vec[0], wantFirst) {
+ t.Fatalf("chunks[%d].q_4_vec = %v, want first=%v", i, vec, wantFirst)
}
- totalTokens += tokenizer.NumTokensFromString(wantText)
+ totalTokens += len(wantEmbedText)
}
if got := payload["embedding_token_consumption"]; got != totalTokens {
t.Fatalf("embedding_token_consumption = %v, want %d", got, totalTokens)
@@ -410,7 +423,7 @@ func TestPipelineRun_TemplateManual_RealComponents(t *testing.T) {
}
for i, wantText := range wantChunkTexts {
if got := chunkerChunks[i]["text"]; got != wantText {
- t.Fatalf("chunker chunks[%d].text = %v, want %q", i, got, wantText)
+ t.Fatalf("chunker chunk[%d].text = %v, want %q", i, got, wantText)
}
}
}
@@ -441,6 +454,7 @@ func TestPipelineRun_TemplateLaws_RealComponents(t *testing.T) {
if err != nil {
t.Fatalf("NewPipelineFromDSL: %v", err)
}
+ attachFixedEmbedderFactory(t, pipe)
out, err := pipe.Run(context.Background(), map[string]any{
"doc_id": docID,
})
@@ -470,10 +484,11 @@ func TestPipelineRun_TemplateLaws_RealComponents(t *testing.T) {
}
vec := floatSliceFromAny(t, chunks[i]["q_4_vec"])
wantEmbedText := strings.TrimSpace(wantText)
- if len(vec) != 4 || vec[0] != float64(len(wantEmbedText)) {
- t.Fatalf("chunks[%d].q_4_vec = %v, want first=%v", i, vec, float64(len(wantEmbedText)))
+ wantFirst := expectedFixedEmbedderFirst("", wantEmbedText)
+ if len(vec) != 4 || !approxFloat(vec[0], wantFirst) {
+ t.Fatalf("chunks[%d].q_4_vec = %v, want first=%v", i, vec, wantFirst)
}
- totalTokens += tokenizer.NumTokensFromString(wantText)
+ totalTokens += len(wantEmbedText)
}
if got := payload["embedding_token_consumption"]; got != totalTokens {
t.Fatalf("embedding_token_consumption = %v, want %d", got, totalTokens)
@@ -490,7 +505,7 @@ func TestPipelineRun_TemplateLaws_RealComponents(t *testing.T) {
}
for i, wantText := range wantChunkTexts {
if got := chunkerChunks[i]["text"]; got != wantText {
- t.Fatalf("chunker chunks[%d].text = %v, want %q", i, got, wantText)
+ t.Fatalf("chunker chunk[%d].text = %v, want %q", i, got, wantText)
}
}
}
@@ -521,6 +536,7 @@ func TestPipelineRun_TemplatePaper_RealComponents(t *testing.T) {
if err != nil {
t.Fatalf("NewPipelineFromDSL: %v", err)
}
+ attachFixedEmbedderFactory(t, pipe)
out, err := pipe.Run(context.Background(), map[string]any{
"doc_id": docID,
})
@@ -548,10 +564,11 @@ func TestPipelineRun_TemplatePaper_RealComponents(t *testing.T) {
}
wantEmbedText := strings.TrimSpace(wantText)
vec := floatSliceFromAny(t, chunks[i]["q_4_vec"])
- if len(vec) != 4 || vec[0] != float64(len(wantEmbedText)) {
- t.Fatalf("chunks[%d].q_4_vec = %v, want first=%v", i, vec, float64(len(wantEmbedText)))
+ wantFirst := expectedFixedEmbedderFirst("", wantEmbedText)
+ if len(vec) != 4 || !approxFloat(vec[0], wantFirst) {
+ t.Fatalf("chunks[%d].q_4_vec = %v, want first=%v", i, vec, wantFirst)
}
- totalTokens += tokenizer.NumTokensFromString(wantText)
+ totalTokens += len(wantEmbedText)
}
if got := payload["embedding_token_consumption"]; got != totalTokens {
t.Fatalf("embedding_token_consumption = %v, want %d", got, totalTokens)
@@ -568,7 +585,7 @@ func TestPipelineRun_TemplatePaper_RealComponents(t *testing.T) {
}
for i, wantText := range wantChunkTexts {
if got := chunkerChunks[i]["text"]; got != wantText {
- t.Fatalf("chunker chunks[%d].text = %v, want %q", i, got, wantText)
+ t.Fatalf("chunker chunk[%d].text = %v, want %q", i, got, wantText)
}
}
}
@@ -599,6 +616,7 @@ func TestPipelineRun_TemplateBook_RealComponents(t *testing.T) {
if err != nil {
t.Fatalf("NewPipelineFromDSL: %v", err)
}
+ attachFixedEmbedderFactory(t, pipe)
out, err := pipe.Run(context.Background(), map[string]any{
"doc_id": docID,
})
@@ -631,10 +649,11 @@ func TestPipelineRun_TemplateBook_RealComponents(t *testing.T) {
}
wantEmbedText := strings.TrimSpace(wantText)
vec := floatSliceFromAny(t, chunks[i]["q_4_vec"])
- if len(vec) != 4 || vec[0] != float64(len(wantEmbedText)) {
- t.Fatalf("chunks[%d].q_4_vec = %v, want first=%v", i, vec, float64(len(wantEmbedText)))
+ wantFirst := expectedFixedEmbedderFirst("", wantEmbedText)
+ if len(vec) != 4 || !approxFloat(vec[0], wantFirst) {
+ t.Fatalf("chunks[%d].q_4_vec = %v, want first=%v", i, vec, wantFirst)
}
- totalTokens += tokenizer.NumTokensFromString(wantText)
+ totalTokens += len(wantEmbedText)
}
if got := payload["embedding_token_consumption"]; got != totalTokens {
t.Fatalf("embedding_token_consumption = %v, want %d", got, totalTokens)
@@ -651,7 +670,7 @@ func TestPipelineRun_TemplateBook_RealComponents(t *testing.T) {
}
for i, wantText := range wantChunkTexts {
if got := chunkerChunks[i]["text"]; got != wantText {
- t.Fatalf("chunker chunks[%d].text = %v, want %q", i, got, wantText)
+ t.Fatalf("chunker chunk[%d].text = %v, want %q", i, got, wantText)
}
}
}
@@ -718,6 +737,7 @@ func TestPipelineRun_TemplateResume_RealComponents(t *testing.T) {
if err != nil {
t.Fatalf("NewPipelineFromDSL: %v", err)
}
+ attachFixedEmbedderFactory(t, pipe)
out, err := pipe.Run(context.Background(), map[string]any{
"doc_id": docID,
"llm_id": model + "@openai",
@@ -789,6 +809,7 @@ func TestPipelineRun_AllIngestionTemplates_RealComponentsSmoke(t *testing.T) {
if err != nil {
t.Fatalf("NewPipelineFromDSL: %v", err)
}
+ attachFixedEmbedderFactory(t, pipe)
out, err := pipe.Run(context.Background(), map[string]any{
"doc_id": docID,
})
@@ -807,13 +828,20 @@ func TestPipelineRun_AllIngestionTemplates_RealComponentsSmoke(t *testing.T) {
}
}
-func repoRootFromPipelineTest(t *testing.T) string {
+func attachFixedEmbedderFactory(t *testing.T, pipe *Pipeline) {
t.Helper()
- _, file, _, ok := goruntime.Caller(0)
- if !ok {
- t.Fatal("runtime.Caller(0) failed")
- }
- return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", ".."))
+ pipe.WithComponentFactory(func(name string, params map[string]any) (runtime.Component, error) {
+ if name == componentpkg.ComponentNameTokenizer {
+ return componentpkg.NewTokenizerComponentWithResolver(params, func(_, _, _ string) (componentpkg.Embedder, error) {
+ return fixedEmbedder{}, nil
+ })
+ }
+ factory, _, _, ok := runtime.DefaultRegistry.Lookup(name)
+ if !ok {
+ return nil, fmt.Errorf("runtime: unknown component %q", name)
+ }
+ return factory(name, params)
+ })
}
func withRealTemplateDeps(t *testing.T) storage.Storage {
@@ -824,10 +852,6 @@ func withRealTemplateDeps(t *testing.T) storage.Storage {
storage.GetStorageFactory().SetStorage(mem)
t.Cleanup(func() { storage.GetStorageFactory().SetStorage(origStorage) })
- origEncode := componentpkg.EncodeFunc
- componentpkg.EncodeFunc = func(_, _ string) componentpkg.Embedder { return fixedEmbedder{} }
- t.Cleanup(func() { componentpkg.EncodeFunc = origEncode })
-
refs := map[string]componentpkg.DocumentStorageRef{}
componentpkg.ResolveDocumentStorageOverride = func(docID string) (*componentpkg.DocumentStorageRef, error) {
ref, ok := refs[docID]
@@ -1048,7 +1072,7 @@ func assertExtractedMetadataContains(t *testing.T, raw any, key, want string) {
}
}
-func assertTokenizerTerminalChunk(t *testing.T, payload map[string]any, wantMergedText string) {
+func assertTokenizerTerminalChunk(t *testing.T, payload map[string]any, name, wantMergedText string) {
t.Helper()
if got := payload["output_format"]; got != "chunks" {
@@ -1074,180 +1098,16 @@ func assertTokenizerTerminalChunk(t *testing.T, payload map[string]any, wantMerg
if len(vec) != 4 {
t.Fatalf("chunks[0].q_4_vec len = %d, want 4", len(vec))
}
- if got := vec[0]; got != float64(len(wantMergedText)) {
- t.Fatalf("chunks[0].q_4_vec[0] = %v, want %v", got, float64(len(wantMergedText)))
+ wantFirst := expectedFixedEmbedderFirst(name, wantMergedText)
+ if got := vec[0]; !approxFloat(got, wantFirst) {
+ t.Fatalf("chunks[0].q_4_vec[0] = %v, want %v", got, wantFirst)
}
- wantTokens := tokenizer.NumTokensFromString(wantMergedText)
+ wantTokens := len(wantMergedText)
if got := payload["embedding_token_consumption"]; got != wantTokens {
t.Fatalf("embedding_token_consumption = %v, want %d", got, wantTokens)
}
}
-func stateFromRunOutput(t *testing.T, out map[string]any) map[string]map[string]any {
- t.Helper()
- state, ok := out["state"].(map[string]map[string]any)
- if !ok {
- t.Fatalf("state = %T, want map[string]map[string]any", out["state"])
- }
- return state
-}
-
-func requireTokenizerPool(t *testing.T) {
- t.Helper()
- if tokenizer.IsInitialized() {
- return
- }
- cfg := &tokenizer.PoolConfig{
- DictPath: os.Getenv("RAGFLOW_DICT_PATH"),
- MinSize: 1,
- MaxSize: 2,
- IdleTimeout: 30 * time.Second,
- AcquireTimeout: 5 * time.Second,
- }
- if cfg.DictPath == "" {
- cfg.DictPath = "/usr/share/infinity/resource"
- }
- if err := tokenizer.Init(cfg); err != nil {
- t.Skipf("tokenizer pool init failed: %v", err)
- }
-}
-
-func floatSliceFromAny(t *testing.T, v any) []float64 {
- t.Helper()
- switch x := v.(type) {
- case []float64:
- return x
- case []any:
- out := make([]float64, 0, len(x))
- for i, item := range x {
- f, ok := item.(float64)
- if !ok {
- t.Fatalf("vector item %d = %T, want float64", i, item)
- }
- out = append(out, f)
- }
- return out
- default:
- t.Fatalf("vector = %T, want []float64 or []any", v)
- return nil
- }
-}
-
-func joinJSONItemTexts(items []map[string]any) string {
- var parts []string
- for _, item := range items {
- text, _ := item["text"].(string)
- text = strings.TrimSpace(text)
- if text == "" {
- continue
- }
- parts = append(parts, text)
- }
- return strings.Join(parts, "\n")
-}
-
-func assertNormalizedContainsAll(t *testing.T, got string, wantSubstrings ...string) {
- t.Helper()
- normalizedGot := normalizeTestText(got)
- for _, want := range wantSubstrings {
- if !strings.Contains(normalizedGot, normalizeTestText(want)) {
- t.Fatalf("normalized text %q does not contain %q", normalizedGot, normalizeTestText(want))
- }
- }
-}
-
-func normalizeTestText(s string) string {
- return strings.Join(strings.Fields(strings.ReplaceAll(s, "\u00ad", "")), " ")
-}
-
-func terminalPayloadFromRunOutput(t *testing.T, out map[string]any, terminalID string) map[string]any {
- t.Helper()
- if out == nil {
- t.Fatal("Run returned nil output")
- }
- if _, ok := out["output_format"]; ok {
- return out
- }
- if terminalID == "" {
- t.Fatal("terminalID is empty")
- }
- nested, ok := out[terminalID].(map[string]any)
- if !ok {
- t.Fatalf("run output missing terminal payload %q in %v", terminalID, out)
- }
- return nested
-}
-
-func terminalComponentIDsFromTemplate(t *testing.T, raw []byte) []string {
- t.Helper()
- var tpl map[string]any
- if err := json.Unmarshal(raw, &tpl); err != nil {
- t.Fatalf("unmarshal template: %v", err)
- }
- dsl, ok := tpl["dsl"].(map[string]any)
- if !ok {
- t.Fatalf("template dsl = %T, want map[string]any", tpl["dsl"])
- }
- components, ok := dsl["components"].(map[string]any)
- if !ok {
- t.Fatalf("template components = %T, want map[string]any", dsl["components"])
- }
- var terminals []string
- for id, rawComp := range components {
- comp, ok := rawComp.(map[string]any)
- if !ok {
- t.Fatalf("component %q = %T, want map[string]any", id, rawComp)
- }
- switch ds := comp["downstream"].(type) {
- case nil:
- terminals = append(terminals, id)
- case []any:
- if len(ds) == 0 {
- terminals = append(terminals, id)
- }
- case []string:
- if len(ds) == 0 {
- terminals = append(terminals, id)
- }
- default:
- t.Fatalf("component %q downstream = %T, want []any/[]string/nil", id, comp["downstream"])
- }
- }
- sort.Strings(terminals)
- return terminals
-}
-
-func templateUsesComponent(t *testing.T, raw []byte, componentName string) bool {
- t.Helper()
- var tpl map[string]any
- if err := json.Unmarshal(raw, &tpl); err != nil {
- t.Fatalf("unmarshal template: %v", err)
- }
- dsl, ok := tpl["dsl"].(map[string]any)
- if !ok {
- t.Fatalf("template dsl = %T, want map[string]any", tpl["dsl"])
- }
- components, ok := dsl["components"].(map[string]any)
- if !ok {
- t.Fatalf("template components = %T, want map[string]any", dsl["components"])
- }
- for id, rawComp := range components {
- comp, ok := rawComp.(map[string]any)
- if !ok {
- t.Fatalf("component %q = %T, want map[string]any", id, rawComp)
- }
- obj, ok := comp["obj"].(map[string]any)
- if !ok {
- t.Fatalf("component %q obj = %T, want map[string]any", id, comp["obj"])
- }
- name, _ := obj["component_name"].(string)
- if name == componentName {
- return true
- }
- }
- return false
-}
-
func TestTemplateFixtures_AreWrappedTemplates(t *testing.T) {
path := filepath.Join(repoRootFromPipelineTest(t), "agent", "templates", "ingestion_pipeline_one.json")
raw, err := os.ReadFile(path)
diff --git a/internal/ingestion/pipeline/test_helpers.go b/internal/ingestion/pipeline/test_helpers.go
new file mode 100644
index 0000000000..85457c4ac8
--- /dev/null
+++ b/internal/ingestion/pipeline/test_helpers.go
@@ -0,0 +1,211 @@
+// 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.
+
+package pipeline
+
+import (
+ "encoding/json"
+ "math"
+ "os"
+ "path/filepath"
+ goruntime "runtime"
+ "sort"
+ "strings"
+ "testing"
+ "time"
+
+ "ragflow/internal/tokenizer"
+)
+
+func repoRootFromPipelineTest(t *testing.T) string {
+ t.Helper()
+ _, file, _, ok := goruntime.Caller(0)
+ if !ok {
+ t.Fatal("runtime.Caller(0) failed")
+ }
+ return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", ".."))
+}
+
+func RequireTokenizerPool(t *testing.T) {
+ t.Helper()
+ if tokenizer.IsInitialized() {
+ return
+ }
+ cfg := &tokenizer.PoolConfig{
+ DictPath: os.Getenv("RAGFLOW_DICT_PATH"),
+ MinSize: 1,
+ MaxSize: 2,
+ IdleTimeout: 30 * time.Second,
+ AcquireTimeout: 5 * time.Second,
+ }
+ if cfg.DictPath == "" {
+ cfg.DictPath = "/usr/share/infinity/resource"
+ }
+ if err := tokenizer.Init(cfg); err != nil {
+ t.Skipf("tokenizer pool init failed: %v", err)
+ }
+}
+
+func terminalComponentIDsFromTemplate(t *testing.T, raw []byte) []string {
+ t.Helper()
+ var tpl map[string]any
+ if err := json.Unmarshal(raw, &tpl); err != nil {
+ t.Fatalf("unmarshal template: %v", err)
+ }
+ dsl, ok := tpl["dsl"].(map[string]any)
+ if !ok {
+ t.Fatalf("template dsl = %T, want map[string]any", tpl["dsl"])
+ }
+ components, ok := dsl["components"].(map[string]any)
+ if !ok {
+ t.Fatalf("template components = %T, want map[string]any", dsl["components"])
+ }
+ var terminals []string
+ for id, rawComp := range components {
+ comp, ok := rawComp.(map[string]any)
+ if !ok {
+ t.Fatalf("component %q = %T, want map[string]any", id, rawComp)
+ }
+ switch ds := comp["downstream"].(type) {
+ case nil:
+ terminals = append(terminals, id)
+ case []any:
+ if len(ds) == 0 {
+ terminals = append(terminals, id)
+ }
+ case []string:
+ if len(ds) == 0 {
+ terminals = append(terminals, id)
+ }
+ default:
+ t.Fatalf("component %q downstream = %T, want []any/[]string/nil", id, comp["downstream"])
+ }
+ }
+ sort.Strings(terminals)
+ return terminals
+}
+
+func terminalPayloadFromRunOutput(t *testing.T, out map[string]any, terminalID string) map[string]any {
+ t.Helper()
+ if out == nil {
+ t.Fatal("Run returned nil output")
+ }
+ if _, ok := out["output_format"]; ok {
+ return out
+ }
+ if terminalID == "" {
+ t.Fatal("terminalID is empty")
+ }
+ nested, ok := out[terminalID].(map[string]any)
+ if !ok {
+ t.Fatalf("run output missing terminal payload %q in %v", terminalID, out)
+ }
+ return nested
+}
+
+func stateFromRunOutput(t *testing.T, out map[string]any) map[string]map[string]any {
+ t.Helper()
+ state, ok := out["state"].(map[string]map[string]any)
+ if !ok {
+ t.Fatalf("state = %T, want map[string]map[string]any", out["state"])
+ }
+ return state
+}
+
+func floatSliceFromAny(t *testing.T, v any) []float64 {
+ t.Helper()
+ switch x := v.(type) {
+ case []float64:
+ return x
+ case []any:
+ out := make([]float64, 0, len(x))
+ for i, item := range x {
+ f, ok := item.(float64)
+ if !ok {
+ t.Fatalf("vector item %d = %T, want float64", i, item)
+ }
+ out = append(out, f)
+ }
+ return out
+ default:
+ t.Fatalf("vector = %T, want []float64 or []any", v)
+ return nil
+ }
+}
+
+func joinJSONItemTexts(items []map[string]any) string {
+ var parts []string
+ for _, item := range items {
+ text, _ := item["text"].(string)
+ text = strings.TrimSpace(text)
+ if text == "" {
+ continue
+ }
+ parts = append(parts, text)
+ }
+ return strings.Join(parts, "\n")
+}
+
+func assertNormalizedContainsAll(t *testing.T, got string, wantSubstrings ...string) {
+ t.Helper()
+ normalizedGot := normalizeTestText(got)
+ for _, want := range wantSubstrings {
+ if !strings.Contains(normalizedGot, normalizeTestText(want)) {
+ t.Fatalf("normalized text %q does not contain %q", normalizedGot, normalizeTestText(want))
+ }
+ }
+}
+
+func normalizeTestText(s string) string {
+ return strings.Join(strings.Fields(strings.ReplaceAll(s, "", "")), " ")
+}
+
+func templateUsesComponent(t *testing.T, raw []byte, componentName string) bool {
+ t.Helper()
+ var tpl map[string]any
+ if err := json.Unmarshal(raw, &tpl); err != nil {
+ t.Fatalf("unmarshal template: %v", err)
+ }
+ dsl, ok := tpl["dsl"].(map[string]any)
+ if !ok {
+ t.Fatalf("template dsl = %T, want map[string]any", tpl["dsl"])
+ }
+ components, ok := dsl["components"].(map[string]any)
+ if !ok {
+ t.Fatalf("template components = %T, want map[string]any", dsl["components"])
+ }
+ for id, rawComp := range components {
+ comp, ok := rawComp.(map[string]any)
+ if !ok {
+ t.Fatalf("component %q = %T, want map[string]any", id, rawComp)
+ }
+ obj, ok := comp["obj"].(map[string]any)
+ if !ok {
+ t.Fatalf("component %q obj = %T, want map[string]any", id, comp["obj"])
+ }
+ name, _ := obj["component_name"].(string)
+ if name == componentName {
+ return true
+ }
+ }
+ return false
+}
+
+func expectedFixedEmbedderFirst(name, text string) float64 {
+ return 0.1*float64(len(name)) + 0.9*float64(len(text))
+}
+
+func approxFloat(got, want float64) bool {
+ return math.Abs(got-want) < 1e-9
+}
diff --git a/internal/ingestion/service/execute_task_test.go b/internal/ingestion/service/execute_task_test.go
index 46befe2571..51fb74cd68 100644
--- a/internal/ingestion/service/execute_task_test.go
+++ b/internal/ingestion/service/execute_task_test.go
@@ -11,6 +11,64 @@ import (
"ragflow/internal/ingestion/testutil"
)
+// TestExecuteTask_CheckpointParseFailureDoesNotKillProcess verifies that checkpoint
+// parse failures do not call fatal exit (which would kill the whole worker process).
+// Instead, the task should be marked as FAILED and return gracefully.
+// This tests the fix for issue 1 from the code review.
+func TestExecuteTask_CheckpointParseFailureDoesNotKillProcess(t *testing.T) {
+ db := testutil.SetupTestDB(t)
+ cleanup := testutil.ReplaceDBForTest(t, db)
+ defer cleanup()
+
+ _, _, docID, taskID := testutil.SeedTestData(t, db,
+ testutil.WithPipelineID("flow-1"),
+ testutil.WithTenantID("tenant-1"),
+ )
+
+ // Create a task log with invalid checkpoint (current_step is a string instead of number)
+ err := db.Create(&entity.IngestionTaskLog{
+ TaskID: taskID,
+ Checkpoint: entity.JSONMap{
+ "current_step": "not-a-number", // intentionally wrong type
+ "total_step": 5,
+ },
+ }).Error
+ if err != nil {
+ t.Fatalf("create bad task log: %v", err)
+ }
+
+ ingestor := NewIngestor("test", 1, []string{"pdf"})
+ // Replace runDocumentTask to ensure it doesn't get called
+ var runDocumentTaskCalled bool
+ ingestor.runDocumentTask = func(ctx context.Context, ingestionTask *entity.IngestionTask) error {
+ runDocumentTaskCalled = true
+ return nil
+ }
+
+ taskCtx := taskpkg.NewTaskContextForScheduling(
+ context.Background(),
+ &entity.IngestionTask{ID: taskID, DocumentID: docID, DatasetID: "kb-1", Status: common.RUNNING},
+ nil,
+ )
+
+ // Execute the task - this should NOT panic or fatal exit (this is our main validation!)
+ ingestor.executeTask(taskCtx)
+
+ // Verify runDocumentTask was not called (we returned early due to checkpoint parse failure)
+ if runDocumentTaskCalled {
+ t.Fatal("expected runDocumentTask to not be called due to checkpoint parse failure")
+ }
+
+ // Verify task status was set to FAILED
+ finalTask, err := dao.NewIngestionTaskDAO().GetByID(taskID)
+ if err != nil {
+ t.Fatalf("load final ingestion task: %v", err)
+ }
+ if finalTask.Status != common.FAILED {
+ t.Fatalf("final status = %s, want %s", finalTask.Status, common.FAILED)
+ }
+}
+
func TestExecuteTask_DataflowRoutesToTaskHandler(t *testing.T) {
db := testutil.SetupTestDB(t)
cleanup := testutil.ReplaceDBForTest(t, db)
@@ -37,10 +95,6 @@ func TestExecuteTask_DataflowRoutesToTaskHandler(t *testing.T) {
wrapped(1.0, "mock dataflow done")
return nil
}
- ingestor.storageImpl = testutil.NewMockStorage(map[string][]byte{"/unused": []byte("unused")})
- ingestor.documentDAO = &testutil.MockDocDAO{
- Docs: map[string]*entity.Document{"doc-1": testutil.TestDoc("doc-1", "pdf", ".pdf")},
- }
taskCtx := taskpkg.NewTaskContextForScheduling(
context.Background(),
diff --git a/internal/ingestion/service/ingestion_service.go b/internal/ingestion/service/ingestion_service.go
index e25c297eb9..e0d7023d8c 100644
--- a/internal/ingestion/service/ingestion_service.go
+++ b/internal/ingestion/service/ingestion_service.go
@@ -30,32 +30,15 @@ import (
"ragflow/internal/common"
"ragflow/internal/dao"
- doctype "ragflow/internal/deepdoc/parser/type"
"ragflow/internal/engine"
"ragflow/internal/entity"
taskpkg "ragflow/internal/ingestion/task"
- "ragflow/internal/storage"
"github.com/google/uuid"
"google.golang.org/grpc"
+ "gorm.io/gorm"
)
-// pdfParser is the interface for PDF parsing, made injectable for tests.
-type pdfParser interface {
- ParseWithDeepDoc(ctx context.Context, filename string, data []byte, config doctype.ParserConfig) ([]map[string]any, error)
-}
-
-// DAO interfaces — injectable for tests.
-type docGetter interface {
- GetByID(id string) (*entity.Document, error)
-}
-type f2dGetter interface {
- GetByDocumentID(docID string) ([]*entity.File2Document, error)
-}
-type fileGetter interface {
- GetByID(id string) (*entity.File, error)
-}
-
type Ingestor struct {
id string
name string
@@ -87,13 +70,6 @@ type Ingestor struct {
ingestionTaskletDAO *dao.IngestionTaskletDAO
ingestionTaskletLogDAO *dao.IngestionTaskletLogDAO
- // Injected dependencies for parseDocument (overridable in tests)
- documentDAO docGetter
- f2dDAO f2dGetter
- fileDAO fileGetter
- storageImpl storage.Storage
- pdfParser pdfParser
-
// runDocumentTask dispatches to the migrated task handler path.
// Tests may override this to verify branch routing without invoking
// the full downstream stack.
@@ -248,6 +224,13 @@ func (e *Ingestor) executeTask(taskCtx *taskpkg.TaskContext) {
latestLog, err := e.ingestionTaskLogDAO.LatestLogByTaskID(task.ID)
if err != nil {
+ if !errors.Is(err, gorm.ErrRecordNotFound) {
+ common.Error(fmt.Sprintf("Failed to get latest task log for task %s", task.ID), err)
+ if uErr := e.ingestionTaskDAO.UpdateStatus(task.ID, common.FAILED); uErr != nil {
+ common.Error(fmt.Sprintf("Failed to set task %s to FAILED", task.ID), uErr)
+ }
+ return
+ }
latestLog = &entity.IngestionTaskLog{
ID: 0,
TaskID: task.ID,
@@ -259,6 +242,9 @@ func (e *Ingestor) executeTask(taskCtx *taskpkg.TaskContext) {
err = e.ingestionTaskLogDAO.Create(latestLog)
if err != nil {
common.Error(fmt.Sprintf("Failed to create task log for task %s", task.ID), err)
+ if uErr := e.ingestionTaskDAO.UpdateStatus(task.ID, common.FAILED); uErr != nil {
+ common.Error(fmt.Sprintf("Failed to set task %s to FAILED", task.ID), uErr)
+ }
return
}
}
@@ -267,12 +253,18 @@ func (e *Ingestor) executeTask(taskCtx *taskpkg.TaskContext) {
checkpointMap = latestLog.Checkpoint
currentStep, ok := common.GetInt(checkpointMap["current_step"])
if !ok {
- common.Fatal(fmt.Sprintf("Failed to get current step from task log for task %s", task.ID))
+ common.Error(fmt.Sprintf("Failed to get current step from task log for task %s", task.ID), nil)
+ if uErr := e.ingestionTaskDAO.UpdateStatus(task.ID, common.FAILED); uErr != nil {
+ common.Error(fmt.Sprintf("Failed to set task %s to FAILED", task.ID), uErr)
+ }
return
}
totalStep, ok := common.GetInt(checkpointMap["total_step"])
if !ok {
- common.Fatal(fmt.Sprintf("Failed to get current step from task log for task %s", task.ID))
+ common.Error(fmt.Sprintf("Failed to get total step from task log for task %s", task.ID), nil)
+ if uErr := e.ingestionTaskDAO.UpdateStatus(task.ID, common.FAILED); uErr != nil {
+ common.Error(fmt.Sprintf("Failed to set task %s to FAILED", task.ID), uErr)
+ }
return
}
// Bump checkpoint to signal we started processing
@@ -322,11 +314,6 @@ func (e *Ingestor) getPipelineID(tenantID string) (string, error) {
return "", err
}
- templateBytes, err = disableTokenizerEmbeddingForTaskTemplate(templateBytes)
- if err != nil {
- return "", err
- }
-
var templateDSL entity.JSONMap
if err := json.Unmarshal(templateBytes, &templateDSL); err != nil {
return "", err
@@ -345,41 +332,6 @@ func (e *Ingestor) getPipelineID(tenantID string) (string, error) {
return ID, nil
}
-func disableTokenizerEmbeddingForTaskTemplate(raw []byte) ([]byte, error) {
- var tpl map[string]any
- if err := json.Unmarshal(raw, &tpl); err != nil {
- return nil, fmt.Errorf("failed to unmarshal")
- }
- dsl, ok := tpl["dsl"].(map[string]any)
- if !ok {
- return nil, fmt.Errorf("failed to do dsl convert")
- }
- components, ok := dsl["components"].(map[string]any)
- if !ok {
- return nil, fmt.Errorf("template components = %T, want map[string]any", dsl["components"])
- }
- for _, rawComp := range components {
- comp, ok := rawComp.(map[string]any)
- if !ok {
- continue
- }
- obj, ok := comp["obj"].(map[string]any)
- if !ok || obj["component_name"] != "Tokenizer" {
- continue
- }
- params, ok := obj["params"].(map[string]any)
- if !ok {
- continue
- }
- params["search_method"] = []string{"full_text"}
- }
- out, err := json.Marshal(tpl)
- if err != nil {
- return nil, fmt.Errorf("marshal modified template: %v", err)
- }
- return out, nil
-}
-
func (e *Ingestor) defaultRunDocumentTask(ctx context.Context, ingestionTask *entity.IngestionTask) error {
docTaskCtx, err := taskpkg.LoadFromIngestionTask(ingestionTask)
if err != nil {
diff --git a/internal/ingestion/service/real_consumer_dataflow_test.go b/internal/ingestion/service/real_consumer_dataflow_test.go
index 17a55ec449..8777720727 100644
--- a/internal/ingestion/service/real_consumer_dataflow_test.go
+++ b/internal/ingestion/service/real_consumer_dataflow_test.go
@@ -1,5 +1,5 @@
-//go:build integration
-// +build integration
+//go:build manual
+// +build manual
package service
@@ -13,6 +13,7 @@ import (
"ragflow/internal/dao"
"ragflow/internal/engine/nats"
"ragflow/internal/entity"
+ taskpkg "ragflow/internal/ingestion/task"
"ragflow/internal/ingestion/testutil"
)
@@ -98,21 +99,22 @@ func TestRealConsumer_DataflowMessageRoutesToExecuteTask(t *testing.T) {
ingestor := NewIngestor("queue-test", 1, []string{"pdf"})
var routedToDataflow bool
var progressEvents []string
+ taskCtx := taskpkg.NewTaskContextForScheduling(
+ context.Background(),
+ task,
+ taskHandle,
+ )
+ taskCtx.ProgressFunc = func(prog float64, msg string) {
+ taskCtx.Progress = int32(prog * 100)
+ progressEvents = append(progressEvents, msg)
+ }
ingestor.runDocumentTask = func(ctx context.Context, ingestionTask *entity.IngestionTask) error {
routedToDataflow = true
- progressFn(0.82, "mock queue dataflow start")
- progressFn(1.0, "mock queue dataflow done")
- progressEvents = append(progressEvents, "0.82:mock queue dataflow start", "1.00:mock queue dataflow done")
+ taskCtx.ProgressFunc(0.82, "mock queue dataflow start")
+ taskCtx.ProgressFunc(1.0, "mock queue dataflow done")
return nil
}
- taskCtx := &TaskContext{
- Ctx: context.Background(),
- CancelFunc: func() {},
- Task: task,
- TaskHandle: taskHandle,
- }
-
ingestor.executeTask(taskCtx)
if !routedToDataflow {
diff --git a/internal/ingestion/service/test_helpers.go b/internal/ingestion/service/test_helpers.go
index 69dc5e922e..aa6b9440d6 100644
--- a/internal/ingestion/service/test_helpers.go
+++ b/internal/ingestion/service/test_helpers.go
@@ -16,8 +16,6 @@
package service
-import "ragflow/internal/storage"
-
// ──────────────────────────────────────────────────────────
// Ingestor Test Helpers
// ──────────────────────────────────────────────────────────
@@ -32,29 +30,6 @@ func NewTestIngestor() *Ingestor {
// IngestorOption configures a test Ingestor.
type IngestorOption func(*Ingestor)
-// WithMockStorage sets the storageImpl to the given mock.
-func WithMockStorage(mock storage.Storage) IngestorOption {
- return func(i *Ingestor) {
- i.storageImpl = mock
- }
-}
-
-// WithMockPDFParser sets the pdfParser to the given mock.
-func WithMockPDFParser(mock pdfParser) IngestorOption {
- return func(i *Ingestor) {
- i.pdfParser = mock
- }
-}
-
-// WithMockDAOs sets the documentDAO, f2dDAO, and fileDAO.
-func WithMockDAOs(docDAO docGetter, f2dDAO f2dGetter, fileDAO fileGetter) IngestorOption {
- return func(i *Ingestor) {
- i.documentDAO = docDAO
- i.f2dDAO = f2dDAO
- i.fileDAO = fileDAO
- }
-}
-
// SetupTestIngestor creates a new test Ingestor with the given options.
func SetupTestIngestor(t testingT, opts ...IngestorOption) *Ingestor {
t.Helper()
diff --git a/internal/ingestion/task/chunk_builder.go b/internal/ingestion/task/chunk_builder.go
index bac48335fd..c13ba9b05f 100644
--- a/internal/ingestion/task/chunk_builder.go
+++ b/internal/ingestion/task/chunk_builder.go
@@ -17,152 +17,14 @@
package task
import (
- "encoding/base64"
"fmt"
- "strings"
- "time"
- "unicode/utf8"
"github.com/cespare/xxhash/v2"
)
-// PAGERANK_FLD is the field key for pagerank feature value on a chunk,
-// matching Python common.constants.PAGERANK_FLD = "pagerank_fea".
-const PAGERANK_FLD = "pagerank_fea"
-
-// Chunk represents a single document chunk ready for embedding and indexing.
-// Mirrors the Python dict format produced by ChunkService._prepare_docs_and_upload().
-type Chunk struct {
- ID string `json:"id"` // xxhash64(content_with_weight + doc_id) hex
- DocID string `json:"doc_id"` // document ID
- KBID string `json:"kb_id"` // knowledge base ID
- DocNameKwd string `json:"docnm_kwd"` // document file name
- ContentWithWeight string `json:"content_with_weight"` // main text content
- CreateTime string `json:"create_time"` // "2006-01-02 15:04:05"
- CreateTimestamp float64 `json:"create_timestamp_flt"` // Unix timestamp
- ImgID string `json:"img_id"` // image ID (from MinIO or inline)
- PageNumInt []int `json:"page_num_int"` // page numbers
- TopInt []int `json:"top_int"` // top positions
- AvailableInt int `json:"available_int"` // availability flag, default 1
- PageRank int `json:"pagerank_fea"` // pagerank feature value, 0 = unset
-}
-
-// prepareDocs converts ParseWithDeepDoc output ([]map[string]any) to a list of
-// Chunk structs. Matches Python ChunkService._prepare_docs_and_upload().
-//
-// Input sections come from ParseWithDeepDoc; each map may contain:
-// - "text" (string) — the section text → ContentWithWeight
-// - "doc_type_kwd" (string) — document type hint (unused directly in chunk)
-// - "img_id" (string) — inline image ID (empty string if none)
-// - "positions" ([]float64) — [pageNum, top, left, width, height]
-//
-// This is a pure function with no I/O side effects. Image upload to MinIO
-// is handled separately by the caller.
-func PrepareDocs(sections []map[string]any, docID, kbID, docName string, pagerank int) []Chunk {
- if len(sections) == 0 {
- return nil
- }
-
- now := time.Now()
- createTime := now.Format("2006-01-02 15:04:05")
- createTimestamp := float64(now.Unix())
-
- chunks := make([]Chunk, 0, len(sections))
- for _, sec := range sections {
- text, _ := sec["text"].(string)
-
- c := Chunk{
- DocID: docID,
- KBID: kbID,
- DocNameKwd: docName,
- ContentWithWeight: text,
- CreateTime: createTime,
- CreateTimestamp: createTimestamp,
- AvailableInt: 1,
- PageRank: pagerank,
- }
-
- // Extract img_id if present
- if imgID, ok := sec["img_id"].(string); ok {
- c.ImgID = imgID
- }
-
- // Extract positions: [pageNum, top, left, width, height]
- if positions, ok := sec["positions"].([]float64); ok && len(positions) >= 2 {
- c.PageNumInt = append(c.PageNumInt, int(positions[0]))
- c.TopInt = append(c.TopInt, int(positions[1]))
- }
-
- // Generate deterministic chunk ID: xxhash64(content_with_weight + doc_id) → hex
- c.ID = ChunkID(text, docID)
- chunks = append(chunks, c)
- }
-
- return chunks
-}
-
// ChunkID generates a deterministic chunk identifier matching Python's:
//
// xxhash.xxh64((content_with_weight + str(doc_id)).encode("utf-8", "surrogatepass")).hexdigest()
func ChunkID(contentWithWeight, docID string) string {
return fmt.Sprintf("%016x", xxhash.Sum64String(contentWithWeight+docID))
}
-
-// String returns a human-readable summary of the chunk for logging.
-func (c *Chunk) String() string {
- preview := c.ContentWithWeight
- if utf8.RuneCountInString(preview) > 80 {
- preview = string([]rune(preview)[:80]) + "..."
- }
- return fmt.Sprintf("Chunk{id=%s doc=%s page=%v top=%v text=%q}",
- c.ID, c.DocID, c.PageNumInt, c.TopInt, preview)
-}
-
-// UploadChunkImages uploads base64-encoded images from sections to storage
-// and sets each chunk's ImgID to the resulting storage reference.
-//
-// Matches Python ChunkService._prepare_docs_and_upload() image upload logic:
-// - If section has "img_id" pre-set → use it directly (skip upload)
-// - If section has "image" (base64) → decode → upload via putFn → set ImgID
-// - Otherwise → ImgID stays empty
-//
-// putFn is typically storage.Put(bucket, fnm, data) where bucket=kbID, fnm=chunkID.
-// sections and chunks must correspond 1:1 by index.
-func UploadChunkImages(sections []map[string]any, chunks []Chunk, kbID string, putFn func(bucket, fnm string, binary []byte) error) error {
- for i := range chunks {
- sec := sections[i]
-
- // Already has img_id from parser — use as-is
- if imgID, ok := sec["img_id"].(string); ok && imgID != "" {
- chunks[i].ImgID = imgID
- continue
- }
-
- // No image data — skip
- imgRaw, ok := sec["image"].(string)
- if !ok || imgRaw == "" {
- continue
- }
-
- imgBytes, err := decodeChunkImagePayload(imgRaw)
- if err != nil {
- return fmt.Errorf("decode image for chunk %s: %w", chunks[i].ID, err)
- }
-
- // Upload to storage (bucket=kbID, fnm=chunkID)
- if err := putFn(kbID, chunks[i].ID, imgBytes); err != nil {
- return fmt.Errorf("upload image for chunk %s: %w", chunks[i].ID, err)
- }
-
- // Set ImgID to storage reference (matching Python f"{bucket}-{objname}")
- chunks[i].ImgID = fmt.Sprintf("%s-%s", kbID, chunks[i].ID)
- }
- return nil
-}
-
-func decodeChunkImagePayload(raw string) ([]byte, error) {
- if idx := strings.Index(raw, ","); strings.HasPrefix(raw, "data:image/") && idx >= 0 {
- raw = raw[idx+1:]
- }
- return base64.StdEncoding.DecodeString(raw)
-}
diff --git a/internal/ingestion/task/chunk_builder_test.go b/internal/ingestion/task/chunk_builder_test.go
index 8d61a9f5e1..b5bc3a52b8 100644
--- a/internal/ingestion/task/chunk_builder_test.go
+++ b/internal/ingestion/task/chunk_builder_test.go
@@ -17,398 +17,13 @@
package task
import (
- "encoding/base64"
- "errors"
- "regexp"
"testing"
)
const (
- testDocID = "doc-1"
- testKBID = "kb-1"
- testDocName = "test.pdf"
+ testDocID = "doc-1"
)
-func TestPrepareDocs_Basic(t *testing.T) {
- sections := []map[string]any{
- {"text": "Hello World", "doc_type_kwd": "text", "img_id": ""},
- }
- chunks := PrepareDocs(sections, testDocID, testKBID, testDocName, 0)
-
- if len(chunks) != 1 {
- t.Fatalf("expected 1 chunk, got %d", len(chunks))
- }
-
- c := chunks[0]
- if c.ContentWithWeight != "Hello World" {
- t.Errorf("ContentWithWeight = %q, want %q", c.ContentWithWeight, "Hello World")
- }
- if c.DocID != testDocID {
- t.Errorf("DocID = %q, want %q", c.DocID, testDocID)
- }
- if c.KBID != testKBID {
- t.Errorf("KBID = %q, want %q", c.KBID, testKBID)
- }
- if c.DocNameKwd != testDocName {
- t.Errorf("DocNameKwd = %q, want %q", c.DocNameKwd, testDocName)
- }
- if c.AvailableInt != 1 {
- t.Errorf("AvailableInt = %d, want 1", c.AvailableInt)
- }
-}
-
-func TestPrepareDocs_Multiple(t *testing.T) {
- sections := []map[string]any{
- {"text": "First", "doc_type_kwd": "text", "img_id": ""},
- {"text": "Second", "doc_type_kwd": "text", "img_id": ""},
- {"text": "Third", "doc_type_kwd": "text", "img_id": ""},
- }
- chunks := PrepareDocs(sections, testDocID, testKBID, testDocName, 0)
-
- if len(chunks) != 3 {
- t.Fatalf("expected 3 chunks, got %d", len(chunks))
- }
-
- // All IDs should be different (different content)
- ids := make(map[string]bool)
- for _, c := range chunks {
- if c.ContentWithWeight == "" {
- t.Error("chunk has empty ContentWithWeight")
- }
- ids[c.ID] = true
- }
- if len(ids) != 3 {
- t.Errorf("expected 3 unique IDs, got %d", len(ids))
- }
-}
-
-func TestPrepareDocs_EmptyInput(t *testing.T) {
- chunks := PrepareDocs(nil, testDocID, testKBID, testDocName, 0)
- if chunks != nil {
- t.Errorf("expected nil for empty input, got %d chunks", len(chunks))
- }
-
- chunks = PrepareDocs([]map[string]any{}, testDocID, testKBID, testDocName, 0)
- if chunks != nil {
- t.Errorf("expected nil for empty input, got %d chunks", len(chunks))
- }
-}
-
-func TestPrepareDocs_IDFormat(t *testing.T) {
- sections := []map[string]any{
- {"text": "Content for ID test", "doc_type_kwd": "text", "img_id": ""},
- }
- chunks := PrepareDocs(sections, testDocID, testKBID, testDocName, 0)
-
- id := chunks[0].ID
- // xxhash64 hex digest is 16 hex chars (64 bits = 16 hex digits)
- matched, _ := regexp.MatchString(`^[0-9a-f]{16}$`, id)
- if !matched {
- t.Errorf("ID %q does not match hex format", id)
- }
-
-}
-
-func TestPrepareDocs_IDDeterministic(t *testing.T) {
- sections := []map[string]any{
- {"text": "Deterministic test content", "doc_type_kwd": "text", "img_id": ""},
- }
-
- chunks1 := PrepareDocs(sections, testDocID, testKBID, testDocName, 0)
- chunks2 := PrepareDocs(sections, testDocID, testKBID, testDocName, 0)
-
- if chunks1[0].ID != chunks2[0].ID {
- t.Errorf("ID not deterministic: %q vs %q", chunks1[0].ID, chunks2[0].ID)
- }
-
- // Different docID should produce different ID
- chunks3 := PrepareDocs(sections, "doc-2", testKBID, testDocName, 0)
- if chunks1[0].ID == chunks3[0].ID {
- t.Error("different docIDs produced the same chunk ID")
- }
-}
-
-func TestPrepareDocs_Timestamps(t *testing.T) {
- sections := []map[string]any{
- {"text": "Timestamp test", "doc_type_kwd": "text", "img_id": ""},
- }
- chunks := PrepareDocs(sections, testDocID, testKBID, testDocName, 0)
-
- c := chunks[0]
-
- // CreateTime should match the expected format
- matched, _ := regexp.MatchString(`^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$`, c.CreateTime)
- if !matched {
- t.Errorf("CreateTime %q does not match format YYYY-MM-DD HH:MM:SS", c.CreateTime)
- }
-
- if c.CreateTimestamp <= 0 {
- t.Errorf("CreateTimestamp = %f, want > 0", c.CreateTimestamp)
- }
-}
-
-func TestPrepareDocs_Positions(t *testing.T) {
- sections := []map[string]any{
- {
- "text": "Positioned content",
- "doc_type_kwd": "text",
- "img_id": "",
- "positions": []float64{3, 150.5, 20, 400, 60},
- },
- }
- chunks := PrepareDocs(sections, testDocID, testKBID, testDocName, 0)
-
- c := chunks[0]
- if len(c.PageNumInt) != 1 || c.PageNumInt[0] != 3 {
- t.Errorf("PageNumInt = %v, want [3]", c.PageNumInt)
- }
- if len(c.TopInt) != 1 || c.TopInt[0] != 150 {
- t.Errorf("TopInt = %v, want [150]", c.TopInt)
- }
-}
-
-func TestPrepareDocs_NoPositions(t *testing.T) {
- sections := []map[string]any{
- {"text": "No positions", "doc_type_kwd": "text", "img_id": ""},
- }
- chunks := PrepareDocs(sections, testDocID, testKBID, testDocName, 0)
-
- c := chunks[0]
- if c.PageNumInt != nil {
- t.Errorf("PageNumInt = %v, want nil", c.PageNumInt)
- }
- if c.TopInt != nil {
- t.Errorf("TopInt = %v, want nil", c.TopInt)
- }
-}
-
-func TestPrepareDocs_ImgID(t *testing.T) {
- sections := []map[string]any{
- {
- "text": "Content with image",
- "doc_type_kwd": "text",
- "img_id": "some_image_id_123",
- },
- }
- chunks := PrepareDocs(sections, testDocID, testKBID, testDocName, 0)
-
- c := chunks[0]
- if c.ImgID != "some_image_id_123" {
- t.Errorf("ImgID = %q, want %q", c.ImgID, "some_image_id_123")
- }
-}
-
-func TestPrepareDocs_EmptyImgID(t *testing.T) {
- sections := []map[string]any{
- {"text": "No image", "doc_type_kwd": "text", "img_id": ""},
- }
- chunks := PrepareDocs(sections, testDocID, testKBID, testDocName, 0)
-
- c := chunks[0]
- if c.ImgID != "" {
- t.Errorf("ImgID = %q, want empty string", c.ImgID)
- }
-}
-
-func TestPrepareDocs_MixedContent(t *testing.T) {
- sections := []map[string]any{
- {"text": "Page 1 content", "doc_type_kwd": "text", "img_id": "",
- "positions": []float64{1, 0, 0, 100, 50}},
- {"text": "Page 2 with image", "doc_type_kwd": "text", "img_id": "img_abc",
- "positions": []float64{2, 30, 10, 200, 80}},
- {"text": "No position, no image", "doc_type_kwd": "text", "img_id": ""},
- }
- chunks := PrepareDocs(sections, testDocID, testKBID, testDocName, 0)
-
- if len(chunks) != 3 {
- t.Fatalf("expected 3 chunks, got %d", len(chunks))
- }
-
- // First: has position
- if len(chunks[0].PageNumInt) != 1 || chunks[0].PageNumInt[0] != 1 {
- t.Errorf("chunk[0] PageNumInt = %v, want [1]", chunks[0].PageNumInt)
- }
-
- // Second: has position + img_id
- if chunks[1].ImgID != "img_abc" {
- t.Errorf("chunk[1] ImgID = %q, want %q", chunks[1].ImgID, "img_abc")
- }
- if len(chunks[1].PageNumInt) != 1 || chunks[1].PageNumInt[0] != 2 {
- t.Errorf("chunk[1] PageNumInt = %v, want [2]", chunks[1].PageNumInt)
- }
-
- // Third: no position, no image
- if chunks[2].PageNumInt != nil {
- t.Errorf("chunk[2] PageNumInt should be nil, got %v", chunks[2].PageNumInt)
- }
- if chunks[2].ImgID != "" {
- t.Errorf("chunk[2] ImgID should be empty, got %q", chunks[2].ImgID)
- }
-}
-
-func TestPrepareDocs_PageRank(t *testing.T) {
- sections := []map[string]any{
- {"text": "Ranked content", "doc_type_kwd": "text", "img_id": ""},
- }
-
- // pagerank=0 → PageRank should be 0 (unset)
- chunks := PrepareDocs(sections, testDocID, testKBID, testDocName, 0)
- if chunks[0].PageRank != 0 {
- t.Errorf("PageRank = %d, want 0", chunks[0].PageRank)
- }
-
- // pagerank=5 → PageRank should be 5
- chunks = PrepareDocs(sections, testDocID, testKBID, testDocName, 5)
- if chunks[0].PageRank != 5 {
- t.Errorf("PageRank = %d, want 5", chunks[0].PageRank)
- }
-}
-
-func TestUploadChunkImages_WithImage(t *testing.T) {
- imgBase64 := base64.StdEncoding.EncodeToString([]byte("fake image"))
- sections := []map[string]any{
- {"text": "Has image", "doc_type_kwd": "text", "img_id": "", "image": imgBase64},
- }
- chunks := PrepareDocs(sections, testDocID, testKBID, testDocName, 0)
-
- var putBucket, putFnm string
- putFn := func(bucket, fnm string, binary []byte) error {
- putBucket = bucket
- putFnm = fnm
- if len(binary) == 0 {
- t.Error("Put called with empty binary")
- }
- return nil
- }
-
- if err := UploadChunkImages(sections, chunks, testKBID, putFn); err != nil {
- t.Fatalf("UploadChunkImages failed: %v", err)
- }
-
- expectedImgID := testKBID + "-" + chunks[0].ID
- if chunks[0].ImgID != expectedImgID {
- t.Errorf("ImgID = %q, want %q", chunks[0].ImgID, expectedImgID)
- }
- if putBucket != testKBID {
- t.Errorf("put bucket = %q, want %q", putBucket, testKBID)
- }
- if putFnm != chunks[0].ID {
- t.Errorf("put fnm = %q, want %q", putFnm, chunks[0].ID)
- }
-}
-
-func TestUploadChunkImages_PresetImgID(t *testing.T) {
- sections := []map[string]any{
- {"text": "Has preset img_id", "doc_type_kwd": "text", "img_id": "preset-id-123", "image": "should-be-ignored"},
- }
- chunks := PrepareDocs(sections, testDocID, testKBID, testDocName, 0)
-
- callCount := 0
- putFn := func(bucket, fnm string, binary []byte) error {
- callCount++
- return nil
- }
-
- if err := UploadChunkImages(sections, chunks, testKBID, putFn); err != nil {
- t.Fatalf("UploadChunkImages failed: %v", err)
- }
-
- if chunks[0].ImgID != "preset-id-123" {
- t.Errorf("ImgID = %q, want %q", chunks[0].ImgID, "preset-id-123")
- }
- if callCount != 0 {
- t.Errorf("expected 0 Put calls (preset img_id), got %d", callCount)
- }
-}
-
-func TestUploadChunkImages_NoImage(t *testing.T) {
- sections := []map[string]any{
- {"text": "No image", "doc_type_kwd": "text", "img_id": ""},
- }
- chunks := PrepareDocs(sections, testDocID, testKBID, testDocName, 0)
-
- callCount := 0
- putFn := func(bucket, fnm string, binary []byte) error {
- callCount++
- return nil
- }
-
- if err := UploadChunkImages(sections, chunks, testKBID, putFn); err != nil {
- t.Fatalf("UploadChunkImages failed: %v", err)
- }
-
- if chunks[0].ImgID != "" {
- t.Errorf("ImgID = %q, want empty string", chunks[0].ImgID)
- }
- if callCount != 0 {
- t.Errorf("expected 0 Put calls, got %d", callCount)
- }
-}
-
-func TestUploadChunkImages_Mixed(t *testing.T) {
- imgBase64 := base64.StdEncoding.EncodeToString([]byte("img1"))
- sections := []map[string]any{
- {"text": "Img1", "doc_type_kwd": "text", "img_id": "", "image": imgBase64},
- {"text": "No img", "doc_type_kwd": "text", "img_id": ""},
- {"text": "Preset", "doc_type_kwd": "text", "img_id": "preset-001"},
- }
- chunks := PrepareDocs(sections, testDocID, testKBID, testDocName, 0)
-
- var callCount int
- putFn := func(bucket, fnm string, binary []byte) error {
- callCount++
- return nil
- }
-
- if err := UploadChunkImages(sections, chunks, testKBID, putFn); err != nil {
- t.Fatalf("UploadChunkImages failed: %v", err)
- }
-
- if callCount != 1 {
- t.Errorf("expected 1 Put call, got %d", callCount)
- }
- if chunks[0].ImgID != testKBID+"-"+chunks[0].ID {
- t.Errorf("chunk[0] ImgID = %q, want %q", chunks[0].ImgID, testKBID+"-"+chunks[0].ID)
- }
- if chunks[1].ImgID != "" {
- t.Errorf("chunk[1] ImgID = %q, want empty", chunks[1].ImgID)
- }
- if chunks[2].ImgID != "preset-001" {
- t.Errorf("chunk[2] ImgID = %q, want 'preset-001'", chunks[2].ImgID)
- }
-}
-
-func TestUploadChunkImages_InvalidBase64(t *testing.T) {
- sections := []map[string]any{
- {"text": "Bad image", "doc_type_kwd": "text", "img_id": "", "image": "!!!not-base64!!!"},
- }
- chunks := PrepareDocs(sections, testDocID, testKBID, testDocName, 0)
-
- putFn := func(bucket, fnm string, binary []byte) error {
- return nil
- }
-
- if err := UploadChunkImages(sections, chunks, testKBID, putFn); err == nil {
- t.Fatal("expected error for invalid base64, got nil")
- }
-}
-
-func TestUploadChunkImages_PutError(t *testing.T) {
- imgBase64 := base64.StdEncoding.EncodeToString([]byte("img"))
- sections := []map[string]any{
- {"text": "Put fails", "doc_type_kwd": "text", "img_id": "", "image": imgBase64},
- }
- chunks := PrepareDocs(sections, testDocID, testKBID, testDocName, 0)
-
- putFn := func(bucket, fnm string, binary []byte) error {
- return errors.New("storage unavailable")
- }
-
- if err := UploadChunkImages(sections, chunks, testKBID, putFn); err == nil {
- t.Fatal("expected error for put failure, got nil")
- }
-}
-
func TestChunkID_NotPanic(t *testing.T) {
// Edge cases: empty content
_ = ChunkID("", testDocID)
@@ -429,33 +44,3 @@ func TestChunkID_PreservesLeadingZero(t *testing.T) {
t.Fatalf("ChunkID length = %d, want 16", len(got))
}
}
-
-func TestChunk_String(t *testing.T) {
- c := Chunk{
- ID: "abc123",
- DocID: "doc-1",
- ContentWithWeight: "Short text",
- PageNumInt: []int{1},
- TopInt: []int{0},
- }
- s := c.String()
- if len(s) == 0 {
- t.Error("String() returned empty")
- }
-}
-
-func TestChunk_StringTruncates(t *testing.T) {
- long := ""
- for i := 0; i < 200; i++ {
- long += "x"
- }
- c := Chunk{
- ID: "abc123",
- DocID: "doc-1",
- ContentWithWeight: long,
- }
- s := c.String()
- if len(s) >= 200 {
- t.Error("String() should truncate long content")
- }
-}
diff --git a/internal/ingestion/task/chunk_process.go b/internal/ingestion/task/chunk_process.go
index cc641fbab1..329a805512 100644
--- a/internal/ingestion/task/chunk_process.go
+++ b/internal/ingestion/task/chunk_process.go
@@ -243,4 +243,4 @@ func processChunkPositions(ck map[string]any) {
AddPositions(ck, positions)
}
delete(ck, "positions")
-}
\ No newline at end of file
+}
diff --git a/internal/ingestion/task/chunk_process_test.go b/internal/ingestion/task/chunk_process_test.go
index f5208a970d..f486d83e33 100644
--- a/internal/ingestion/task/chunk_process_test.go
+++ b/internal/ingestion/task/chunk_process_test.go
@@ -362,39 +362,3 @@ func TestProcessChunksForDataflow_PreservesContentWithWeight(t *testing.T) {
t.Errorf("content_with_weight = %q, want \"already set\"", chunks[0]["content_with_weight"])
}
}
-
-func TestProcessChunksForDataflow_MetadataExtraction(t *testing.T) {
- chunks := []map[string]any{
- {"text": "hello", "metadata": map[string]any{"author": "Alice", "year": "2024"}},
- }
- meta := ProcessChunksForDataflow(chunks, "doc-1", "kb-1", "test-doc.pdf", time.Now())
-
- if _, exists := chunks[0]["metadata"]; exists {
- t.Error("metadata key should be removed from chunk")
- }
- if meta["author"] != "Alice" {
- t.Errorf("metadata[\"author\"] = %q, want \"Alice\"", meta["author"])
- }
-}
-
-func TestProcessChunksForDataflow_MetadataMergeAcrossChunks(t *testing.T) {
- chunks := []map[string]any{
- {"text": "chunk1", "metadata": map[string]any{"author": "Alice"}},
- {"text": "chunk2", "metadata": map[string]any{"year": "2024"}},
- }
- meta := ProcessChunksForDataflow(chunks, "doc-1", "kb-1", "test-doc.pdf", time.Now())
-
- if meta["author"] != "Alice" {
- t.Errorf("author = %q, want \"Alice\"", meta["author"])
- }
- if meta["year"] != "2024" {
- t.Errorf("year = %v, want \"2024\"", meta["year"])
- }
-}
-
-func TestProcessChunksForDataflow_NilChunks(t *testing.T) {
- meta := ProcessChunksForDataflow(nil, "doc-1", "kb-1", "test-doc.pdf", time.Now())
- if len(meta) != 0 {
- t.Errorf("metadata should be empty for nil chunks, got %v", meta)
- }
-}
diff --git a/internal/ingestion/task/chunk_utils.go b/internal/ingestion/task/chunk_utils.go
index aa88aaf5f6..e43e261df5 100644
--- a/internal/ingestion/task/chunk_utils.go
+++ b/internal/ingestion/task/chunk_utils.go
@@ -142,23 +142,3 @@ func MustGetChunkTextString(chunk map[string]any, where string) string {
common.Error(msg, nil)
panic(msg)
}
-
-// AttachVectors attaches embedding vectors to chunks in-place.
-// Each chunk gets a key like "q_{dim}_vec" with the vector as []float64.
-// Mirrors Python: EmbeddingUtils.attach_vectors()
-func AttachVectors(chunks []map[string]any, vectors [][]float64) int {
- if len(chunks) == 0 && len(vectors) == 0 {
- return 0
- }
- if len(vectors) != len(chunks) {
- panic(fmt.Sprintf("vectors/chunks length mismatch: %d != %d", len(vectors), len(chunks)))
- }
- vectorSize := 0
- for i, doc := range chunks {
- vec := vectors[i]
- vectorSize = len(vec)
- key := fmt.Sprintf("q_%d_vec", vectorSize)
- doc[key] = vec
- }
- return vectorSize
-}
diff --git a/internal/ingestion/task/chunk_utils_test.go b/internal/ingestion/task/chunk_utils_test.go
index 7bdb042c59..6feefc3b15 100644
--- a/internal/ingestion/task/chunk_utils_test.go
+++ b/internal/ingestion/task/chunk_utils_test.go
@@ -331,97 +331,3 @@ func TestMustGetChunkTextString_PanicOnStringSlice(t *testing.T) {
}()
_ = MustGetChunkTextString(chunk, "unit-test")
}
-
-// =============================================================================
-// AttachVectors
-// =============================================================================
-
-func TestAttachVectors_Basic(t *testing.T) {
- chunks := []map[string]any{
- {"text": "hello"},
- {"text": "world"},
- }
- vectors := [][]float64{
- {0.1, 0.2, 0.3},
- {0.4, 0.5, 0.6},
- }
- dim := AttachVectors(chunks, vectors)
- if dim != 3 {
- t.Errorf("dim = %d, want 3", dim)
- }
- if len(chunks[0]) == 0 || chunks[0]["q_3_vec"] == nil {
- t.Errorf("expected q_3_vec in chunk[0], got %v", chunks[0])
- }
- vec0 := chunks[0]["q_3_vec"].([]float64)
- if len(vec0) != 3 || vec0[0] != 0.1 {
- t.Errorf("chunk[0] vector = %v, want [0.1 0.2 0.3]", vec0)
- }
- vec1 := chunks[1]["q_3_vec"].([]float64)
- if len(vec1) != 3 || vec1[2] != 0.6 {
- t.Errorf("chunk[1] vector = %v, want [0.4 0.5 0.6]", vec1)
- }
-}
-
-func TestAttachVectors_EmptyChunks(t *testing.T) {
- result := AttachVectors(nil, nil)
- if result != 0 {
- t.Errorf("expected 0 for empty chunks, got %d", result)
- }
-}
-
-func TestAttachVectors_DifferentDimensions(t *testing.T) {
- chunks := []map[string]any{
- {"text": "a"},
- {"text": "b"},
- }
- vectors := [][]float64{
- {0.1, 0.2},
- {0.3, 0.4, 0.5},
- }
- // Each chunk gets its own vector with key based on its dimension
- AttachVectors(chunks, vectors)
- if chunks[0]["q_2_vec"] == nil {
- t.Errorf("chunk[0] should have q_2_vec, got keys: %v", chunkKeys(chunks[0]))
- }
- if chunks[1]["q_3_vec"] == nil {
- t.Errorf("chunk[1] should have q_3_vec, got keys: %v", chunkKeys(chunks[1]))
- }
-}
-
-func TestAttachVectors_MismatchedCount(t *testing.T) {
- chunks := []map[string]any{
- {"text": "hello"},
- }
- vectors := [][]float64{
- {0.1},
- {0.2},
- }
- defer func() {
- if r := recover(); r == nil {
- t.Error("expected panic for mismatched count")
- }
- }()
- AttachVectors(chunks, vectors)
-}
-
-func TestAttachVectors_KeyFormat(t *testing.T) {
- chunks := []map[string]any{
- {"text": "hello"},
- }
- vectors := [][]float64{
- {0.1, 0.2, 0.3, 0.4, 0.5},
- }
- AttachVectors(chunks, vectors)
- key := "q_5_vec"
- if chunks[0][key] == nil {
- t.Errorf("expected key %q in chunk, got keys: %v", key, chunkKeys(chunks[0]))
- }
-}
-
-func chunkKeys(m map[string]any) []string {
- keys := make([]string, 0, len(m))
- for k := range m {
- keys = append(keys, k)
- }
- return keys
-}
diff --git a/internal/ingestion/task/dataflow_real_pipeline_integration_test.go b/internal/ingestion/task/dataflow_real_pipeline_integration_test.go
index c97fd779bd..98d4fe690f 100644
--- a/internal/ingestion/task/dataflow_real_pipeline_integration_test.go
+++ b/internal/ingestion/task/dataflow_real_pipeline_integration_test.go
@@ -107,7 +107,12 @@ func TestDataflowService_Run_RealCanvasDSL_UsesGeneralPipeline(t *testing.T) {
})
taskCtx := &TaskContext{
- Task: entity.Task{ID: "task-real-canvas-1", DocID: docID, TaskType: "dataflow"},
+ IngestionTask: &entity.IngestionTask{
+ ID: "task-real-canvas-1",
+ DocumentID: docID,
+ DatasetID: kbID,
+ },
+ TaskType: "dataflow",
Doc: entity.Document{
ID: docID,
KbID: kbID,
@@ -241,7 +246,12 @@ func TestDataflowService_Run_RealPDF_WritesAndReadsBackFromElasticsearch(t *test
})
taskCtx := &TaskContext{
- Task: entity.Task{ID: "task-real-pdf-es-1", DocID: docID, TaskType: "dataflow"},
+ IngestionTask: &entity.IngestionTask{
+ ID: "task-real-pdf-es-1",
+ DocumentID: docID,
+ DatasetID: kbID,
+ },
+ TaskType: "dataflow",
Doc: entity.Document{
ID: docID,
KbID: kbID,
@@ -360,7 +370,12 @@ func TestRunDataflow_RealPipelineOutput_ProducesIndexFields(t *testing.T) {
pipelineOut = taskTerminalPayloadFromRunOutput(t, pipelineOut, "Tokenizer:LegalReadersDecide")
taskCtx := &TaskContext{
- Task: entity.Task{ID: "task-real-1", DocID: docID},
+ IngestionTask: &entity.IngestionTask{
+ ID: "task-real-1",
+ DocumentID: docID,
+ DatasetID: kbID,
+ },
+ TaskType: "dataflow",
Doc: entity.Document{
ID: docID,
KbID: kbID,
@@ -507,25 +522,12 @@ func prepareTokenizerResourceForTaskIntegration(t *testing.T) {
if os.Getenv("RAGFLOW_DICT_PATH") != "" {
return
}
- srcDir := filepath.Join(taskRepoRoot(t), ".venv", "lib", "python3.13", "site-packages", "infinity")
- if _, err := os.Stat(filepath.Join(srcDir, "huqie.txt")); err != nil {
- t.Skipf("tokenizer resource source not found at %s: %v", srcDir, err)
+ const systemDictPath = "/usr/share/infinity/resource"
+ if _, err := os.Stat(filepath.Join(systemDictPath, "rag", "huqie.txt")); err != nil {
+ t.Skipf("system tokenizer resource not found at %s: %v", systemDictPath, err)
}
- if _, err := os.Stat(filepath.Join(srcDir, "huqie.txt.trie")); err != nil {
- t.Skipf("tokenizer trie source not found at %s: %v", srcDir, err)
- }
- root := t.TempDir()
- ragDir := filepath.Join(root, "rag")
- if err := os.MkdirAll(ragDir, 0o755); err != nil {
- t.Fatalf("mkdir rag tokenizer dir: %v", err)
- }
- taskMustSymlink(t, filepath.Join(srcDir, "huqie.txt"), filepath.Join(ragDir, "huqie.txt"))
- taskMustSymlink(t, filepath.Join(srcDir, "huqie.txt.trie"), filepath.Join(ragDir, "huqie.trie"))
- taskMustWriteTokenizerPOSDef(t, filepath.Join(srcDir, "huqie.txt"), filepath.Join(ragDir, "pos-id.def"))
- taskMustPrepareTokenizerWordNet(t, root)
- taskMustPrepareTokenizerOpenCC(t, root)
- if err := os.Setenv("RAGFLOW_DICT_PATH", root); err != nil {
- t.Fatalf("set RAGFLOW_DICT_PATH: %v", err)
+ if err := os.Setenv("RAGFLOW_DICT_PATH", systemDictPath); err != nil {
+ t.Fatalf("set RAGFLOW_DICT_PATH=%s: %v", systemDictPath, err)
}
t.Cleanup(func() {
_ = os.Unsetenv("RAGFLOW_DICT_PATH")
diff --git a/internal/ingestion/task/dataflow_service.go b/internal/ingestion/task/dataflow_service.go
index a445fd154f..cef839b79d 100644
--- a/internal/ingestion/task/dataflow_service.go
+++ b/internal/ingestion/task/dataflow_service.go
@@ -20,6 +20,7 @@ import (
"context"
"encoding/json"
"fmt"
+ "ragflow/internal/agent/runtime"
componentpkg "ragflow/internal/ingestion/component"
"ragflow/internal/utility"
"regexp"
@@ -40,15 +41,22 @@ type embedder struct {
model *models.EmbeddingModel
}
-func (e *embedder) Encode(texts []string) ([][]float64, error) {
+func (e *embedder) MaxTokens() int {
+ if e == nil || e.model == nil {
+ return 0
+ }
+ return e.model.MaxTokens
+}
+
+func (e *embedder) Encode(texts []string) ([]componentpkg.EmbeddingResult, error) {
config := &models.EmbeddingConfig{Dimension: 0}
embeds, err := e.model.ModelDriver.Embed(e.model.ModelName, texts, e.model.APIConfig, config)
if err != nil {
return nil, err
}
- vecs := make([][]float64, len(embeds))
+ vecs := make([]componentpkg.EmbeddingResult, len(embeds))
for i, v := range embeds {
- vecs[i] = v.Embedding
+ vecs[i] = componentpkg.EmbeddingResult{Vector: v.Embedding, TokenCount: v.TokenCount}
}
return vecs, nil
}
@@ -107,13 +115,14 @@ type PipelineExecutor struct {
docBulkSize int
progressFunc ProgressFunc
- docSvc docService
- chunkCounter chunkCounter
- insertChunksFunc func(ctx context.Context, chunks []map[string]any, baseName string, datasetID string) ([]string, error)
- logCreateFunc func(log *entity.PipelineOperationLog) error
- getEmbeddingModelFunc func(tenantID, embdID string) (*models.EmbeddingModel, error)
- loadDSLFunc func(ctx context.Context, dataflowID string) (string, string, error)
- runPipelineFunc func(ctx context.Context, dsl string) (map[string]any, string, error)
+ docSvc docService
+ chunkCounter chunkCounter
+ insertChunksFunc func(ctx context.Context, chunks []map[string]any, baseName string, datasetID string) ([]string, error)
+ logCreateFunc func(log *entity.PipelineOperationLog) error
+ getEmbeddingModelFunc func(tenantID, embdID string) (*models.EmbeddingModel, error)
+ getKnowledgebaseByIDFunc func(kbID string) (*entity.Knowledgebase, error)
+ loadDSLFunc func(ctx context.Context, dataflowID string) (string, string, error)
+ runPipelineFunc func(ctx context.Context, dsl string) (map[string]any, string, error)
}
func validateDataflowTaskContext(taskCtx *TaskContext) error {
@@ -132,9 +141,6 @@ func validateDataflowTaskContext(taskCtx *TaskContext) error {
if taskCtx.KB.ID == "" {
return fmt.Errorf("dataflow service: empty knowledgebase id")
}
- if taskCtx.KB.EmbdID == "" {
- return fmt.Errorf("dataflow service: empty embedding model id")
- }
if taskCtx.Tenant.ID == "" {
return fmt.Errorf("dataflow service: empty tenant id")
}
@@ -157,6 +163,7 @@ func NewDataflowService(
if taskCtx != nil && taskCtx.ProgressFunc != nil {
progressFn = taskCtx.ProgressFunc
}
+ modelProvider := service.NewModelProviderService()
svc := &PipelineExecutor{
taskCtx: taskCtx,
dataflowID: dataflowID,
@@ -168,8 +175,9 @@ func NewDataflowService(
insertChunksFunc: func(ctx context.Context, chunks []map[string]any, baseName string, datasetID string) ([]string, error) {
return engine.Get().InsertChunks(ctx, chunks, baseName, datasetID)
},
- logCreateFunc: dao.NewPipelineOperationLogDAO().Create,
- getEmbeddingModelFunc: service.NewModelProviderService().GetEmbeddingModel,
+ logCreateFunc: dao.NewPipelineOperationLogDAO().Create,
+ getEmbeddingModelFunc: modelProvider.GetEmbeddingModel,
+ getKnowledgebaseByIDFunc: dao.NewKnowledgebaseDAO().GetByID,
}
svc.loadDSLFunc = svc.defaultLoadDSL
svc.runPipelineFunc = svc.defaultRunPipeline
@@ -300,59 +308,6 @@ func (s *PipelineExecutor) normalizeChunks(output map[string]any) []map[string]a
return NormalizeChunks(output)
}
-func (s *PipelineExecutor) embedChunks(ctx context.Context, chunks []map[string]any, tokenConsumption int) ([]map[string]any, int, error) {
- if len(chunks) == 0 {
- return nil, 0, nil
- }
- s.progress(0.82, "\n-------------------------------------\nStart to embedding...")
- model, err := s.getEmbeddingModel(s.taskCtx.Tenant.ID, s.taskCtx.KB.EmbdID)
- if err != nil {
- s.progress(-1, fmt.Sprintf("[ERROR]: %v", err))
- return nil, tokenConsumption, err
- }
- texts := PrepareTextsForDataflowEmbedding(chunks)
- batchSize := s.embeddingBatchSize
- if batchSize <= 0 {
- batchSize = 16
- }
- delta := 0.20 / float64(len(texts)/batchSize+1)
- prog := 0.8
- var allVects [][]float64
- for i := 0; i < len(texts); i += batchSize {
- end := i + batchSize
- if end > len(texts) {
- end = len(texts)
- }
- batch := texts[i:end]
- if lim := s.taskCtx.EmbedLimiter; lim != nil {
- if err := lim.Acquire(ctx, 1); err != nil {
- s.progress(-1, fmt.Sprintf("[ERROR]: %v", err))
- return nil, tokenConsumption, err
- }
- }
- vecs, tc, err := encodeTexts(model, batch)
- if err != nil {
- if lim := s.taskCtx.EmbedLimiter; lim != nil {
- lim.Release(1)
- }
- s.progress(-1, fmt.Sprintf("[ERROR]: %v", err))
- return nil, tokenConsumption, err
- }
- if lim := s.taskCtx.EmbedLimiter; lim != nil {
- lim.Release(1)
- }
- allVects = append(allVects, vecs...)
- tokenConsumption += tc
- prog += delta
- s.progress(prog, fmt.Sprintf("%d / %d", i+1, len(texts)/batchSize))
- }
- if len(allVects) != len(chunks) {
- panic(fmt.Sprintf("vector count mismatch: %d vs %d", len(allVects), len(chunks)))
- }
- AttachVectors(chunks, allVects)
- return chunks, tokenConsumption, nil
-}
-
func (s *PipelineExecutor) processChunks(chunks []map[string]any) map[string]any {
return ProcessChunksForDataflow(
chunks,
@@ -496,21 +451,41 @@ func (s *PipelineExecutor) defaultLoadDSL(ctx context.Context, dataflowID string
return string(raw), correctedID, nil
}
+func (s *PipelineExecutor) tokenizerEmbedderResolver() componentpkg.EmbedderResolver {
+ return func(tenantID, kbID, modelID string) (componentpkg.Embedder, error) {
+ if strings.TrimSpace(tenantID) == "" && s != nil && s.taskCtx != nil {
+ tenantID = s.taskCtx.Tenant.ID
+ }
+ if strings.TrimSpace(kbID) == "" && s != nil && s.taskCtx != nil {
+ kbID = s.taskCtx.KB.ID
+ }
+ model, err := s.resolveEmbeddingModel(tenantID, kbID, modelID)
+ if err != nil {
+ return nil, err
+ }
+ return &embedder{model: model}, nil
+ }
+}
+
+func (s *PipelineExecutor) taskScopedComponentFactory() runtime.ComponentFactory {
+ resolver := s.tokenizerEmbedderResolver()
+ return func(name string, params map[string]any) (runtime.Component, error) {
+ if strings.EqualFold(name, componentpkg.ComponentNameTokenizer) {
+ return componentpkg.NewTokenizerComponentWithResolver(params, resolver)
+ }
+ factory, _, _, ok := runtime.DefaultRegistry.Lookup(name)
+ if !ok {
+ return nil, fmt.Errorf("runtime: unknown component %q", name)
+ }
+ return factory(name, params)
+ }
+}
+
func (s *PipelineExecutor) defaultRunPipeline(ctx context.Context, dsl string) (map[string]any, string, error) {
if s == nil || s.taskCtx == nil {
return nil, dsl, fmt.Errorf("dataflow service: nil task context")
}
- prevEncode := componentpkg.EncodeFunc
- componentpkg.EncodeFunc = func(tenantID, embdID string) componentpkg.Embedder {
- model, err := s.getEmbeddingModelFunc(tenantID, embdID)
- if err != nil {
- return nil
- }
- return &embedder{model: model}
- }
- defer func() { componentpkg.EncodeFunc = prevEncode }()
-
// Use doc ID as pipeline ID if available, otherwise a placeholder
pipelineID := "pipeline_" + s.taskCtx.Doc.ID
if s.taskCtx.IngestionTask != nil && s.taskCtx.IngestionTask.ID != "" {
@@ -520,6 +495,7 @@ func (s *PipelineExecutor) defaultRunPipeline(ctx context.Context, dsl string) (
if err != nil {
return nil, dsl, fmt.Errorf("compile pipeline dsl: %w", err)
}
+ pipe.WithComponentFactory(s.taskScopedComponentFactory())
inputs := map[string]any{}
if s.taskCtx.Doc.ID != "" {
inputs["doc_id"] = s.taskCtx.Doc.ID
@@ -528,7 +504,7 @@ func (s *PipelineExecutor) defaultRunPipeline(ctx context.Context, dsl string) (
inputs["file"] = s.taskCtx.File
}
inputs["tenant_id"] = s.taskCtx.Tenant.ID
- inputs["model_id"] = s.taskCtx.KB.EmbdID
+ inputs["kb_id"] = s.taskCtx.KB.ID
output, err := pipe.Run(ctx, inputs)
if err != nil {
@@ -541,6 +517,29 @@ func (s *PipelineExecutor) defaultRunPipeline(ctx context.Context, dsl string) (
return payload, dsl, nil
}
+func (s *PipelineExecutor) resolveEmbeddingModel(tenantID, kbID, modelID string) (*models.EmbeddingModel, error) {
+ _ = modelID
+ if strings.TrimSpace(kbID) != "" {
+ if s.taskCtx != nil && s.taskCtx.KB.ID == kbID && strings.TrimSpace(s.taskCtx.KB.EmbdID) != "" {
+ return s.getEmbeddingModelFunc(tenantID, s.taskCtx.KB.EmbdID)
+ }
+ if s.getKnowledgebaseByIDFunc == nil {
+ return nil, fmt.Errorf("knowledgebase resolver unavailable")
+ }
+ kb, err := s.getKnowledgebaseByIDFunc(kbID)
+ if err != nil {
+ return nil, err
+ }
+ if kb != nil && strings.TrimSpace(kb.EmbdID) != "" {
+ return s.getEmbeddingModelFunc(tenantID, kb.EmbdID)
+ }
+ }
+ if s.taskCtx != nil && strings.TrimSpace(s.taskCtx.KB.EmbdID) != "" {
+ return s.getEmbeddingModelFunc(tenantID, s.taskCtx.KB.EmbdID)
+ }
+ return nil, fmt.Errorf("embedding requested but dataset has no embd_id configured")
+}
+
func extractDataflowPipelinePayload(dsl string, out map[string]any) (map[string]any, error) {
if out == nil {
return nil, nil
diff --git a/internal/ingestion/task/dataflow_service_test.go b/internal/ingestion/task/dataflow_service_test.go
index ffa2954ee6..9ac6b0e3ca 100644
--- a/internal/ingestion/task/dataflow_service_test.go
+++ b/internal/ingestion/task/dataflow_service_test.go
@@ -3,7 +3,6 @@ package task
import (
"context"
"encoding/json"
- "errors"
"strings"
"testing"
"time"
@@ -185,7 +184,6 @@ func TestNewDataflowService_RejectsIncompleteTaskContext(t *testing.T) {
{name: "missing kb id", mutate: func(ctx *TaskContext) { ctx.Doc.KbID = "" }},
{name: "missing doc name", mutate: func(ctx *TaskContext) { ctx.Doc.Name = nil }},
{name: "missing knowledgebase id", mutate: func(ctx *TaskContext) { ctx.KB.ID = "" }},
- {name: "missing embedding model id", mutate: func(ctx *TaskContext) { ctx.KB.EmbdID = "" }},
{name: "missing tenant id", mutate: func(ctx *TaskContext) { ctx.Tenant.ID = "" }},
}
@@ -256,71 +254,6 @@ func TestDataflowService_ProcessChunks_WrapsProcessChunksForDataflow(t *testing.
}
}
-// =============================================================================
-// embedChunks — Python: _embed_chunks (line 247)
-// =============================================================================
-
-func TestEmbedChunks_Success(t *testing.T) {
- stub := &stubDriver{}
- svc := mustNewDataflowService(t, makeTaskCtx(), "flow-1", 0, 0).WithGetEmbeddingModelFunc(
- func(tenantID, embdID string) (*models.EmbeddingModel, error) {
- return makeTestEmbeddingModel(stub, 100), nil
- },
- )
- chunks := []map[string]any{
- {"text": "hello"},
- {"text": "world"},
- }
- result, tc, err := svc.embedChunks(context.Background(), chunks, 5)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if len(result) != 2 {
- t.Fatalf("len = %d, want 2", len(result))
- }
- // vectors should be attached (q_*_vec keys)
- if result[0]["q_2_vec"] == nil {
- t.Errorf("expected q_2_vec in chunk[0], got keys: %v", chunkKeys(result[0]))
- }
- // token consumption should include initial + new tokens
- if tc < 5 {
- t.Errorf("token consumption should be at least the initial 5, got %d", tc)
- }
-}
-
-func TestEmbedChunks_EmptyChunks(t *testing.T) {
- svc := mustNewDataflowService(t, makeTaskCtx(), "flow-1", 0, 0)
- result, _, err := svc.embedChunks(context.Background(), nil, 0)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if result != nil {
- t.Errorf("expected nil for empty chunks, got %v", result)
- }
-}
-
-func TestEmbedChunks_ModelError(t *testing.T) {
- svc := mustNewDataflowService(t, makeTaskCtx(), "flow-1", 0, 0).WithGetEmbeddingModelFunc(
- func(tenantID, embdID string) (*models.EmbeddingModel, error) {
- return nil, errors.New("model not found")
- },
- )
- chunks := []map[string]any{{"text": "hello"}}
- result, tc, err := svc.embedChunks(context.Background(), chunks, 10)
- if err == nil {
- t.Fatal("expected error on model error")
- }
- if !strings.Contains(err.Error(), "model not found") {
- t.Errorf("expected error containing 'model not found', got %v", err)
- }
- if result != nil {
- t.Errorf("expected nil result on model error, got %v", result)
- }
- if tc != 10 {
- t.Errorf("token consumption should be preserved on error, got %d", tc)
- }
-}
-
// =============================================================================
// progress
// =============================================================================
@@ -695,30 +628,6 @@ func TestDataflowService_Run_MainFlowWithStubs(t *testing.T) {
}
}
-func TestEmbedChunks_ReportsIntermediateProgress(t *testing.T) {
- stub := &stubDriver{}
- var progressCalls []float64
- svc := mustNewDataflowService(t, makeTaskCtx(), "flow-1", 1, 0).
- WithGetEmbeddingModelFunc(func(tenantID, embdID string) (*models.EmbeddingModel, error) {
- return makeTestEmbeddingModel(stub, 100), nil
- }).
- WithProgressFunc(func(prog float64, msg string) {
- progressCalls = append(progressCalls, prog)
- })
-
- chunks := []map[string]any{
- {"text": "hello"},
- {"text": "world"},
- }
- _, _, err := svc.embedChunks(context.Background(), chunks, 0)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if len(progressCalls) < 2 {
- t.Fatalf("expected start and intermediate progress calls, got %v", progressCalls)
- }
-}
-
func TestInsertChunks_ReportsBatchProgress(t *testing.T) {
var progressCalls []float64
svc := mustNewDataflowService(t, makeTaskCtx(), "flow-1", 0, 1).
@@ -742,26 +651,6 @@ func TestInsertChunks_ReportsBatchProgress(t *testing.T) {
}
}
-func TestEmbedChunks_ErrorReportsNegativeProgressAndReturnsError(t *testing.T) {
- var progressCalls []float64
- svc := mustNewDataflowService(t, makeTaskCtx(), "flow-1", 1, 0).
- WithGetEmbeddingModelFunc(func(tenantID, embdID string) (*models.EmbeddingModel, error) {
- return nil, errors.New("model unavailable")
- }).
- WithProgressFunc(func(prog float64, msg string) {
- progressCalls = append(progressCalls, prog)
- })
-
- chunks := []map[string]any{{"text": "hello"}}
- _, _, err := svc.embedChunks(context.Background(), chunks, 0)
- if err == nil {
- t.Fatal("expected error")
- }
- if len(progressCalls) == 0 || progressCalls[len(progressCalls)-1] != -1 {
- t.Fatalf("expected final error progress -1, got %v", progressCalls)
- }
-}
-
// =============================================================================
// Stub implementations for testing
// =============================================================================
@@ -824,3 +713,104 @@ var (
_ docService = (*stubDocService)(nil)
_ chunkCounter = (*stubChunkCounter)(nil)
)
+
+func makeEmbeddingModelForResolver() *models.EmbeddingModel {
+ return models.NewEmbeddingModel(&stubDriver{}, strPtr("embed"), &models.APIConfig{}, 128)
+}
+
+func TestPipelineExecutor_ResolveEmbeddingModel_IgnoresModelIDAndUsesDatasetEmbedding(t *testing.T) {
+ svc := mustNewDataflowService(t, makeTaskCtx(), "flow-1", 0, 0)
+ var gotTenantID, gotModelID string
+ svc.getEmbeddingModelFunc = func(tenantID, embdID string) (*models.EmbeddingModel, error) {
+ gotTenantID, gotModelID = tenantID, embdID
+ return makeEmbeddingModelForResolver(), nil
+ }
+ model, err := svc.resolveEmbeddingModel("tenant-1", "kb-1", "override-model")
+ if err != nil {
+ t.Fatalf("resolveEmbeddingModel: %v", err)
+ }
+ if model == nil {
+ t.Fatal("expected model")
+ }
+ if gotTenantID != "tenant-1" || gotModelID != "embd-1" {
+ t.Fatalf("resolver args = (%q, %q), want (tenant-1, embd-1)", gotTenantID, gotModelID)
+ }
+}
+
+func TestPipelineExecutor_ResolveEmbeddingModel_KnowledgebaseModel(t *testing.T) {
+ svc := mustNewDataflowService(t, makeTaskCtx(), "flow-1", 0, 0)
+ var gotModelID string
+ svc.getEmbeddingModelFunc = func(_ string, embdID string) (*models.EmbeddingModel, error) {
+ gotModelID = embdID
+ return makeEmbeddingModelForResolver(), nil
+ }
+ _, err := svc.resolveEmbeddingModel("tenant-1", "kb-1", "")
+ if err != nil {
+ t.Fatalf("resolveEmbeddingModel: %v", err)
+ }
+ if gotModelID != "embd-1" {
+ t.Fatalf("got model id %q, want embd-1", gotModelID)
+ }
+}
+
+func TestPipelineExecutor_ResolveEmbeddingModel_KnowledgebaseLookupFallback(t *testing.T) {
+ svc := mustNewDataflowService(t, makeTaskCtx(), "flow-1", 0, 0)
+ svc.taskCtx.KB.ID = "other-kb"
+ var gotModelID string
+ svc.getEmbeddingModelFunc = func(_ string, embdID string) (*models.EmbeddingModel, error) {
+ gotModelID = embdID
+ return makeEmbeddingModelForResolver(), nil
+ }
+ svc.getKnowledgebaseByIDFunc = func(kbID string) (*entity.Knowledgebase, error) {
+ if kbID != "kb-2" {
+ t.Fatalf("kb lookup id = %q, want kb-2", kbID)
+ }
+ return &entity.Knowledgebase{ID: "kb-2", EmbdID: "lookup-embd"}, nil
+ }
+ _, err := svc.resolveEmbeddingModel("tenant-1", "kb-2", "")
+ if err != nil {
+ t.Fatalf("resolveEmbeddingModel: %v", err)
+ }
+ if gotModelID != "lookup-embd" {
+ t.Fatalf("got model id %q, want lookup-embd", gotModelID)
+ }
+}
+
+func TestPipelineExecutor_ResolveEmbeddingModel_MissingDatasetEmbeddingReturnsError(t *testing.T) {
+ svc := mustNewDataflowService(t, makeTaskCtx(), "flow-1", 0, 0)
+ svc.taskCtx.KB.EmbdID = ""
+ svc.getKnowledgebaseByIDFunc = func(string) (*entity.Knowledgebase, error) {
+ return &entity.Knowledgebase{ID: "kb-1", EmbdID: ""}, nil
+ }
+ svc.getEmbeddingModelFunc = func(_ string, _ string) (*models.EmbeddingModel, error) {
+ t.Fatal("knowledgebase resolver should not be called")
+ return nil, nil
+ }
+ _, err := svc.resolveEmbeddingModel("tenant-1", "kb-1", "")
+ if err == nil {
+ t.Fatal("expected error when dataset embd_id is missing, got nil")
+ }
+ if !strings.Contains(err.Error(), "dataset has no embd_id configured") {
+ t.Fatalf("err = %v, want dataset has no embd_id configured", err)
+ }
+}
+
+func TestPipelineExecutor_TokenizerEmbedderResolver_FallsBackToTaskContext(t *testing.T) {
+ svc := mustNewDataflowService(t, makeTaskCtx(), "flow-1", 0, 0)
+ var gotTenantID, gotModelID string
+ svc.getEmbeddingModelFunc = func(tenantID, embdID string) (*models.EmbeddingModel, error) {
+ gotTenantID, gotModelID = tenantID, embdID
+ return makeEmbeddingModelForResolver(), nil
+ }
+ resolver := svc.tokenizerEmbedderResolver()
+ emb, err := resolver("", "", "")
+ if err != nil {
+ t.Fatalf("resolver: %v", err)
+ }
+ if emb == nil {
+ t.Fatal("expected embedder")
+ }
+ if gotTenantID != "tenant-1" || gotModelID != "embd-1" {
+ t.Fatalf("resolver args = (%q, %q), want (tenant-1, embd-1)", gotTenantID, gotModelID)
+ }
+}
diff --git a/internal/ingestion/task/embedding_test.go b/internal/ingestion/task/embedding_test.go
index 25d5c9613d..1b3079b2f0 100644
--- a/internal/ingestion/task/embedding_test.go
+++ b/internal/ingestion/task/embedding_test.go
@@ -148,32 +148,6 @@ func TestEncodeTexts_ReturnsVectors(t *testing.T) {
}
}
-// =============================================================================
-// GetEmbeddingDimension
-// =============================================================================
-
-func TestTestEncodeForDim_ReturnsDimension(t *testing.T) {
- stub := &stubDriver{}
- model := makeTestEmbeddingModel(stub, 100)
- dim, err := getEmbeddingDimension(model)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if dim != 2 {
- t.Errorf("dim = %d, want 2 (stubDriver returns 2-element vectors)", dim)
- }
-}
-
-func TestTestEncodeForDim_ModelError(t *testing.T) {
- stub := &stubDriver{}
- model := makeTestEmbeddingModel(stub, 100)
- model.ModelDriver = &errDriver{}
- _, err := getEmbeddingDimension(model)
- if err == nil {
- t.Error("expected error from failing driver")
- }
-}
-
type errDriver struct{}
func (d *errDriver) Embed(modelName *string, texts []string, apiConfig *models.APIConfig, embeddingConfig *models.EmbeddingConfig) ([]models.EmbeddingData, error) {
diff --git a/internal/ingestion/task/naive_merge.go b/internal/ingestion/task/naive_merge.go
deleted file mode 100644
index 83fc83f4cd..0000000000
--- a/internal/ingestion/task/naive_merge.go
+++ /dev/null
@@ -1,269 +0,0 @@
-//
-// 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.
-//
-
-package task
-
-import (
- "regexp"
- "sort"
- "strings"
-
- "ragflow/internal/tokenizer"
-)
-
-// MergedSegment is a merged text chunk produced by NaiveMerge.
-// SectionIndices tracks which input sections contributed to this segment,
-// enabling the caller to associate images from those sections.
-type MergedSegment struct {
- Text string `json:"text"`
- SectionIndices []int `json:"section_indices"`
- Position string `json:"position,omitempty"`
-}
-
-// indexedText pairs text content with its original index and position.
-type indexedText struct {
- text string
- idx int
- pos string
-}
-
-// NaiveMerge merges texts into chunks by token count with delimiter-based
-// splitting and overlap. Matches Python naive_merge_with_images().
-//
-// Parameters:
-// - texts: input text segments (one per parsed section)
-// - positions: optional position strings (one per section, may be nil)
-// - chunkTokenNum: max token count per chunk (default 128 in Python)
-// - delimiter: delimiter pattern, supports backtick-quoted custom delimiters
-// - overlappedPercent: overlap percentage (0-100)
-func NaiveMerge(texts []string, positions []string, chunkTokenNum int, delimiter string, overlappedPercent float64) []MergedSegment {
- if len(texts) == 0 {
- return nil
- }
-
- // Filter out empty texts but track original indices and positions
- var filtered []indexedText
- for i, t := range texts {
- if strings.TrimSpace(t) != "" {
- pos := ""
- if positions != nil && i < len(positions) {
- pos = positions[i]
- }
- filtered = append(filtered, indexedText{text: t, idx: i, pos: pos})
- }
- }
- if len(filtered) == 0 {
- return nil
- }
-
- // Parse delimiter: extract custom (backtick-quoted) and normal delimiters
- customDels, normalPattern := parseDelimiters(delimiter)
-
- // Custom delimiter mode: each segment is its own chunk, no merging by token count
- if len(customDels) > 0 {
- return mergeByCustomDelimiters(filtered, customDels, chunkTokenNum)
- }
-
- // Normal mode: split oversized texts at delimiters, merge by token count
- return mergeByTokenCount(filtered, normalPattern, chunkTokenNum, overlappedPercent)
-}
-
-// ── Delimiter parsing ───────────────────────────────────────────────────────
-
-// parseDelimiters extracts custom (backtick-quoted) and normal delimiters.
-// Matches Python get_delimiters().
-func parseDelimiters(delimiter string) (custom []string, pattern string) {
- re := regexp.MustCompile("`([^`]+)`")
- matches := re.FindAllStringSubmatchIndex(delimiter, -1)
-
- var normalChars []rune
- lastEnd := 0
- for _, m := range matches {
- start, end := m[0], m[1]
- normalChars = append(normalChars, []rune(delimiter[lastEnd:start])...)
- custom = append(custom, delimiter[m[2]:m[3]])
- lastEnd = end
- }
- if lastEnd < len(delimiter) {
- normalChars = append(normalChars, []rune(delimiter[lastEnd:])...)
- }
-
- var dels []string
- seen := make(map[string]bool)
- for _, r := range normalChars {
- s := string(r)
- if !seen[s] && s != "" {
- dels = append(dels, regexp.QuoteMeta(s))
- seen[s] = true
- }
- }
-
- sort.Slice(dels, func(i, j int) bool {
- return len(dels[i]) > len(dels[j])
- })
-
- if len(dels) > 0 {
- pattern = strings.Join(dels, "|")
- }
- return
-}
-
-// ── Custom delimiter mode ───────────────────────────────────────────────────
-
-func mergeByCustomDelimiters(texts []indexedText, customDels []string, chunkTokenNum int) []MergedSegment {
- sort.Slice(customDels, func(i, j int) bool {
- return len(customDels[i]) > len(customDels[j])
- })
- var escaped []string
- for _, d := range customDels {
- escaped = append(escaped, regexp.QuoteMeta(d))
- }
- pattern := regexp.MustCompile("(" + strings.Join(escaped, "|") + ")")
-
- var result []MergedSegment
- for _, it := range texts {
- parts := pattern.Split(it.text, -1)
- for _, part := range parts {
- if part == "" {
- continue
- }
- isDelimiter := false
- for _, d := range customDels {
- if part == d {
- isDelimiter = true
- break
- }
- }
- if isDelimiter {
- continue
- }
-
- textSeg := "\n" + part
- pos := resolvePosition(textSeg, it.pos)
-
- result = append(result, MergedSegment{
- Text: textSeg,
- SectionIndices: []int{it.idx},
- Position: pos,
- })
- }
- }
- return result
-}
-
-// ── Normal (token-count-based) mode ─────────────────────────────────────────
-
-func mergeByTokenCount(texts []indexedText, delimPattern string, chunkTokenNum int, overlappedPercent float64) []MergedSegment {
- var result []MergedSegment
-
- threshold := float64(chunkTokenNum) * (100.0 - overlappedPercent) / 100.0
-
- for _, it := range texts {
- var subSegments []indexedText
- if delimPattern != "" && tokenizer.NumTokensFromString(it.text) >= chunkTokenNum {
- re := regexp.MustCompile("(" + delimPattern + ")")
- parts := re.Split(it.text, -1)
- for _, part := range parts {
- if part == "" {
- continue
- }
- if matched, _ := regexp.MatchString("^("+delimPattern+")$", part); matched {
- continue
- }
- subSegments = append(subSegments, indexedText{
- text: "\n" + part,
- idx: it.idx,
- pos: it.pos,
- })
- }
- } else {
- subSegments = append(subSegments, indexedText{
- text: "\n" + it.text,
- idx: it.idx,
- pos: it.pos,
- })
- }
-
- for _, sub := range subSegments {
- tk := tokenizer.NumTokensFromString(sub.text)
- pos := resolvePosition(sub.text, sub.pos)
-
- if len(result) == 0 {
- result = append(result, MergedSegment{
- Text: sub.text,
- SectionIndices: []int{sub.idx},
- Position: pos,
- })
- continue
- }
-
- last := &result[len(result)-1]
- lastTk := tokenizer.NumTokensFromString(last.Text)
-
- if lastTk > int(threshold) {
- newText := sub.text
- if overlappedPercent > 0 && lastTk > 0 {
- overlapChars := int(float64(len([]rune(last.Text))) * (100.0 - overlappedPercent) / 100.0)
- if overlapChars > 0 && overlapChars < len([]rune(last.Text)) {
- overlap := string([]rune(last.Text)[overlapChars:])
- newText = overlap + sub.text
- }
- }
- result = append(result, MergedSegment{
- Text: newText,
- SectionIndices: []int{sub.idx},
- Position: pos,
- })
- } else {
- last.Text += sub.text
- last.SectionIndices = appendUnique(last.SectionIndices, sub.idx)
- // Position: keep the first non-empty position
- if last.Position == "" && pos != "" {
- last.Position = pos
- }
- }
- _ = tk
- }
- }
-
- return result
-}
-
-// ── Helpers ─────────────────────────────────────────────────────────────────
-
-// resolvePosition applies Python position rules:
-// - if token count < 8 → clear position
-// - if position is not already in text → append to text (done by caller)
-// Returns the resolved position string.
-func resolvePosition(text, pos string) string {
- if pos == "" {
- return ""
- }
- tk := tokenizer.NumTokensFromString(text)
- if tk < 8 {
- return ""
- }
- return pos
-}
-
-func appendUnique(slice []int, val int) []int {
- for _, v := range slice {
- if v == val {
- return slice
- }
- }
- return append(slice, val)
-}
diff --git a/internal/ingestion/task/naive_merge_test.go b/internal/ingestion/task/naive_merge_test.go
deleted file mode 100644
index f70769f2ce..0000000000
--- a/internal/ingestion/task/naive_merge_test.go
+++ /dev/null
@@ -1,417 +0,0 @@
-//
-// 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.
-//
-
-package task
-
-import (
- "strings"
- "testing"
-
- "ragflow/internal/tokenizer"
-)
-
-// naiveMergeTests defines a table-driven test case for NaiveMerge.
-type naiveMergeTests struct {
- name string
- texts []string
- chunkTokenNum int
- delimiter string
- overlappedPct float64
-
- // Expectations
- minChunks int // at least this many chunks
- maxChunks int // at most this many chunks
- maxTokens int // no single chunk should exceed this token count
- contains []string // each chunk should contain these substrings
- notContains []string // no chunk should contain these
-}
-
-func TestNaiveMerge_TableDriven(t *testing.T) {
- tests := []naiveMergeTests{
- // ── Empty & nil inputs ──────────────────────────────────────────
- {
- name: "nil texts",
- texts: nil,
- chunkTokenNum: 128,
- delimiter: "\n。;!?",
- overlappedPct: 0,
- maxChunks: 0,
- },
- {
- name: "empty texts",
- texts: []string{},
- chunkTokenNum: 128,
- delimiter: "\n。;!?",
- overlappedPct: 0,
- maxChunks: 0,
- },
-
- // ── Single short text ───────────────────────────────────────────
- {
- name: "single short text",
- texts: []string{"Hello World"},
- chunkTokenNum: 128,
- delimiter: "\n。;!?",
- overlappedPct: 0,
- minChunks: 1,
- maxChunks: 1,
- contains: []string{"Hello World"},
- },
-
- // ── Multiple short texts merged into one chunk ──────────────────
- {
- name: "multiple short texts merge into one",
- texts: []string{"Short A", "Short B", "Short C"},
- chunkTokenNum: 128,
- delimiter: "\n。;!?",
- overlappedPct: 0,
- minChunks: 1,
- maxChunks: 1,
- contains: []string{"Short A", "Short B", "Short C"},
- },
-
- // ── Large text split at delimiters ──────────────────────────────
- {
- name: "long text split at delimiter",
- texts: []string{strings.Repeat("A。", 100)}, // 100 Chinese sentences
- chunkTokenNum: 128,
- delimiter: "\n。;!?",
- overlappedPct: 0,
- minChunks: 2, // should be split at '。'
- maxChunks: 20,
- },
-
- // ── Chinese delimiters ──────────────────────────────────────────
- {
- name: "chinese sentence delimiters",
- texts: []string{"第一句话。第二句话!第三句话?"},
- chunkTokenNum: 128,
- delimiter: "\n。;!?",
- overlappedPct: 0,
- minChunks: 1,
- maxChunks: 3,
- },
-
- // ── Overlap ─────────────────────────────────────────────────────
- {
- name: "overlap between chunks",
- texts: []string{
- strings.Repeat("A", 100) + "。",
- strings.Repeat("B", 100) + "。",
- strings.Repeat("C", 100) + "。",
- },
- chunkTokenNum: 50, // small enough to force multiple chunks
- delimiter: "\n。;!?",
- overlappedPct: 20, // 20% overlap
- minChunks: 2,
- maxChunks: 10,
- },
-
- // ── Custom delimiter (backtick-quoted) ──────────────────────────
- {
- name: "custom backtick delimiter",
- texts: []string{"Section 1 `CHAPTER` Section 2"},
- chunkTokenNum: 128,
- delimiter: "`CHAPTER`\n。;!?",
- overlappedPct: 0,
- minChunks: 2,
- maxChunks: 2,
- contains: []string{"Section 1", "Section 2"},
- notContains: []string{"CHAPTER"},
- },
-
- // ── Empty delimiter — no splitting ─────────────────────────────
- {
- name: "empty delimiter",
- texts: []string{"Keep As Is Without Split"},
- chunkTokenNum: 128,
- delimiter: "",
- overlappedPct: 0,
- minChunks: 1,
- maxChunks: 1,
- },
-
- // ── Multiple texts, some oversized, some not ────────────────────
- {
- name: "mixed sizes",
- texts: []string{
- "Tiny。",
- strings.Repeat("Long text that exceeds the token limit and should be split。", 20),
- "Another tiny。",
- },
- chunkTokenNum: 128,
- delimiter: "\n。;!?",
- overlappedPct: 0,
- minChunks: 2, // oversized text split, small ones may merge
- maxChunks: 30,
- },
-
- // ── All texts empty strings ─────────────────────────────────────
- {
- name: "all empty strings",
- texts: []string{"", "", ""},
- chunkTokenNum: 128,
- delimiter: "\n。;!?",
- overlappedPct: 0,
- maxChunks: 0,
- },
-
- // ── Very small chunk_token_num forces many chunks ───────────────
- {
- name: "tiny token limit",
- texts: []string{"一。二。三。四。五。六。"},
- chunkTokenNum: 5,
- delimiter: "\n。;!?",
- overlappedPct: 0,
- minChunks: 2, // should be split into multiple chunks
- maxChunks: 10,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- chunks := NaiveMerge(tt.texts, nil, tt.chunkTokenNum, tt.delimiter, tt.overlappedPct)
-
- // Check min/max count
- if len(chunks) < tt.minChunks {
- t.Errorf("got %d chunks, want at least %d", len(chunks), tt.minChunks)
- }
- if len(chunks) > tt.maxChunks {
- t.Errorf("got %d chunks, want at most %d", len(chunks), tt.maxChunks)
- }
-
- // Check max tokens per chunk
- if tt.maxTokens > 0 {
- for i, c := range chunks {
- tk := tokenizer.NumTokensFromString(c.Text)
- if tk > tt.maxTokens {
- t.Errorf("chunk[%d] has %d tokens, exceeds max %d: %q", i, tk, tt.maxTokens, c.Text)
- }
- }
- }
-
- // Check contains
- for _, substr := range tt.contains {
- found := false
- for _, c := range chunks {
- if strings.Contains(c.Text, substr) {
- found = true
- break
- }
- }
- if !found {
- t.Errorf("no chunk contains %q", substr)
- }
- }
-
- // Check not contains
- for _, substr := range tt.notContains {
- for _, c := range chunks {
- if strings.Contains(c.Text, substr) {
- t.Errorf("chunk should not contain %q: %q", substr, c.Text)
- }
- }
- }
- })
- }
-}
-
-// ── Specific behavior tests ────────────────────────────────────────────────
-
-func TestNaiveMerge_OverlapContent(t *testing.T) {
- // Create texts where we can verify overlap behavior.
- // Each text is a complete sentence with delimiter.
- texts := []string{
- "AAAAA。",
- "BBBBB。",
- "CCCCC。",
- "DDDDD。",
- }
- chunks := NaiveMerge(texts, nil, 10, "\n。;!?", 30) // 30% overlap
-
- // With token limit 10 and 30% overlap, chunks should reuse end of previous.
- // Verify we got chunks and they look reasonable.
- if len(chunks) < 2 {
- t.Fatalf("expected at least 2 chunks with overlap, got %d", len(chunks))
- }
-
- // Verify chunks are non-empty and contain content
- for i, c := range chunks {
- if c.Text == "" {
- t.Errorf("chunk[%d] is empty", i)
- }
- }
-}
-
-func TestNaiveMerge_DelimiterAsSplitPoint(t *testing.T) {
- // Delimiters are split points for oversized texts (>chunkTokenNum).
- // For texts under the token limit, no splitting occurs.
- // This test verifies that a large text IS split at delimiters.
- texts := []string{strings.Repeat("A。", 100)} // many sentences, over token limit
- chunks := NaiveMerge(texts, nil, 20, "\n。;!?", 0)
-
- // Should be split into multiple chunks (token limit 20 is small)
- if len(chunks) < 2 {
- t.Fatalf("expected at least 2 chunks, got %d", len(chunks))
- }
- // No chunk should contain '。' (delimiter is removed)
- for _, c := range chunks {
- if strings.Contains(c.Text, "。") {
- t.Errorf("chunk should not contain delimiter '。': %q", c.Text)
- }
- }
-}
-
-func TestNaiveMerge_AllBelowTokenThreshold(t *testing.T) {
- // All texts below chunk_token_num → should merge into one chunk.
- texts := []string{"A", "B", "C", "D", "E"}
- chunks := NaiveMerge(texts, nil, 128, "\n。;!?", 0)
-
- if len(chunks) != 1 {
- t.Fatalf("expected 1 chunk, got %d", len(chunks))
- }
- // Should contain all
- for _, letter := range []string{"A", "B", "C", "D", "E"} {
- if !strings.Contains(chunks[0].Text, letter) {
- t.Errorf("chunk missing %q", letter)
- }
- }
-}
-
-func TestNaiveMerge_PositionInfo(t *testing.T) {
- // Texts with position info should have positions carried through.
- texts := []string{"Page 1 content。", "Page 2 content。"}
- chunks := NaiveMerge(texts, nil, 128, "\n。;!?", 0)
-
- // Verify SectionIndices are tracked
- for _, c := range chunks {
- if len(c.SectionIndices) == 0 {
- t.Error("chunk has no SectionIndices")
- }
- // Section indices should be within range
- for _, idx := range c.SectionIndices {
- if idx < 0 || idx >= len(texts) {
- t.Errorf("SectionIndex %d out of range [0, %d)", idx, len(texts))
- }
- }
- }
-}
-
-func TestNaiveMerge_CustomDelimiterExclusive(t *testing.T) {
- // Custom delimiter mode: each segment is its own chunk, no merging.
- texts := []string{"Part A `---` Part B `---` Part C"}
- chunks := NaiveMerge(texts, nil, 128, "`---`", 0)
-
- // Should produce 3 chunks (one per segment between ---)
- if len(chunks) != 3 {
- t.Fatalf("expected 3 chunks from custom delimiter split, got %d", len(chunks))
- }
- // No chunk should contain "---"
- for _, c := range chunks {
- if strings.Contains(c.Text, "---") {
- t.Errorf("chunk should not contain custom delimiter: %q", c.Text)
- }
- }
-}
-
-func TestNaiveMerge_LeadingNewline(t *testing.T) {
- // Python adds "\n" prefix to each text before adding as chunk.
- texts := []string{"Hello World"}
- chunks := NaiveMerge(texts, nil, 128, "\n。;!?", 0)
-
- if len(chunks) != 1 {
- t.Fatalf("expected 1 chunk, got %d", len(chunks))
- }
- // Should start with "\n" (matching Python behavior)
- if !strings.HasPrefix(chunks[0].Text, "\n") {
- t.Errorf("expected chunk to start with newline, got %q", chunks[0].Text)
- }
-}
-
-func TestNaiveMerge_SectionIndicesCorrect(t *testing.T) {
- // Verify that merged chunks track which input sections they came from.
- texts := []string{"First。", "Second。", "Third。", "Fourth。"}
- chunks := NaiveMerge(texts, nil, 40, "\n。;!?", 0)
-
- // Collect all section indices across all chunks
- allIndices := make(map[int]bool)
- for _, c := range chunks {
- for _, idx := range c.SectionIndices {
- allIndices[idx] = true
- }
- }
-
- // Every input section should appear in exactly one chunk
- for i := range texts {
- if !allIndices[i] {
- t.Errorf("section %d (%q) not found in any chunk", i, texts[i])
- }
- }
-}
-
-// ── Position-aware tests (gap: Python carries pos through add_chunk) ──────────
-
-func TestNaiveMerge_PositionCarriedThrough(t *testing.T) {
- texts := []string{"Long enough text with sufficient tokens for position to stay。", "Another long sentence with enough tokens。"}
- positions := []string{"p1", "p2"}
- chunks := NaiveMerge(texts, positions, 128, "\n。;!?", 0)
-
- if len(chunks) != 1 {
- t.Fatalf("expected 1 chunk, got %d", len(chunks))
- }
- if chunks[0].Position == "" {
- t.Error("position should not be empty for merged chunk")
- }
-}
-
-func TestNaiveMerge_PositionClearedWhenBelowThreshold(t *testing.T) {
- // Token count < 8 → position should be cleared (Python: tnum < 8 → pos = "")
- texts := []string{"Hi"}
- positions := []string{"page1_top100"}
- chunks := NaiveMerge(texts, positions, 128, "\n。;!?", 0)
-
- if len(chunks) != 1 {
- t.Fatalf("expected 1 chunk, got %d", len(chunks))
- }
- if chunks[0].Position != "" {
- t.Errorf("position should be empty for short text (< 8 tokens), got %q", chunks[0].Position)
- }
-}
-
-func TestNaiveMerge_PositionAppendedWhenNotPresent(t *testing.T) {
- texts := []string{"Long enough text that exceeds eight tokens easily。"}
- positions := []string{"[page1]"}
- chunks := NaiveMerge(texts, positions, 128, "\n。;!?", 0)
-
- if len(chunks) != 1 {
- t.Fatalf("expected 1 chunk, got %d", len(chunks))
- }
- if chunks[0].Position != "[page1]" {
- t.Errorf("position = %q, want %q", chunks[0].Position, "[page1]")
- }
-}
-
-func TestNaiveMerge_NilPositions(t *testing.T) {
- texts := []string{"Text A。", "Text B。"}
- chunks := NaiveMerge(texts, nil, 128, "\n。;!?", 0)
-
- if len(chunks) != 1 {
- t.Fatalf("expected 1 chunk, got %d", len(chunks))
- }
- if chunks[0].Position != "" {
- t.Errorf("position should be empty when positions is nil, got %q", chunks[0].Position)
- }
-}
diff --git a/internal/ingestion/task/real_consumer_test.go b/internal/ingestion/task/real_consumer_test.go
index a08cad2a6f..afa96bd77b 100644
--- a/internal/ingestion/task/real_consumer_test.go
+++ b/internal/ingestion/task/real_consumer_test.go
@@ -1,3 +1,6 @@
+//go:build manual
+// +build manual
+
//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
@@ -14,14 +17,13 @@
// limitations under the License.
//
-//go:build integration
-// +build integration
-
package task
import (
"encoding/json"
"errors"
+ "fmt"
+ "strings"
"testing"
"ragflow/internal/common"
@@ -57,7 +59,16 @@ func TestRealProducerConsumer(t *testing.T) {
}
// ── 2. SQLite DB ──
- db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{TranslateError: true})
+ dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()))
+ db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{TranslateError: true})
+ if err != nil {
+ t.Fatalf("failed to open sqlite: %v", err)
+ }
+ sqlDB, err := db.DB()
+ if err != nil {
+ t.Fatalf("failed to get sql DB: %v", err)
+ }
+ sqlDB.SetMaxOpenConns(1)
db.AutoMigrate(
&entity.IngestionTask{}, &entity.Task{},
&entity.Document{}, &entity.Knowledgebase{}, &entity.Tenant{},
diff --git a/internal/ingestion/task/task_context.go b/internal/ingestion/task/task_context.go
index 285917fac6..76ce31068c 100644
--- a/internal/ingestion/task/task_context.go
+++ b/internal/ingestion/task/task_context.go
@@ -119,38 +119,6 @@ func LoadFromIngestionTask(ingestionTask *entity.IngestionTask) (*TaskContext, e
}, nil
}
-// Load loads the full task context following the FK chain: document → knowledgebase → tenant.
-// Kept for backward compatibility.
-func Load(docID string) (*TaskContext, error) {
- doc, err := dao.NewDocumentDAO().GetByID(docID)
- if err != nil || doc == nil {
- return nil, fmt.Errorf("error when load document %s : %w", docID, err)
- }
-
- kb, err := dao.NewKnowledgebaseDAO().GetByID(doc.KbID)
- if err != nil || kb == nil {
- return nil, fmt.Errorf("error when load knowledgebase %s: %w", doc.KbID, err)
- }
-
- tenant, err := dao.NewTenantDAO().GetByID(kb.TenantID)
- if err != nil || tenant == nil {
- return nil, fmt.Errorf("error when load tenant %s: %w", kb.TenantID, err)
- }
-
- pipelineID := ""
- if doc.PipelineID != nil {
- pipelineID = *doc.PipelineID
- }
-
- return &TaskContext{
- TaskType: "dataflow",
- PipelineID: pipelineID,
- Doc: *doc,
- KB: *kb,
- Tenant: *tenant,
- }, nil
-}
-
// LoadTaskContext loads the full task context following the FK chain: task → document → knowledgebase → tenant.
// Kept for backward compatibility.
func LoadTaskContext(taskID string) (*TaskContext, error) {
diff --git a/internal/ingestion/task/task_context_test.go b/internal/ingestion/task/task_context_test.go
index ff13959d45..541027e83e 100644
--- a/internal/ingestion/task/task_context_test.go
+++ b/internal/ingestion/task/task_context_test.go
@@ -17,6 +17,8 @@
package task
import (
+ "fmt"
+ "strings"
"testing"
"ragflow/internal/dao"
@@ -28,10 +30,16 @@ import (
func setupTestDB(t *testing.T) *gorm.DB {
t.Helper()
- db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{TranslateError: true})
+ dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()))
+ db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{TranslateError: true})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
+ sqlDB, err := db.DB()
+ if err != nil {
+ t.Fatalf("failed to get sql DB: %v", err)
+ }
+ sqlDB.SetMaxOpenConns(1)
if err := db.AutoMigrate(
&entity.Task{},
&entity.Document{},
@@ -51,137 +59,3 @@ func pushDB(t *testing.T, db *gorm.DB) {
}
func ptr(s string) *string { return &s }
-
-func TestLoadTaskContext(t *testing.T) {
- db := setupTestDB(t)
- pushDB(t, db)
-
- // Prepare test data in FK dependency order: tenant → kb → document → task
- tenant := &entity.Tenant{
- ID: "tenant-1",
- LLMID: "gpt-4",
- ASRID: "whisper-1",
- Img2TxtID: "gpt-4-vision",
- Status: ptr("1"),
- }
- kb := &entity.Knowledgebase{
- ID: "kb-1",
- TenantID: "tenant-1",
- Language: ptr("Chinese"),
- EmbdID: "embd-model-1",
- Pagerank: 0,
- Status: ptr(string(entity.StatusValid)),
- ParserConfig: entity.JSONMap{
- "chunk_token_size": float64(512),
- },
- }
- doc := &entity.Document{
- ID: "doc-1",
- KbID: "kb-1",
- ParserID: "naive",
- ParserConfig: entity.JSONMap{"pages": []any{[]any{float64(1), float64(10)}}},
- Name: ptr("test.pdf"),
- Type: "pdf",
- Location: ptr("bucket/test.pdf"),
- Size: 1024,
- }
- task := &entity.Task{
- ID: "task-1",
- DocID: "doc-1",
- FromPage: 0,
- ToPage: 100000,
- }
-
- db.Create(tenant)
- db.Create(kb)
- db.Create(doc)
- db.Create(task)
-
- got, err := LoadTaskContext("task-1")
- if err != nil {
- t.Fatalf("LoadTaskContext: %v", err)
- }
-
- // === task table fields (no longer stored in TaskContext) ===
- // Task field removed from TaskContext, skipping tests
-
- // === document table fields ===
- if got.Doc.ID != "doc-1" {
- t.Errorf("Doc.ID = %v, want doc-1", got.Doc.ID)
- }
- if got.Doc.ParserID != "naive" {
- t.Errorf("Doc.ParserID = %v, want naive", got.Doc.ParserID)
- }
- if got.Doc.Name == nil || *got.Doc.Name != "test.pdf" {
- t.Errorf("Doc.Name = %v, want test.pdf", got.Doc.Name)
- }
- if got.Doc.Type != "pdf" {
- t.Errorf("Doc.Type = %v, want pdf", got.Doc.Type)
- }
- if got.Doc.Location == nil || *got.Doc.Location != "bucket/test.pdf" {
- t.Errorf("Doc.Location = %v, want bucket/test.pdf", got.Doc.Location)
- }
- if got.Doc.Size != 1024 {
- t.Errorf("Doc.Size = %v, want 1024", got.Doc.Size)
- }
- if got.Doc.ParserConfig == nil {
- t.Error("Doc.ParserConfig is nil")
- } else if got.Doc.ParserConfig["pages"] == nil {
- t.Error("Doc.ParserConfig.pages is nil")
- }
-
- // === knowledgebase table fields ===
- if got.KB.ID != "kb-1" {
- t.Errorf("KB.ID = %v, want kb-1", got.KB.ID)
- }
- if got.KB.TenantID != "tenant-1" {
- t.Errorf("KB.TenantID = %v, want tenant-1", got.KB.TenantID)
- }
- if got.KB.Language == nil || *got.KB.Language != "Chinese" {
- t.Errorf("KB.Language = %v, want Chinese", got.KB.Language)
- }
- if got.KB.EmbdID != "embd-model-1" {
- t.Errorf("KB.EmbdID = %v, want embd-model-1", got.KB.EmbdID)
- }
- if got.KB.Pagerank != 0 {
- t.Errorf("KB.Pagerank = %v, want 0", got.KB.Pagerank)
- }
- if got.KB.ParserConfig == nil {
- t.Error("KB.ParserConfig is nil")
- } else if got.KB.ParserConfig["chunk_token_size"] != float64(512) {
- t.Errorf("KB.ParserConfig.chunk_token_size = %v, want 512",
- got.KB.ParserConfig["chunk_token_size"])
- }
-
- // === tenant table fields ===
- if got.Tenant.LLMID != "gpt-4" {
- t.Errorf("Tenant.LLMID = %v, want gpt-4", got.Tenant.LLMID)
- }
-}
-
-func TestLoadTaskContext_TaskNotFound(t *testing.T) {
- db := setupTestDB(t)
- pushDB(t, db)
-
- _, err := LoadTaskContext("nonexistent")
- if err == nil {
- t.Fatal("expected error for nonexistent task")
- }
-}
-
-func TestLoadTaskContext_DocNotFound(t *testing.T) {
- db := setupTestDB(t)
- pushDB(t, db)
-
- db.Create(&entity.Task{
- ID: "task-1",
- DocID: "nonexistent-doc",
- FromPage: 0,
- ToPage: 100000,
- })
-
- _, err := LoadTaskContext("task-1")
- if err == nil {
- t.Fatal("expected error when document not found")
- }
-}
diff --git a/internal/ingestion/task/task_handler.go b/internal/ingestion/task/task_handler.go
index 4a59234fb7..1807fee9be 100644
--- a/internal/ingestion/task/task_handler.go
+++ b/internal/ingestion/task/task_handler.go
@@ -20,10 +20,6 @@ import (
"context"
"fmt"
"strings"
-
- "ragflow/internal/entity"
- "ragflow/internal/entity/models"
- "ragflow/internal/service"
)
// TaskHandler dispatches document processing tasks by task_type.
@@ -99,7 +95,11 @@ func (h *TaskHandler) handleDataflow() error {
if err != nil {
return err
}
- return svc.Run(context.Background())
+ runCtx := h.ctx.Ctx
+ if runCtx == nil {
+ runCtx = context.Background()
+ }
+ return svc.Run(runCtx)
}
func (h *TaskHandler) handleRaptor() error {
@@ -117,51 +117,3 @@ func (h *TaskHandler) handleStub(name string) error {
func (h *TaskHandler) handleStandard() error {
return nil // stub
}
-
-// BindEmbeddingModel creates an embedding model for the task's tenant.
-// Returns the model and its vector dimension.
-// Mirrors Python: _bind_embedding_model (task_handler.py:204)
-func (h *TaskHandler) BindEmbeddingModel() (*models.EmbeddingModel, int, error) {
- var model *models.EmbeddingModel
- var err error
- if embdID := h.ctx.KB.EmbdID; embdID != "" {
- modelSvc := service.NewModelProviderService()
- model, err = modelSvc.GetEmbeddingModel(h.ctx.Tenant.ID, embdID)
- } else {
- // Use tenant's default embedding model
- model, err = defaultBindEmbeddingModel(h.ctx.Tenant.ID)
- }
-
- if err != nil {
- return nil, 0, fmt.Errorf("bind embedding model: %w", err)
- }
-
- dim, err := getEmbeddingDimension(model)
- if err != nil {
- return nil, 0, fmt.Errorf("bind embedding model: %w", err)
- }
- return model, dim, err
-}
-
-// defaultBindEmbeddingModel returns the tenant's default embedding model and its vector dimension.
-func defaultBindEmbeddingModel(tenantID string) (*models.EmbeddingModel, error) {
- modelSvc := service.NewModelProviderService()
- driver, modelName, apiConfig, maxTokens, err := modelSvc.GetTenantDefaultModelByType(tenantID, entity.ModelTypeEmbedding)
- if err != nil {
- return nil, fmt.Errorf("bind default embedding model: %w", err)
- }
- model := models.NewEmbeddingModel(driver, &modelName, apiConfig, maxTokens)
- return model, nil
-}
-
-// getEmbeddingDimension encodes a test string to determine vector dimension.
-func getEmbeddingDimension(model *models.EmbeddingModel) (int, error) {
- embeds, err := model.ModelDriver.Embed(model.ModelName, []string{"ok"}, model.APIConfig, &models.EmbeddingConfig{Dimension: 0})
- if err != nil {
- return 0, fmt.Errorf("test encode failed: %w", err)
- }
- if len(embeds) == 0 {
- return 0, fmt.Errorf("test encode returned no embeddings")
- }
- return len(embeds[0].Embedding), nil
-}
diff --git a/internal/ingestion/task/task_handler_test.go b/internal/ingestion/task/task_handler_test.go
index 489726f0ee..f3ea61dc36 100644
--- a/internal/ingestion/task/task_handler_test.go
+++ b/internal/ingestion/task/task_handler_test.go
@@ -136,6 +136,35 @@ func TestTaskHandler_DefaultDataflowServiceInjectsProgress(t *testing.T) {
}
}
+func TestTaskHandler_Dataflow_UsesTaskContext(t *testing.T) {
+ ctx := makeTaskHandlerTestContext("dataflow")
+ type ctxKey string
+ const key ctxKey = "trace"
+ ctx.Ctx = context.WithValue(context.Background(), key, "task-ctx")
+
+ handler := NewTaskHandler(ctx).WithDataflowServiceFactory(func(ctx *TaskContext, dataflowID string) (*PipelineExecutor, error) {
+ return mustNewDataflowService(t, ctx, dataflowID, 0, 0).
+ WithLoadDSLFunc(func(ctx context.Context, dataflowID string) (string, string, error) {
+ return `{"nodes":[{"id":"stub-node"}],"edges":[]}`, dataflowID, nil
+ }).
+ WithRunPipelineFunc(func(runCtx context.Context, dsl string) (map[string]any, string, error) {
+ if got := runCtx.Value(key); got != "task-ctx" {
+ t.Fatalf("runCtx value = %v, want task-ctx", got)
+ }
+ return map[string]any{"chunks": []map[string]any{{"text": "stub", "q_2_vec": []float64{0.1, 0.2}}}}, dsl, nil
+ }).
+ WithInsertChunksFunc(func(ctx context.Context, chunks []map[string]any, baseName, datasetID string) ([]string, error) {
+ return nil, nil
+ }).
+ WithLogCreateFunc(func(log *entity.PipelineOperationLog) error { return nil }).
+ WithDocService(&stubDocService{}).
+ WithChunkCounter(&stubChunkCounter{}), nil
+ })
+ if err := handler.Handle(); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
func TestTaskHandler_Dataflow_ShowsProgressAndPipelineLog(t *testing.T) {
ctx := makeTaskHandlerTestContext("dataflow")
ctx.Doc.PipelineID = testStrPtr("flow-1")
diff --git a/internal/ingestion/testutil/helpers.go b/internal/ingestion/testutil/helpers.go
index babf34c8e0..057d8e6f35 100644
--- a/internal/ingestion/testutil/helpers.go
+++ b/internal/ingestion/testutil/helpers.go
@@ -17,6 +17,8 @@
package testutil
import (
+ "fmt"
+ "strings"
"testing"
"ragflow/internal/common"
@@ -45,12 +47,18 @@ func StrPtr(s string) *string {
// It auto-migrates the given tables (or all common tables if none provided).
func SetupTestDB(t *testing.T, tables ...any) *gorm.DB {
t.Helper()
- db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
+ dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()))
+ db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
+ sqlDB, err := db.DB()
+ if err != nil {
+ t.Fatalf("failed to get sql DB: %v", err)
+ }
+ sqlDB.SetMaxOpenConns(1)
if len(tables) == 0 {
tables = []any{
diff --git a/internal/service/document.go b/internal/service/document.go
index 96ce798637..12f092d69c 100644
--- a/internal/service/document.go
+++ b/internal/service/document.go
@@ -1313,7 +1313,7 @@ func (s *DocumentService) Ingest(userID string, req *IngestDocumentRequest) (com
kb, err := s.kbDAO.GetByID(doc.KbID)
if err != nil {
- return common.CodeDataError, fmt.Errorf("tenant not found")
+ return common.CodeDataError, fmt.Errorf("dataset not found")
}
if !s.kbDAO.Accessible(kb.ID, userID) {
diff --git a/internal/service/model_service.go b/internal/service/model_service.go
index be20d5e6ce..1a4f6dc5e6 100644
--- a/internal/service/model_service.go
+++ b/internal/service/model_service.go
@@ -2127,7 +2127,7 @@ func (m *ModelProviderService) GetTenantDefaultModelByType(tenantID string, mode
tenantSvc := NewTenantService()
modelName, err := tenantSvc.GetDefaultModelName(tenantID, modelType)
if err != nil {
- return nil, "", nil, 0, fmt.Errorf("failed to get default model name for type %s: %w", modelType, err)
+ return nil, "", nil, 0, fmt.Errorf("failed to get default model name for tenant: %s type %s: %w", tenantID, modelType, err)
}
if modelName == "" {
return nil, "", nil, 0, fmt.Errorf("no default %s model is set", modelType)