mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-23 17:06:42 +08:00
## Summary Align the Go ingestion pipeline with Python's `image` → `img_id` persistence semantics, and unify the chunk-id computation across all paths. ### Changes **1. Image upload at chunker stage ** - Add `ImageUploader` type and `DefaultImageUploader` in `internal/ingestion/component/image_uploader.go` — the write-side counterpart to `FetchBinary`, storing raw image bytes at `(bucket=kbID, key=chunkID)`, no re-encoding. - Add `uploadOneImage` — pure upload primitive (bytes in, `img_id` out), does not touch chunk maps. - Add `uploadChunkImages` / `uploadChunkImage` — caller-side helper: decodes `image` from a chunk, uploads bytes, writes `ck["img_id"]`, `delete(ck,"image")` , bounded by a process-wide semaphore (default 10, env `MAX_CONCURRENT_MINIO`). - Wire via `imageUploadDecorator` in `register.go`: every chunker runs the upload pass at invocation time, writing `ck["id"]` before upload and dropping image bytes right after — peak memory = single chunk image lifetime. **2. Unify chunk-id computation** - Consolidate three separate id-computation paths (`component.ChunkID`, `task.ChunkID`, inline `FormatUint` in API) into one: `common.ChunkID(docID, text string)`, using `%016x` + `xxhash.Sum64String(text+docID)` (matching Python `hexdigest()`). - The chunker decorator writes `ck["id"]` via `common.ChunkID`; the persist stage (`ProcessChunksForPipeline`) falls back to the same function (`if !exists id`). - The API AddChunk path now also calls `common.ChunkID` instead of the divergent `FormatUint(xxhash.Sum64(...))` — fixing a pre-existing inconsistency. - Delete `internal/ingestion/component/chunk_id.go` and `internal/ingestion/task/chunk_builder.go` (both were pure forwarding shells). **3. Preserve `img_id` (never deleted)** - `img_id` is a persistent index field (Infinity, OB) and the only consumer-side reference for image retrieval; it is NEVER removed from the chunk map. Only `image` (raw data URL) is dropped after upload. **4. PPT parser support** Previously PPT parsing failed. Add support to parse. ### Key design decisions | Decision | Choice | |----------|--------| | Upload timing | Chunker stage (not persist), so image bytes are dropped immediately — bounds peak memory to one chunk image | | Upload concurrency | Process-wide semaphore, default 10 (matches Python `minio_limiter`), env `MAX_CONCURRENT_MINIO` | | Image encoding | Store as-is, no JPEG re-encoding (unlike Python) | | `img_id` format | `"<kb_id>-<chunk_id>"` — matches Python task_executor path | | id function | Single `common.ChunkID(docID, text)`, concatenation `text+docID` inside hash (matching Python) | | `removeInternalChunkFields` | Retains `delete(ck,"image")` as defensive fallback for non-chunker paths | ### Files touched | File | Change | |------|--------| | `internal/common/format.go` | Add `ChunkID(docID, text)` | | `internal/common/format_test.go` | Add ChunkID golden-value test | | `internal/ingestion/component/image_uploader.go` | Add `ImageUploader` type + `DefaultImageUploader` | | `internal/ingestion/component/chunker/image_upload.go` | Add `uploadOneImage`, `uploadChunkImages`, `uploadChunkImage`, `decodeChunkImage`, semaphore | | `internal/ingestion/component/chunker/image_upload_test.go` | Tests: upload/drop, skip, no-image, concurrency, missing-id error | | `internal/ingestion/component/chunker/register.go` | Add `imageUploadDecorator` (writes `ck["id"]`, runs upload) | | `internal/ingestion/task/chunk_process.go` | Use `common.ChunkID` for persist fallback | | `internal/service/chunk/chunk.go` | Use `common.ChunkID` instead of `FormatUint` | | `internal/ingestion/component/chunk_id.go` | **Deleted** (moved to `common`) | | `internal/ingestion/task/chunk_builder.go` | **Deleted** (shell, no callers left) | | `internal/ingestion/task/chunk_builder_test.go` | **Deleted** (test migrated to `common/format_test.go`) | ### Verification ``` bash build.sh --test ./internal/service/chunk/... ./internal/common/... ./internal/ingestion/component/... ./internal/ingestion/task/... → ok service/chunk / common / component / chunker / schema / task ```
339 lines
12 KiB
Go
339 lines
12 KiB
Go
package task
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// =============================================================================
|
|
// RenameTextToContentWithWeight - Python processChunks logic
|
|
// =============================================================================
|
|
|
|
func TestRenameTextToContentWithWeight_Basic(t *testing.T) {
|
|
chunk := map[string]any{"text": "hello world"}
|
|
RenameTextToContentWithWeight(chunk)
|
|
if _, exists := chunk["text"]; exists {
|
|
t.Error("text key should be removed")
|
|
}
|
|
if chunk["content_with_weight"] != "hello world" {
|
|
t.Errorf("content_with_weight = %q, want \"hello world\"", chunk["content_with_weight"])
|
|
}
|
|
}
|
|
|
|
func TestRenameTextToContentWithWeight_PreservesExisting(t *testing.T) {
|
|
chunk := map[string]any{"content_with_weight": "already set", "text": "hello"}
|
|
RenameTextToContentWithWeight(chunk)
|
|
if chunk["content_with_weight"] != "already set" {
|
|
t.Errorf("preserved value should not be overwritten")
|
|
}
|
|
if _, exists := chunk["text"]; exists {
|
|
t.Error("text should still be removed")
|
|
}
|
|
}
|
|
|
|
func TestRenameTextToContentWithWeight_NoTextKey(t *testing.T) {
|
|
chunk := map[string]any{"other": "value"}
|
|
RenameTextToContentWithWeight(chunk)
|
|
if _, exists := chunk["content_with_weight"]; exists {
|
|
t.Error("should not add content_with_weight when no text key")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// ProcessChunksForPipeline - Python: processChunks()
|
|
// =============================================================================
|
|
|
|
func TestProcessChunksForPipeline_SetsDocIDAndKBID(t *testing.T) {
|
|
chunks := []map[string]any{{"text": "hello world"}}
|
|
_, err := ProcessChunksForPipeline(chunks, "doc-1", "kb-1", "test-doc.pdf", time.Now())
|
|
if err != nil {
|
|
t.Fatalf("ProcessChunksForPipeline: %v", err)
|
|
}
|
|
|
|
if chunks[0]["doc_id"] != "doc-1" {
|
|
t.Errorf("doc_id = %q, want \"doc-1\"", chunks[0]["doc_id"])
|
|
}
|
|
if kbIDs, ok := chunks[0]["kb_id"].([]string); ok {
|
|
if len(kbIDs) != 1 || kbIDs[0] != "kb-1" {
|
|
t.Errorf("kb_id = %v, want [\"kb-1\"]", chunks[0]["kb_id"])
|
|
}
|
|
} else {
|
|
t.Errorf("kb_id should be []string, got %T", chunks[0]["kb_id"])
|
|
}
|
|
}
|
|
|
|
func TestProcessChunksForPipeline_SetsDocNameKwd(t *testing.T) {
|
|
chunks := []map[string]any{{"text": "hello"}}
|
|
_, err := ProcessChunksForPipeline(chunks, "doc-1", "kb-1", "test-doc.pdf", time.Now())
|
|
if err != nil {
|
|
t.Fatalf("ProcessChunksForPipeline: %v", err)
|
|
}
|
|
if chunks[0]["docnm_kwd"] != "test-doc.pdf" {
|
|
t.Errorf("docnm_kwd = %q, want \"test-doc.pdf\"", chunks[0]["docnm_kwd"])
|
|
}
|
|
}
|
|
|
|
func TestProcessChunksForPipeline_SetsTimeFields(t *testing.T) {
|
|
now := time.Now()
|
|
chunks := []map[string]any{{"text": "hello"}}
|
|
_, err := ProcessChunksForPipeline(chunks, "doc-1", "kb-1", "test-doc.pdf", now)
|
|
if err != nil {
|
|
t.Fatalf("ProcessChunksForPipeline: %v", err)
|
|
}
|
|
|
|
if timeStr, ok := chunks[0]["create_time"].(string); ok {
|
|
if timeStr != now.Format("2006-01-02 15:04:05") {
|
|
t.Errorf("create_time = %q, want %q", timeStr, now.Format("2006-01-02 15:04:05"))
|
|
}
|
|
} else {
|
|
t.Errorf("create_time should be string, got %T", chunks[0]["create_time"])
|
|
}
|
|
if ts, ok := chunks[0]["create_timestamp_flt"].(float64); ok {
|
|
expected := float64(now.UnixMicro()) / 1e6
|
|
if ts != expected {
|
|
t.Errorf("create_timestamp_flt = %f, want %f", ts, expected)
|
|
}
|
|
} else {
|
|
t.Errorf("create_timestamp_flt should be float64, got %T", chunks[0]["create_timestamp_flt"])
|
|
}
|
|
}
|
|
|
|
func TestProcessChunksForPipeline_GeneratesID(t *testing.T) {
|
|
chunks := []map[string]any{{"text": "hello"}}
|
|
_, err := ProcessChunksForPipeline(chunks, "doc-1", "kb-1", "test-doc.pdf", time.Now())
|
|
if err != nil {
|
|
t.Fatalf("ProcessChunksForPipeline: %v", err)
|
|
}
|
|
id, ok := chunks[0]["id"].(string)
|
|
if !ok || id == "" {
|
|
t.Errorf("id should be non-empty string, got %v", chunks[0]["id"])
|
|
}
|
|
}
|
|
|
|
// TestProcessChunksForPipeline_GeneratesIDOnNonStringText pins the id fallback:
|
|
// when ck["id"] is absent and ck["text"] is a non-string (e.g. from a
|
|
// malformed input), the type assertion silently yields "" and
|
|
// component.ChunkID computes a valid id from empty text, rather than erroring.
|
|
func TestProcessChunksForPipeline_GeneratesIDOnNonStringText(t *testing.T) {
|
|
chunks := []map[string]any{{"text": []any{"bad-shape"}}}
|
|
_, err := ProcessChunksForPipeline(chunks, "doc-1", "kb-1", "test-doc.pdf", time.Now())
|
|
if err != nil {
|
|
t.Fatalf("ProcessChunksForPipeline: %v", err)
|
|
}
|
|
id, ok := chunks[0]["id"].(string)
|
|
if !ok || id == "" {
|
|
t.Errorf("id should be generated even for non-string text, got %v", chunks[0]["id"])
|
|
}
|
|
}
|
|
|
|
func TestProcessChunksForPipeline_RemovesInternalPipelineFields(t *testing.T) {
|
|
chunks := []map[string]any{{
|
|
"text": "hello",
|
|
"_pdf_positions": []any{[]any{0, 1, 2, 3, 4}},
|
|
"image": "data:image/png;base64,abc",
|
|
}}
|
|
|
|
_, err := ProcessChunksForPipeline(chunks, "doc-1", "kb-1", "test-doc.pdf", time.Now())
|
|
if err != nil {
|
|
t.Fatalf("ProcessChunksForPipeline: %v", err)
|
|
}
|
|
if _, exists := chunks[0]["_pdf_positions"]; exists {
|
|
t.Fatalf("_pdf_positions should be removed before indexing: %v", chunks[0]["_pdf_positions"])
|
|
}
|
|
if _, exists := chunks[0]["image"]; exists {
|
|
t.Fatalf("image should be removed before indexing: %v", chunks[0]["image"])
|
|
}
|
|
}
|
|
|
|
func TestProcessChunksForPipeline_PreservesExistingID(t *testing.T) {
|
|
chunks := []map[string]any{{"text": "hello", "id": "existing-id"}}
|
|
_, err := ProcessChunksForPipeline(chunks, "doc-1", "kb-1", "test-doc.pdf", time.Now())
|
|
if err != nil {
|
|
t.Fatalf("ProcessChunksForPipeline: %v", err)
|
|
}
|
|
if chunks[0]["id"] != "existing-id" {
|
|
t.Errorf("existing id should be preserved, got %q", chunks[0]["id"])
|
|
}
|
|
}
|
|
|
|
func TestProcessChunksForPipeline_QuestionsProcessing(t *testing.T) {
|
|
chunks := []map[string]any{{"text": "hello", "questions": "Q1\nQ2\nQ3"}}
|
|
_, err := ProcessChunksForPipeline(chunks, "doc-1", "kb-1", "test-doc.pdf", time.Now())
|
|
if err != nil {
|
|
t.Fatalf("ProcessChunksForPipeline: %v", err)
|
|
}
|
|
|
|
if _, exists := chunks[0]["questions"]; exists {
|
|
t.Error("questions key should be removed")
|
|
}
|
|
kwd, ok := chunks[0]["question_kwd"].([]string)
|
|
if !ok {
|
|
t.Fatalf("question_kwd should be []string, got %T", chunks[0]["question_kwd"])
|
|
}
|
|
if len(kwd) != 3 {
|
|
t.Errorf("question_kwd len = %d, want 3", len(kwd))
|
|
}
|
|
if _, ok := chunks[0]["question_tks"]; ok {
|
|
t.Errorf("question_tks must NOT be produced by executor (owned by Tokenizer), got %T", chunks[0]["question_tks"])
|
|
}
|
|
}
|
|
|
|
func TestProcessChunksForPipeline_KeywordsProcessing(t *testing.T) {
|
|
chunks := []map[string]any{{"text": "hello", "keywords": "kw1,kw2;kw3"}}
|
|
_, err := ProcessChunksForPipeline(chunks, "doc-1", "kb-1", "test-doc.pdf", time.Now())
|
|
if err != nil {
|
|
t.Fatalf("ProcessChunksForPipeline: %v", err)
|
|
}
|
|
|
|
if _, exists := chunks[0]["keywords"]; exists {
|
|
t.Error("keywords key should be removed")
|
|
}
|
|
kwd, ok := chunks[0]["important_kwd"].([]string)
|
|
if !ok || len(kwd) == 0 {
|
|
t.Errorf("important_kwd should be non-empty []string, got %v", chunks[0]["important_kwd"])
|
|
}
|
|
if _, ok := chunks[0]["important_tks"]; ok {
|
|
t.Errorf("important_tks must NOT be produced by executor (owned by Tokenizer), got %T", chunks[0]["important_tks"])
|
|
}
|
|
}
|
|
|
|
func TestProcessChunksForPipeline_SummaryProcessing(t *testing.T) {
|
|
chunks := []map[string]any{{"text": "hello", "summary": "This is a summary."}}
|
|
_, err := ProcessChunksForPipeline(chunks, "doc-1", "kb-1", "test-doc.pdf", time.Now())
|
|
if err != nil {
|
|
t.Fatalf("ProcessChunksForPipeline: %v", err)
|
|
}
|
|
|
|
if _, exists := chunks[0]["summary"]; exists {
|
|
t.Error("summary key should be removed")
|
|
}
|
|
if _, ok := chunks[0]["content_ltks"]; ok {
|
|
t.Errorf("content_ltks must NOT be produced by executor (owned by Tokenizer), got %T", chunks[0]["content_ltks"])
|
|
}
|
|
if _, ok := chunks[0]["content_sm_ltks"]; ok {
|
|
t.Errorf("content_sm_ltks must NOT be produced by executor (owned by Tokenizer), got %T", chunks[0]["content_sm_ltks"])
|
|
}
|
|
}
|
|
|
|
// TestProcessChunksForPipeline_PreservesTokenizerProducedFields documents the
|
|
// Tokenizer-terminated contract: when the upstream Tokenizer already produced
|
|
// the _tks/_ltks/_kwd fields, the executor preserves them untouched and only
|
|
// strips the consumed source fields. The executor never re-tokenizes or
|
|
// overwrites Tokenizer output.
|
|
func TestProcessChunksForPipeline_PreservesTokenizerProducedFields(t *testing.T) {
|
|
chunks := []map[string]any{{
|
|
"text": "hello",
|
|
"questions": "Q1\nQ2",
|
|
"question_tks": "tokenizer-output-tks",
|
|
"question_kwd": []string{"preset-q-kwd"},
|
|
"keywords": "kw1,kw2",
|
|
"important_tks": "tokenizer-output-itks",
|
|
"important_kwd": []string{"preset-i-kwd"},
|
|
"summary": "a summary",
|
|
"content_ltks": "tokenizer-output-ltks",
|
|
"content_sm_ltks": "tokenizer-output-smltks",
|
|
}}
|
|
_, err := ProcessChunksForPipeline(chunks, "doc-1", "kb-1", "test-doc.pdf", time.Now())
|
|
if err != nil {
|
|
t.Fatalf("ProcessChunksForPipeline: %v", err)
|
|
}
|
|
|
|
// Consumed source fields are stripped.
|
|
for _, k := range []string{"questions", "keywords", "summary"} {
|
|
if _, exists := chunks[0][k]; exists {
|
|
t.Errorf("%s should be removed (consumed by Tokenizer)", k)
|
|
}
|
|
}
|
|
// Tokenizer-produced fields are preserved verbatim (not overwritten).
|
|
if chunks[0]["question_tks"] != "tokenizer-output-tks" {
|
|
t.Errorf("question_tks overwritten: %v", chunks[0]["question_tks"])
|
|
}
|
|
if chunks[0]["important_tks"] != "tokenizer-output-itks" {
|
|
t.Errorf("important_tks overwritten: %v", chunks[0]["important_tks"])
|
|
}
|
|
if chunks[0]["content_ltks"] != "tokenizer-output-ltks" {
|
|
t.Errorf("content_ltks overwritten: %v", chunks[0]["content_ltks"])
|
|
}
|
|
if chunks[0]["content_sm_ltks"] != "tokenizer-output-smltks" {
|
|
t.Errorf("content_sm_ltks overwritten: %v", chunks[0]["content_sm_ltks"])
|
|
}
|
|
// Preset _kwd arrays are preserved (executor does not overwrite).
|
|
if kwd, ok := chunks[0]["question_kwd"].([]string); !ok || len(kwd) != 1 || kwd[0] != "preset-q-kwd" {
|
|
t.Errorf("question_kwd preset not preserved: %v", chunks[0]["question_kwd"])
|
|
}
|
|
if kwd, ok := chunks[0]["important_kwd"].([]string); !ok || len(kwd) != 1 || kwd[0] != "preset-i-kwd" {
|
|
t.Errorf("important_kwd preset not preserved: %v", chunks[0]["important_kwd"])
|
|
}
|
|
}
|
|
|
|
func TestProcessChunksForPipeline_TextRenamed(t *testing.T) {
|
|
chunks := []map[string]any{{"text": "hello world"}}
|
|
_, err := ProcessChunksForPipeline(chunks, "doc-1", "kb-1", "test-doc.pdf", time.Now())
|
|
if err != nil {
|
|
t.Fatalf("ProcessChunksForPipeline: %v", err)
|
|
}
|
|
|
|
if _, exists := chunks[0]["text"]; exists {
|
|
t.Error("text key should be removed")
|
|
}
|
|
if chunks[0]["content_with_weight"] != "hello world" {
|
|
t.Errorf("content_with_weight = %q, want \"hello world\"", chunks[0]["content_with_weight"])
|
|
}
|
|
}
|
|
|
|
func TestProcessChunksForPipeline_PreservesContentWithWeight(t *testing.T) {
|
|
chunks := []map[string]any{{"content_with_weight": "already set", "text": "hello"}}
|
|
_, err := ProcessChunksForPipeline(chunks, "doc-1", "kb-1", "test-doc.pdf", time.Now())
|
|
if err != nil {
|
|
t.Fatalf("ProcessChunksForPipeline: %v", err)
|
|
}
|
|
if chunks[0]["content_with_weight"] != "already set" {
|
|
t.Errorf("content_with_weight = %q, want \"already set\"", chunks[0]["content_with_weight"])
|
|
}
|
|
}
|
|
|
|
func TestProcessChunkPositions_FlatFloat64(t *testing.T) {
|
|
chunk := map[string]any{
|
|
"positions": []float64{0, 100, 50, 200, 150},
|
|
}
|
|
processChunkPositions(chunk)
|
|
|
|
if _, exists := chunk["positions"]; exists {
|
|
t.Fatal("positions key must be removed")
|
|
}
|
|
pageNum := chunk["page_num_int"].([]int)
|
|
if len(pageNum) != 1 || pageNum[0] != 1 {
|
|
t.Errorf("page_num_int = %v, want [1]", pageNum)
|
|
}
|
|
}
|
|
|
|
func TestProcessChunkPositions_2DFloat64(t *testing.T) {
|
|
chunk := map[string]any{
|
|
"positions": [][]float64{
|
|
{0, 100, 50, 200, 150},
|
|
{1, 200, 60, 300, 250},
|
|
},
|
|
}
|
|
processChunkPositions(chunk)
|
|
|
|
if _, exists := chunk["positions"]; exists {
|
|
t.Fatal("positions key must be removed")
|
|
}
|
|
pageNum := chunk["page_num_int"].([]int)
|
|
if len(pageNum) != 2 || pageNum[0] != 1 || pageNum[1] != 2 {
|
|
t.Errorf("page_num_int = %v, want [1 2]", pageNum)
|
|
}
|
|
top := chunk["top_int"].([]int)
|
|
if len(top) != 2 || top[0] != 200 || top[1] != 300 {
|
|
t.Errorf("top_int = %v, want [200 300]", top)
|
|
}
|
|
}
|
|
|
|
func TestProcessChunkPositions_NoPositions(t *testing.T) {
|
|
chunk := map[string]any{"text": "hello"}
|
|
processChunkPositions(chunk)
|
|
if _, exists := chunk["page_num_int"]; exists {
|
|
t.Error("page_num_int must not be set when positions is missing")
|
|
}
|
|
}
|