2026-07-29 21:06:48 +08:00
package structure
import (
"context"
"encoding/json"
"fmt"
"math"
"strings"
"testing"
"github.com/cespare/xxhash/v2"
"ragflow/internal/ingestion/component/knowledge_compiler/common"
)
// ---- mocks ----
// graphChat answers the three LLM call shapes of the structure variant:
// node extraction, edge extraction, and merge judging. It emits items for
// every fixture phrase present in the user prompt so assertions hold whether
// the chunks pack into one batch or several.
type graphChat struct {
mergeCalls int
nodeCalls int
edgeCalls int
}
var graphFixtures = [ ] struct {
phrase string
entities [ ] map [ string ] any
relations [ ] map [ string ] any
} {
{
phrase : "Alpha is a Beta" ,
entities : [ ] map [ string ] any {
{ "type" : "letter" , "name" : "Alpha" , "description" : "the letter Alpha" } ,
{ "type" : "letter" , "name" : "Beta" , "description" : "the letter Beta" } ,
} ,
relations : [ ] map [ string ] any {
{ "type" : "linked" , "source" : "Alpha" , "target" : "Beta" , "description" : "Alpha is a Beta" } ,
} ,
} ,
{
phrase : "Beta related to Gamma" ,
entities : [ ] map [ string ] any {
{ "type" : "letter" , "name" : "Beta" , "description" : "the letter Beta" } ,
{ "type" : "letter" , "name" : "Gamma" , "description" : "the letter Gamma" } ,
} ,
relations : [ ] map [ string ] any {
{ "type" : "linked" , "source" : "Beta" , "target" : "Gamma" , "description" : "Beta related to Gamma" } ,
} ,
} ,
{
phrase : "Al partners with Gamma" ,
entities : [ ] map [ string ] any {
{ "type" : "letter" , "name" : "Al" , "description" : "short for Alpha" } ,
{ "type" : "letter" , "name" : "Gamma" , "description" : "the letter Gamma" } ,
} ,
relations : [ ] map [ string ] any {
{ "type" : "partners_with" , "source" : "Al" , "target" : "Gamma" , "description" : "Al partners with Gamma" } ,
} ,
} ,
{
phrase : "Alpha also links Gamma" ,
entities : [ ] map [ string ] any {
{ "type" : "letter" , "name" : "Alpha" , "description" : "the letter Alpha" } ,
{ "type" : "letter" , "name" : "Gamma" , "description" : "the letter Gamma" } ,
} ,
relations : [ ] map [ string ] any {
{ "type" : "linked" , "source" : "Alpha" , "target" : "Gamma" , "description" : "Alpha also links Gamma" } ,
} ,
} ,
}
func itemsJSON ( items [ ] map [ string ] any , chunkIDs [ ] string ) string {
for _ , item := range items {
item [ "source_chunk_ids" ] = chunkIDs
}
b , _ := json . Marshal ( map [ string ] any { "items" : items } )
return string ( b )
}
func chunkIDsFromPrompt ( prompt string ) [ ] string {
var ids [ ] string
for _ , id := range [ ] string { "c1" , "c2" , "c3" } {
if strings . Contains ( prompt , "[CHUNK_ID: " + id + "]" ) {
ids = append ( ids , id )
}
}
if len ( ids ) == 0 {
return [ ] string { "c1" }
}
return ids
}
func ( m * graphChat ) Chat ( _ context . Context , req common . ChatRequest ) ( * common . ChatResponse , error ) {
ids := chunkIDsFromPrompt ( req . UserPrompt )
switch {
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
case strings . HasPrefix ( req . UserPrompt , "Pair " ) :
// Batched merge judge: one call covers every (Item A, Item B) pair.
m . mergeCalls ++
pairs := parseBatchPairs ( req . UserPrompt )
var results [ ] string
for _ , p := range pairs {
if sameLogicalItem ( p . a , p . b ) {
results = append ( results , fmt . Sprintf ( ` { "index":%d,"duplicated":true,"merged":%s} ` , p . index , payloadJSON ( p . a ) ) )
} else {
results = append ( results , fmt . Sprintf ( ` { "index":%d,"duplicated":false,"merged":null} ` , p . index ) )
}
}
return & common . ChatResponse { Content : fmt . Sprintf ( ` { "pairs":[%s]} ` , strings . Join ( results , "," ) ) } , nil
2026-07-29 21:06:48 +08:00
case strings . HasPrefix ( req . UserPrompt , "Item A (existing):" ) :
m . mergeCalls ++
a , b := parseMergeItems ( req . UserPrompt )
if sameLogicalItem ( a , b ) {
return & common . ChatResponse { Content : fmt . Sprintf ( ` { "duplicated":true,"merged":%s} ` , payloadJSON ( a ) ) } , nil
}
return & common . ChatResponse { Content : ` { "duplicated":false,"merged":null} ` } , nil
case strings . Contains ( req . SystemPrompt , "strict-chain constraint" ) :
// Chain correction: keep Alpha→Beta, drop the fan-out partner.
return & common . ChatResponse { Content : ` { "keep":[ { "from":"Alpha","to":"Beta"}]} ` } , nil
case strings . Contains ( req . SystemPrompt , "## Known Entities:" ) :
m . edgeCalls ++
var rels [ ] map [ string ] any
for _ , f := range graphFixtures {
if strings . Contains ( req . UserPrompt , f . phrase ) {
for _ , r := range f . relations {
rels = append ( rels , copyPayload ( r ) )
}
}
}
return & common . ChatResponse { Content : itemsJSON ( rels , ids ) } , nil
default :
m . nodeCalls ++
var ents [ ] map [ string ] any
for _ , f := range graphFixtures {
if strings . Contains ( req . UserPrompt , f . phrase ) {
for _ , e := range f . entities {
ents = append ( ents , copyPayload ( e ) )
}
}
}
return & common . ChatResponse { Content : itemsJSON ( ents , ids ) } , nil
}
}
func copyPayload ( p map [ string ] any ) map [ string ] any {
out := map [ string ] any { }
for k , v := range p {
out [ k ] = v
}
return out
}
// parseMergeItems extracts the Item A / Item B payload JSONs from a merge
// judge user prompt.
func parseMergeItems ( prompt string ) ( map [ string ] any , map [ string ] any ) {
body := strings . TrimPrefix ( prompt , "Item A (existing):\n" )
parts := strings . SplitN ( body , "\n\nItem B (incoming):\n" , 2 )
if len ( parts ) != 2 {
return nil , nil
}
return parsePayload ( parts [ 0 ] ) , parsePayload ( parts [ 1 ] )
}
// sameLogicalItem mirrors what a reasonable judge would decide on the
// fixtures: entities are duplicates when their names match (or the known
// Al≡Alpha alias pair); relations when source+target+type all match.
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
func parseBatchPairs ( prompt string ) [ ] struct {
index int
a , b map [ string ] any
} {
sections := strings . Split ( prompt , "\nPair " )
var pairs [ ] struct {
index int
a , b map [ string ] any
}
for i , s := range sections {
if i == 0 {
// First section may start with "Pair 0:" without a leading "\n".
if ! strings . HasPrefix ( s , "Pair " ) {
continue
}
}
// Drop the leading "N:\n".
rest := s
if idx := strings . Index ( rest , ":\n" ) ; idx >= 0 {
rest = rest [ idx + 2 : ]
}
aBody , bBody , ok := strings . Cut ( rest , "\n\nItem B (incoming):\n" )
if ! ok {
continue
}
aBody = strings . TrimPrefix ( aBody , "Item A (existing):\n" )
pairs = append ( pairs , struct {
index int
a , b map [ string ] any
} { index : i , a : parsePayload ( aBody ) , b : parsePayload ( bBody ) } )
}
return pairs
}
2026-07-29 21:06:48 +08:00
func sameLogicalItem ( a , b map [ string ] any ) bool {
if a == nil || b == nil {
return false
}
aName , bName := entityName ( a ) , entityName ( b )
if aName != "" && bName != "" {
if aName == bName {
return true
}
pair := map [ string ] bool { aName : true , bName : true }
return pair [ "Al" ] && pair [ "Alpha" ]
}
as , bs := relationEndpoint ( a , "" , "source" ) , relationEndpoint ( b , "" , "source" )
at , bt := relationEndpoint ( a , "" , "target" ) , relationEndpoint ( b , "" , "target" )
return as != "" && as == bs && at == bt && stringOf ( a [ "type" ] ) == stringOf ( b [ "type" ] )
}
// hashEmbedder is content-deterministic: identical text => identical (unit)
// vector => cosine 1.0. Near-identical texts do NOT collide, so only exact
// duplicate payloads reach the LLM judge.
type hashEmbedder struct { dim int }
func ( m hashEmbedder ) Dimensions ( ) int { return m . dim }
func ( m hashEmbedder ) Encode ( _ context . Context , texts [ ] string ) ( [ ] [ ] float32 , error ) {
out := make ( [ ] [ ] float32 , len ( texts ) )
for i , t := range texts {
v := make ( [ ] float32 , m . dim )
for j := 0 ; j < m . dim ; j ++ {
h := xxhash . Sum64String ( fmt . Sprintf ( "%d:%s" , j , t ) )
v [ j ] = float32 ( int64 ( h % ( 1 << 20 ) ) - ( 1 << 19 ) ) / float32 ( 1 << 19 )
}
var s float64
for _ , x := range v {
s += float64 ( x ) * float64 ( x )
}
if s = math . Sqrt ( s ) ; s > 0 {
for k := range v {
v [ k ] = float32 ( float64 ( v [ k ] ) / s )
}
}
out [ i ] = v
}
return out , nil
}
// constEmbedder maps every text to the same vector, so every pair in a dedup
// bucket reaches the LLM judge (used to exercise alias merges deterministically).
type constEmbedder struct { dim int }
func ( m constEmbedder ) Dimensions ( ) int { return m . dim }
func ( m constEmbedder ) Encode ( _ context . Context , texts [ ] string ) ( [ ] [ ] float32 , error ) {
v := make ( [ ] float32 , m . dim )
for i := range v {
v [ i ] = 1
}
out := make ( [ ] [ ] float32 , len ( texts ) )
for i := range out {
out [ i ] = v
}
return out , nil
}
func graphParserConfig ( ) map [ string ] any {
return map [ string ] any {
"kind" : "graph" ,
"entity" : map [ string ] any { "fields" : [ ] any {
map [ string ] any { "type" : "letter" , "description" : "a single letter entity" } ,
} } ,
"relation" : map [ string ] any { "fields" : [ ] any {
map [ string ] any { "type" : "linked" , "description" : "a link between letters" } ,
map [ string ] any { "type" : "partners_with" , "description" : "a partnership" } ,
} } ,
}
}
func testParam ( ) common . Param {
p := common . Param { } . Defaults ( )
p . Variant = common . VariantStructure
p . SimilarityThreshold = 0.99
p . MaxWorkers = 1
return p
}
// ---- prompt alignment tests ----
func TestHypergraphPromptsTemplateShape ( t * testing . T ) {
cfg := map [ string ] any {
"kind" : "graph" ,
"guideline" : map [ string ] any { "target" : "Extract the letter graph." , "rules_for_entities" : "Be precise." , "rules_for_relations" : "Only stated links." , "rules_for_time" : "As of {observation_time}." } ,
"global_rules" : "No inventions." ,
"entity" : map [ string ] any {
"description" : "Letter entities." ,
"fields" : [ ] any { map [ string ] any { "type" : "letter" , "description" : "a letter" , "rule" : "single glyph" } } ,
} ,
"relation" : map [ string ] any {
"description" : "Letter links." ,
"fields" : [ ] any { map [ string ] any { "type" : "linked" , "description" : "a link" } } ,
} ,
}
node , edge := HypergraphPrompts ( cfg , "en" )
for _ , want := range [ ] string {
"# Role and Task:\nExtract the letter graph." ,
"## Global Rules:\nNo inventions." ,
"## Entity Extraction Rules:\nBe precise." ,
"## Entity Description:\nLetter entities." ,
"## Entity Fields:\n- type: letter\n description: a letter\n rule: single glyph" ,
` Auto-type: "hypergraph". ` ,
"Return JSON only, no commentary." ,
` "name": "<exact extracted item text>" ` ,
` "source_chunk_ids": ["<source chunk id>", ...] ` ,
} {
if ! strings . Contains ( node , want ) {
t . Errorf ( "node prompt missing %q\n---\n%s" , want , node )
}
}
if strings . Contains ( node , "Items must be unique." ) {
t . Errorf ( "hypergraph (not set) must not demand uniqueness" )
}
for _ , want := range [ ] string {
"## Relation Extraction Rules:\nOnly stated links." ,
"## Time Rules:\nAs of " ,
"## Relation Description:\nLetter links." ,
"## Relation Fields:\n- type: linked\n description: a link" ,
"## Known Entities:\n{known_nodes}" ,
"Only create relations between entities listed in 'Known Entities'." ,
` "source": "<known entity name>" ` ,
} {
if ! strings . Contains ( edge , want ) {
t . Errorf ( "edge prompt missing %q\n---\n%s" , want , edge )
}
}
if strings . Contains ( edge , "{observation_time}" ) {
t . Errorf ( "observation_time placeholder was not substituted" )
}
}
func TestHypergraphPromptsSetUniquenessAndNoRelations ( t * testing . T ) {
cfg := map [ string ] any {
"compile_type" : "set" ,
"entity" : map [ string ] any { "fields" : [ ] any { map [ string ] any { "type" : "letter" } } } ,
}
node , edge := HypergraphPrompts ( cfg , "en" )
if ! strings . Contains ( node , "Items must be unique. " ) {
t . Errorf ( "set kind must demand uniqueness:\n%s" , node )
}
if ! strings . Contains ( node , ` Auto-type: "set". ` ) {
t . Errorf ( "set Auto-type missing:\n%s" , node )
}
if edge != "" {
t . Errorf ( "config without relations must skip the edge stage, got:\n%s" , edge )
}
if got := InferType ( cfg ) ; got != TypeSet {
t . Errorf ( "InferType = %q, want set" , got )
}
// "graph" / "knowledge_graph" aliases resolve to hypergraph.
for _ , alias := range [ ] string { "graph" , "knowledge_graph" } {
if got := InferType ( map [ string ] any { "kind" : alias } ) ; got != TypeHypergraph {
t . Errorf ( "InferType(kind=%q) = %q, want hypergraph" , alias , got )
}
}
// Arbitrary kinds (timeline, page_index) are returned verbatim — Python
// stamps them as the autotype/compile_kwd.
if got := InferType ( map [ string ] any { "kind" : "timeline" } ) ; got != Type ( "timeline" ) {
t . Errorf ( "InferType(kind=timeline) = %q, want verbatim timeline" , got )
}
// An unknown compile_type is NOT a compile kind and falls through to kind.
if got := InferType ( map [ string ] any { "compile_type" : "timeline" } ) ; got != TypeList {
t . Errorf ( "InferType(compile_type=timeline) = %q, want list (falls through)" , got )
}
if got := InferType ( map [ string ] any { } ) ; got != TypeList {
t . Errorf ( "InferType(empty) = %q, want list" , got )
}
}
func TestFillKnownNodes ( t * testing . T ) {
tmpl := "## Known Entities:\n{known_nodes}"
if got := fillKnownNodes ( tmpl , nil ) ; ! strings . Contains ( got , "(none)" ) {
t . Fatalf ( "empty known list must render (none): %q" , got )
}
got := fillKnownNodes ( tmpl , [ ] string { "Alpha" , "Beta" } )
if ! strings . Contains ( got , "- Alpha\n- Beta" ) {
t . Fatalf ( "known list not rendered: %q" , got )
}
}
// ---- payload helpers ----
func TestPayloadChunkIDs ( t * testing . T ) {
payload := map [ string ] any { "source_chunk_ids" : [ ] any { "c1" , "nope" , "c1" } }
got := payloadChunkIDs ( payload , [ ] string { "c1" , "c2" } )
if len ( got ) != 1 || got [ 0 ] != "c1" {
t . Fatalf ( "filter+dedupe failed: %v" , got )
}
// Fallback: none of the model's ids belong to the batch -> all batch ids.
payload = map [ string ] any { "source_chunk_ids" : [ ] any { "nope" } }
got = payloadChunkIDs ( payload , [ ] string { "c1" , "c2" } )
if len ( got ) != 2 {
t . Fatalf ( "fallback to batch ids failed: %v" , got )
}
// A bare string is accepted (mirrors Python).
payload = map [ string ] any { "source_chunk_ids" : "c2" }
got = payloadChunkIDs ( payload , [ ] string { "c1" , "c2" } )
if len ( got ) != 1 || got [ 0 ] != "c2" {
t . Fatalf ( "string form failed: %v" , got )
}
}
func TestPayloadDescriptionSortedAndFlattened ( t * testing . T ) {
payload := map [ string ] any {
"name" : "Beta" ,
"type" : "letter" ,
"source_chunk_ids" : [ ] any { "c1" , "c2" } ,
}
got := payloadDescription ( payload )
// Keys are sorted for determinism: name < source_chunk_ids < type.
if got != "Beta c1 c2 letter" {
t . Fatalf ( "payloadDescription = %q, want %q" , got , "Beta c1 c2 letter" )
}
}
// ---- Run: extraction + LLM dedup + graph ----
func TestStructureRunGraphKind ( t * testing . T ) {
deps := common . Deps { Chat : & graphChat { } , Embed : hashEmbedder { dim : 16 } , TenantID : "t1" , DatasetID : "d1" }
param := testParam ( )
inputs := common . Inputs {
DocID : "doc1" ,
Chunks : [ ] common . Chunk {
{ ID : "c1" , Text : "Alpha is a Beta." } ,
{ ID : "c2" , Text : "Beta related to Gamma." } ,
} ,
VariantSpecific : map [ string ] any { "parser_config" : graphParserConfig ( ) } ,
}
out , err := Run ( context . Background ( ) , deps , param , inputs )
if err != nil {
t . Fatalf ( "Run: %v" , err )
}
var entities , relations , graphs int
var betaProduct * common . Product
for i , p := range out . Products {
switch p . Meta [ "kind" ] {
case "entity" :
entities ++
if p . Meta [ "name" ] == "Beta" {
betaProduct = & out . Products [ i ]
}
if p . Meta [ "entity_type" ] != "letter" {
t . Errorf ( "entity %v missing entity_type stamp" , p . Meta [ "name" ] )
}
if p . Meta [ "compile_kwd" ] != "hypergraph" {
t . Errorf ( "entity row compile_kwd = %v, want hypergraph" , p . Meta [ "compile_kwd" ] )
}
case "relation" :
relations ++
case "graph" :
graphs ++
}
}
if entities != 3 || relations != 2 || graphs != 1 {
t . Fatalf ( "products = %d entities + %d relations + %d graph, want 3+2+1" , entities , relations , graphs )
}
if out . DuplicatesDropped != 1 {
t . Fatalf ( "DuplicatesDropped = %d, want 1 (the cross-chunk Beta)" , out . DuplicatesDropped )
}
if betaProduct == nil {
t . Fatal ( "no Beta entity product" )
}
ids := metaStrings ( betaProduct . Meta , "source_chunk_ids" )
if len ( ids ) != 2 {
t . Fatalf ( "Beta provenance = %v, want {c1,c2} unioned through the merge" , ids )
}
// The merged entity keeps the LLM-merged payload content (parseable JSON).
if parsePayload ( betaProduct . Content ) == nil {
t . Fatalf ( "entity content is not payload JSON: %q" , betaProduct . Content )
}
// Graph summary mirrors Python's {entities, relations} shape.
g := parsePayload ( graphContentOf ( out ) )
if g == nil {
t . Fatal ( "graph product missing/unparseable" )
}
gEnts , _ := g [ "entities" ] . ( [ ] any )
gRels , _ := g [ "relations" ] . ( [ ] any )
if len ( gEnts ) != 3 || len ( gRels ) != 2 {
t . Fatalf ( "graph = %d entities + %d relations, want 3+2" , len ( gEnts ) , len ( gRels ) )
}
}
func TestStructureListKindSkipsRelations ( t * testing . T ) {
deps := common . Deps { Chat : & graphChat { } , Embed : hashEmbedder { dim : 16 } , TenantID : "t1" , DatasetID : "d1" }
param := testParam ( )
inputs := common . Inputs {
DocID : "doc1" ,
Chunks : [ ] common . Chunk { { ID : "c1" , Text : "Alpha is a Beta." } } ,
// No parser_config: InferType yields "list", so no edge stage runs.
}
out , err := Run ( context . Background ( ) , deps , param , inputs )
if err != nil {
t . Fatalf ( "Run: %v" , err )
}
var relations int
for _ , p := range out . Products {
if p . Meta [ "kind" ] == "relation" {
relations ++
}
if p . Meta [ "compile_kwd" ] != "list" {
t . Errorf ( "compile_kwd = %v, want list" , p . Meta [ "compile_kwd" ] )
}
}
if relations != 0 {
t . Fatalf ( "list kind must not extract relations, got %d" , relations )
}
}
func TestStructureAliasRewrite ( t * testing . T ) {
// constEmbedder makes every pair a judge candidate; the mock judge merges
// Al into Alpha (the known alias pair), so the relation Al→Gamma must be
// rewritten to Alpha→Gamma by the alias pass.
deps := common . Deps { Chat : & graphChat { } , Embed : constEmbedder { dim : 4 } , TenantID : "t1" , DatasetID : "d1" }
param := testParam ( )
inputs := common . Inputs {
DocID : "doc1" ,
Chunks : [ ] common . Chunk {
{ ID : "c1" , Text : "Alpha is a Beta." } ,
{ ID : "c2" , Text : "Al partners with Gamma." } ,
} ,
VariantSpecific : map [ string ] any { "parser_config" : graphParserConfig ( ) } ,
}
out , err := Run ( context . Background ( ) , deps , param , inputs )
if err != nil {
t . Fatalf ( "Run: %v" , err )
}
var sawAlEntity , sawRewritten bool
for _ , p := range out . Products {
if p . Meta [ "kind" ] == "entity" && p . Meta [ "name" ] == "Al" {
sawAlEntity = true
}
if p . Meta [ "kind" ] == "relation" {
payload := parsePayload ( p . Content )
if payload == nil {
t . Fatalf ( "relation content not payload JSON: %q" , p . Content )
}
if relationEndpoint ( payload , "" , "source" ) == "Alpha" && relationEndpoint ( payload , "" , "target" ) == "Gamma" {
sawRewritten = true
if p . Meta [ "from" ] != "Alpha" || p . Meta [ "to" ] != "Gamma" {
t . Errorf ( "rewritten relation meta from/to = %v/%v" , p . Meta [ "from" ] , p . Meta [ "to" ] )
}
}
}
}
if sawAlEntity {
t . Errorf ( "Al entity should have been merged into Alpha" )
}
if ! sawRewritten {
t . Errorf ( "relation Al→Gamma was not rewritten to Alpha→Gamma" )
}
}
// ---- merge unit tests ----
func TestLLMMergeDeciderContracts ( t * testing . T ) {
chat := & graphChat { }
d := NewLLMMergeDecider ( chat , "llm1" , hashEmbedder { dim : 8 } , 0.99 )
// Below threshold: no LLM call, keep both.
before := chat . mergeCalls
dec , _ , err := d . Decide ( context . Background ( ) , common . Product { } , common . Product { } , 0.5 )
if err != nil || dec != DecisionKeepBoth || chat . mergeCalls != before {
t . Fatalf ( "below-threshold pair must be kept without an LLM call: dec=%v err=%v" , dec , err )
}
existing := common . Product {
ID : "row-alpha" ,
Content : payloadJSON ( map [ string ] any { "type" : "letter" , "name" : "Alpha" , "description" : "the letter Alpha" } ) ,
Meta : map [ string ] any { "kind" : "entity" , "name" : "Alpha" , "source_chunk_ids" : [ ] string { "c1" } } ,
}
incoming := common . Product {
ID : "row-alpha-2" ,
Content : payloadJSON ( map [ string ] any { "type" : "letter" , "name" : "Al" , "description" : "short for Alpha" } ) ,
Meta : map [ string ] any { "kind" : "entity" , "name" : "Al" , "source_chunk_ids" : [ ] string { "c2" } } ,
}
dec , merged , err := d . Decide ( context . Background ( ) , existing , incoming , 1.0 )
if err != nil {
t . Fatalf ( "Decide: %v" , err )
}
if dec != DecisionMerge {
t . Fatalf ( "alias pair must merge: dec=%v" , dec )
}
if merged . ID != "row-alpha" {
t . Errorf ( "merged row must preserve the existing id, got %q" , merged . ID )
}
if got := entityName ( parsePayload ( merged . Content ) ) ; got != "Alpha" {
t . Errorf ( "merged canonical name = %q, want Alpha" , got )
}
if ids := metaStrings ( merged . Meta , "source_chunk_ids" ) ; len ( ids ) != 2 {
t . Errorf ( "merged provenance = %v, want {c1,c2}" , ids )
}
if aliases := d . Aliases ( ) ; aliases [ "Al" ] != "Alpha" {
t . Errorf ( "alias map = %v, want Al→Alpha" , aliases )
}
}
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
// TestLLMMergeDeciderDecideBatch locks the batched judge: every pair is
// judged in a single LLM call (one mergeCalls increment), and the verdict
// array is returned in input order with duplicated/merged fields set.
func TestLLMMergeDeciderDecideBatch ( t * testing . T ) {
chat := & graphChat { }
d := NewLLMMergeDecider ( chat , "llm1" , hashEmbedder { dim : 8 } , 0.99 )
alpha := common . Product {
ID : "row-alpha" ,
DocID : "kb1" ,
Content : payloadJSON ( map [ string ] any { "type" : "letter" , "name" : "Alpha" , "description" : "the letter Alpha" } ) ,
Meta : map [ string ] any { "kind" : "entity" , "name" : "Alpha" , "source_chunk_ids" : [ ] string { "c1" } } ,
}
al := common . Product {
Content : payloadJSON ( map [ string ] any { "type" : "letter" , "name" : "Al" , "description" : "short for Alpha" } ) ,
Meta : map [ string ] any { "kind" : "entity" , "name" : "Al" , "source_chunk_ids" : [ ] string { "c2" } } ,
}
beta := common . Product {
Content : payloadJSON ( map [ string ] any { "type" : "letter" , "name" : "Beta" , "description" : "the letter Beta" } ) ,
Meta : map [ string ] any { "kind" : "entity" , "name" : "Beta" , "source_chunk_ids" : [ ] string { "c3" } } ,
}
pairs := [ ] MergePairInput {
{ Index : 0 , Existing : alpha . Content , Incoming : al . Content } , // duplicated (Al≡Alpha)
{ Index : 1 , Existing : alpha . Content , Incoming : beta . Content } , // distinct
}
before := chat . mergeCalls
results , err := d . DecideBatch ( context . Background ( ) , pairs )
if err != nil {
t . Fatalf ( "DecideBatch: %v" , err )
}
if chat . mergeCalls != before + 1 {
t . Errorf ( "batched judge made %d LLM calls, want 1" , chat . mergeCalls - before )
}
if len ( results ) != 2 {
t . Fatalf ( "want 2 results, got %d" , len ( results ) )
}
if ! results [ 0 ] . Duplicated || results [ 0 ] . Merged == nil {
t . Errorf ( "pair 0 (Al→Alpha) should be duplicated" )
}
if results [ 1 ] . Duplicated || results [ 1 ] . Merged != nil {
t . Errorf ( "pair 1 (Beta) should be distinct" )
}
if results [ 0 ] . Index != 0 || results [ 1 ] . Index != 1 {
t . Errorf ( "results must preserve input index order" )
}
}
// TestLLMMergeDeciderDecideBatchEmpty locks that an empty pair slice is a
// no-op and never invokes the LLM.
func TestLLMMergeDeciderDecideBatchEmpty ( t * testing . T ) {
chat := & graphChat { }
d := NewLLMMergeDecider ( chat , "llm1" , hashEmbedder { dim : 8 } , 0.99 )
before := chat . mergeCalls
out , err := d . DecideBatch ( context . Background ( ) , nil )
if err != nil || out != nil {
t . Fatalf ( "empty DecideBatch: err=%v out=%v" , err , out )
}
if chat . mergeCalls != before {
t . Errorf ( "empty DecideBatch must not call the LLM" )
}
}
// TestLLMMergeDeciderDecideBatchSplitsByTokenBudget locks that a tight token
// budget forces DecideBatch to issue several LLM calls (sub-batches) while
// still returning every verdict keyed by its original global index. This is
// the guard against overflowing the model's max_token on large candidate sets.
func TestLLMMergeDeciderDecideBatchSplitsByTokenBudget ( t * testing . T ) {
chat := & graphChat { }
d := NewLLMMergeDecider ( chat , "llm1" , hashEmbedder { dim : 8 } , 0.99 )
// Tiny model budget → one pair per sub-batch (exercises the split path).
// 20 * (1-0.15) = 17 token budget, well under each ~40-token pair.
d . SetMaxBatchTokens ( 20 )
alpha := common . Product {
ID : "row-alpha" ,
DocID : "kb1" ,
Content : payloadJSON ( map [ string ] any { "type" : "letter" , "name" : "Alpha" , "description" : "the letter Alpha" } ) ,
Meta : map [ string ] any { "kind" : "entity" , "name" : "Alpha" , "source_chunk_ids" : [ ] string { "c1" } } ,
}
al := common . Product {
Content : payloadJSON ( map [ string ] any { "type" : "letter" , "name" : "Al" , "description" : "short for Alpha" } ) ,
Meta : map [ string ] any { "kind" : "entity" , "name" : "Al" , "source_chunk_ids" : [ ] string { "c2" } } ,
}
beta := common . Product {
Content : payloadJSON ( map [ string ] any { "type" : "letter" , "name" : "Beta" , "description" : "the letter Beta" } ) ,
Meta : map [ string ] any { "kind" : "entity" , "name" : "Beta" , "source_chunk_ids" : [ ] string { "c3" } } ,
}
pairs := [ ] MergePairInput {
{ Index : 0 , Existing : alpha . Content , Incoming : al . Content } , // duplicated (Al≡Alpha)
{ Index : 1 , Existing : alpha . Content , Incoming : beta . Content } , // distinct
{ Index : 2 , Existing : beta . Content , Incoming : alpha . Content } , // distinct
}
before := chat . mergeCalls
results , err := d . DecideBatch ( context . Background ( ) , pairs )
if err != nil {
t . Fatalf ( "DecideBatch: %v" , err )
}
// Budget=1 → one LLM call per pair.
if chat . mergeCalls != before + 3 {
t . Errorf ( "token-split DecideBatch made %d LLM calls, want 3" , chat . mergeCalls - before )
}
if len ( results ) != 3 {
t . Fatalf ( "want 3 results, got %d" , len ( results ) )
}
// Verdicts must stay keyed by the original global index, not re-indexed.
wantDup := map [ int ] bool { 0 : true , 1 : false , 2 : false }
for _ , r := range results {
if r . Duplicated != wantDup [ r . Index ] {
t . Errorf ( "pair %d: duplicated=%v, want %v" , r . Index , r . Duplicated , wantDup [ r . Index ] )
}
}
}
2026-07-29 21:06:48 +08:00
func TestApplyMergeInvariants ( t * testing . T ) {
existing := common . Product {
Content : payloadJSON ( map [ string ] any { "type" : "linked" , "source" : "Alpha" , "target" : "Beta" } ) ,
Meta : map [ string ] any { "kind" : "relation" } ,
}
merged := map [ string ] any { "type" : "linked" , "source" : "WRONG" , "target" : "ALSO_WRONG" , "description" : "richer" }
out := applyMergeInvariants ( existing , merged )
if out [ "source" ] != "Alpha" || out [ "target" ] != "Beta" {
t . Fatalf ( "relation endpoints must be pinned to the existing payload: %v" , out )
}
if out [ "description" ] != "richer" {
t . Fatalf ( "non-endpoint fields must survive the merge: %v" , out )
}
}
func TestResolveAliasToleratesCycles ( t * testing . T ) {
aliases := map [ string ] string { "A" : "B" , "B" : "A" }
if got := resolveAlias ( "A" , aliases ) ; got != "A" && got != "B" {
t . Fatalf ( "cycle must terminate: %q" , got )
}
if got := resolveAlias ( "x" , map [ string ] string { "x" : "y" , "y" : "z" } ) ; got != "z" {
t . Fatalf ( "chain resolution failed: %q" , got )
}
}
func TestCosineDecider ( t * testing . T ) {
d := CosineDecider { Threshold : 0.99 }
got , _ , err := d . Decide ( context . Background ( ) , common . Product { } , common . Product { } , 1.0 )
if err != nil {
t . Fatal ( err )
}
if got != DecisionDropIncoming {
t . Fatalf ( "expected drop at 1.0, got %v" , got )
}
got , _ , _ = d . Decide ( context . Background ( ) , common . Product { } , common . Product { } , 0.5 )
if got != DecisionKeepBoth {
t . Fatalf ( "expected keep at 0.5, got %v" , got )
}
}
// ---- helpers ----
func graphContentOf ( out common . Outputs ) string {
for _ , p := range out . Products {
if p . Meta [ "kind" ] == "graph" {
return p . Content
}
}
return ""
}