Files
ragflow/internal/ingestion/knowledge_compile/reader.go
Zhichang Yu 01d667296d refactor(knowledge_compile): global compile pool, token-budget batching, and DocEngine-only deletion (#17679)
## Summary

This PR refactors the Go knowledge-compilation ingestion pipeline
(`internal/ingestion/knowledge_compile` +
`internal/ingestion/component/knowledge_compiler`) with three related
changes:

- **Token-budget batching for LLM merge decisions.**
`LLMMergeDecider.DecideBatch` previously stuffed every `(existing,
candidate)` pair into a single LLM call, risking `max_token` overflow.
It now splits pairs into token-bounded sub-batches (budget =
`llmMaxTokens * 0.85`) via `tokenizer.NumTokensFromString`, runs them
concurrently while preserving the global pair index, and never
reindexes.
- **Process-level global compile pool.** Introduces a single vCPU-sized
goroutine pool (`pool.go`, env `KC_COMPILE_CONCURRENCY`) dedicated to
*all* knowledge-compilation stages. KNN search loop, `DecideBatch`
sub-batches, `WriteMerged`/`DeleteMerged` internals, and the
component-level (structure/mindmap) per-call pools are all unified into
it via an injected submitter. No more per-job short-lived goroutines in
`runCompilerJobs` (futures are collected then awaited on the caller).
Fan-out stays bounded by the pool worker count; these stages are
docengine-bounded / LLM-bounded, not CPU-bounded.
- **DocEngine-only deletion.** `Consumer.processBatch` deletion no
longer loads the deleted docs' products into memory. Two sequential
DocEngine calls replace the old in-memory surgery:
- `DeleteDocLevelForDocs` — one `DeleteChunks` over `doc_id IN
deletedDocIDs` (merged rows carry `doc_id == kb`, so only per-doc
products match).
- `StripMergedSources` — one `Search` of `kc_merged=1` rows filtered by
`source_doc_ids IN deletedDocIDs` (intersection pushed down to the
engine), `UpdateChunks` the source array of survivors, and
`DeleteChunks` the rows whose array became empty.

## Changes

- `internal/ingestion/knowledge_compile/pool.go` (new): global
`compilerPool` +
`runCompilerJobs`/`SubmitCompilerJob`/`SubmitCompilerJobs`.
- `internal/ingestion/knowledge_compile/consumer.go`: deletion rewritten
to the two DocEngine calls;
`mergedBase`/`toDelete`/`stripDeletedSources` removed.
- `internal/ingestion/knowledge_compile/writer.go`:
`DeleteDocLevelForDocs` + `StripMergedSources` replace
`DeleteMergedForDoc`/`DeleteMerged`.
- `internal/ingestion/knowledge_compile/reader.go`: drop
`LoadMergedBySourceDoc` + `containsString` (keep `LoadDocProducts` for
the completion branch).
- `internal/ingestion/knowledge_compile/dedup.go`: `NewLLMDeduper` takes
`llmMaxTokens`; wires `SetMaxBatchTokens`/`SetSubmitter`.
- `internal/ingestion/knowledge_compiler/{structure,merge}.go`,
`mindmap/mindmap.go`, `pool_wiring.go`: token-budget split + submitter
injection.
- Tests: `structure_test.go` (token-budget split), `dedup_test.go`,
`consumer_test.go` (tombstone + DocEngine deletion assertions) updated.

## Validation

`bash build.sh --test -race ./internal/ingestion/knowledge_compile/...
./internal/ingestion/component/knowledge_compiler/...` passes (unit
tier, no external services).

🤖 Generated with [CodeBuddy](https://www.codebuddy.ai)

---------

Co-authored-by: yuzhichang <yuzhichang@infiniflow.ai>
2026-08-02 17:06:29 +08:00

291 lines
9.0 KiB
Go

//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package knowledge_compile
import (
"context"
"encoding/json"
"fmt"
"strconv"
"ragflow/internal/engine"
"ragflow/internal/engine/types"
kccommon "ragflow/internal/ingestion/component/knowledge_compiler/common"
)
// Reader finds the compiled products needed for incremental dedup without
// loading the whole KB into memory (§11.6 step 1, §11.7 incremental re-dedup).
//
// The dedup between an incoming per-document product and the already-merged
// rows lives in external storage (DocEngine): the consumer only keeps the
// in-flight batch in memory and asks the engine for nearest matches via KNN.
// It never scans every compiled chunk of the KB, which would OOM on a large
// knowledge base — this mirrors Python's _struct_doc_storage_dedup_batch, which
// takes only the just-compiled docs and KNN-queries the store per doc.
type Reader interface {
// LoadDocProducts returns the per-document compiled rows for a single
// document (doc_id == source_doc). Bounded by one document, never the whole
// KB.
LoadDocProducts(ctx context.Context, tenant, kb, docID string) ([]kccommon.Product, error)
// SearchSimilar runs a dense (KNN) search over the existing merged rows of
// the given variant and returns the single most-similar row whose score is
// at least minScore, plus that score. It returns a zero Product when nothing
// clears the threshold. This mirrors Python's _struct_doc_storage_knn_candidate
// (topn=1, similarity_threshold): find the dot product above the threshold
// and maximum, then decide duplication with the LLM.
SearchSimilar(ctx context.Context, tenant, kb string, variant kccommon.Variant, vector []float64, topN int, minScore float64) (kccommon.Product, float64, error)
}
// engineReader loads the per-document compiled products through the global
// DocEngine (§11.6 step 1, §11.7 incremental re-dedup). It depends on the
// process-wide DocEngine obtained via engine.Get(); the engine abstraction owns
// the storage schema, so this reader is not backend-specific.
type engineReader struct {
eng engine.DocEngine
}
// compiledSelectFields are the columns needed to reconstruct a Product from a
// stored compiled chunk document.
var compiledSelectFields = []string{
"id", "doc_id", "tenant_id", "compile_kwd",
"content_with_weight", "kc_payload",
"source_chunk_ids", "source_doc_ids",
"name_kwd", "entity_type_kwd", "from_entity_kwd", "to_entity_kwd",
"slug_kwd", "type",
}
// loadDocProductsLimit is the per-page size used when scrolling a single
// document's compiled rows. A document can compile more than this many rows, so
// LoadDocProducts pages until the engine returns fewer than a full page.
const loadDocProductsLimit = 5000
// LoadDocProducts returns the per-document compiled rows for a single document.
// It is bounded to one document, so the consumer never loads the whole KB. The
// results are paged so a document with more than loadDocProductsLimit rows is
// not silently truncated.
func (r engineReader) LoadDocProducts(ctx context.Context, tenant, kb, docID string) ([]kccommon.Product, error) {
eng := r.eng
if eng == nil {
eng = engine.Get()
}
if eng == nil {
return nil, nil
}
var out []kccommon.Product
offset := 0
for {
res, err := eng.Search(ctx, &types.SearchRequest{
IndexNames: []string{fmt.Sprintf("ragflow_%s", tenant)},
KbIDs: []string{kb},
Filter: map[string]interface{}{"doc_id": docID},
SelectFields: compiledSelectFields,
Limit: loadDocProductsLimit,
Offset: offset,
})
if err != nil {
return nil, err
}
for _, c := range res.Chunks {
// Only compiled products carry compile_kwd; skip ordinary source chunks.
if _, ok := c["compile_kwd"]; !ok {
continue
}
if p, ok := productFromChunkMap(c, tenant); ok {
out = append(out, p)
}
}
if len(res.Chunks) < loadDocProductsLimit {
break
}
offset += loadDocProductsLimit
}
return out, nil
}
// productFromChunkMap reconstructs a kccommon.Product from a stored compiled
// chunk document. It reads the payload from kc_payload (falling back to
// content_with_weight) and the embedding from the q_<dim>_vec column.
func productFromChunkMap(c map[string]interface{}, tenant string) (kccommon.Product, bool) {
content, _ := c["kc_payload"].(string)
if content == "" {
content, _ = c["content_with_weight"].(string)
}
if content == "" {
return kccommon.Product{}, false
}
id, _ := c["id"].(string)
docID, _ := c["doc_id"].(string)
variant, _ := c["compile_kwd"].(string)
merged := isMerged(c["kc_merged"])
meta := map[string]any{}
if v, ok := c["name_kwd"].(string); ok && v != "" {
meta["name"] = v
}
if v, ok := c["entity_type_kwd"].(string); ok && v != "" {
meta["entity_type"] = v
}
if v, ok := c["from_entity_kwd"].(string); ok && v != "" {
meta["from"] = v
meta["kind"] = "relation"
}
if v, ok := c["to_entity_kwd"].(string); ok && v != "" {
meta["to"] = v
meta["kind"] = "relation"
}
if v, ok := c["slug_kwd"].(string); ok && v != "" {
meta["slug"] = v
}
if v, ok := c["type"].(string); ok && v != "" {
meta["type"] = v
}
if _, ok := meta["kind"]; !ok {
if _, hasName := meta["name"]; hasName {
meta["kind"] = "entity"
}
}
if v := metaStringSlice(c, "source_chunk_ids"); len(v) > 0 {
meta["source_chunk_ids"] = v
}
if v := metaStringSlice(c, "source_doc_ids"); len(v) > 0 {
meta["source_doc_ids"] = v
}
vec, _ := kccommon.VectorFromChunkMap(c, 0)
return kccommon.Product{
ID: id,
DocID: docID,
TenantID: tenant,
Variant: kccommon.Variant(variant),
Content: content,
Vector: vec,
Meta: meta,
Merged: merged,
}, true
}
// SearchSimilar runs a dense KNN over the existing merged rows (kc_merged=1,
// compile_kwd=variant) of the KB and returns the closest hit above minScore.
func (r engineReader) SearchSimilar(ctx context.Context, tenant, kb string, variant kccommon.Variant, vector []float64, topN int, minScore float64) (kccommon.Product, float64, error) {
eng := r.eng
if eng == nil {
eng = engine.Get()
}
if eng == nil {
return kccommon.Product{}, 0, nil
}
if topN <= 0 {
topN = 1
}
dim := len(vector)
req := &types.SearchRequest{
IndexNames: []string{fmt.Sprintf("ragflow_%s", tenant)},
KbIDs: []string{kb},
Limit: topN,
SelectFields: []string{"id", "doc_id", "kb_id", "content_with_weight", "kc_payload",
"name_kwd", "entity_type_kwd", "from_entity_kwd", "to_entity_kwd", "slug_kwd",
"type", "source_chunk_ids", "source_doc_ids", "kc_merged", "compile_kwd"},
Filter: map[string]interface{}{
"kc_merged": 1,
"compile_kwd": string(variant),
},
MatchExprs: []interface{}{
&types.MatchDenseExpr{
VectorColumnName: fmt.Sprintf("q_%d_vec", dim),
EmbeddingData: vector,
DistanceType: "cosine",
TopN: topN,
ExtraOptions: map[string]interface{}{"min_score": minScore},
},
},
}
res, err := eng.Search(ctx, req)
if err != nil {
return kccommon.Product{}, 0, err
}
for _, c := range res.Chunks {
p, ok := productFromChunkMap(c, tenant)
if !ok || !p.Merged {
continue
}
score := toFloat64(c["score"])
return p, score, nil
}
return kccommon.Product{}, 0, nil
}
// isMerged normalizes the boxed kc_merged field returned by the DocEngine,
// which may be stored as a string ("1"/"0"/"true"), a bool, or a numeric,
// depending on backend and mapping. Returns true for any positive/true form.
func isMerged(v interface{}) bool {
switch t := v.(type) {
case nil:
return false
case bool:
return t
case string:
switch t {
case "1", "true", "True", "TRUE":
return true
}
if f, err := strconv.ParseFloat(t, 64); err == nil {
return f > 0
}
return false
case int:
return t > 0
case int64:
return t > 0
case float64:
return t > 0
case float32:
return t > 0
case json.Number:
if f, err := t.Float64(); err == nil {
return f > 0
}
}
return false
}
// toFloat64 normalizes the boxed score field returned by the DocEngine into a
// float64, accepting float32, float64, numeric strings, and json.Number. It
// returns 0 when the value is missing or not numeric.
func toFloat64(v interface{}) float64 {
switch t := v.(type) {
case nil:
return 0
case float64:
return t
case float32:
return float64(t)
case int:
return float64(t)
case int64:
return float64(t)
case string:
if f, err := strconv.ParseFloat(t, 64); err == nil {
return f
}
case json.Number:
if f, err := t.Float64(); err == nil {
return f
}
}
return 0
}