Files
ragflow/internal/ingestion/knowledge_compile/consumer.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

442 lines
14 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"
"sort"
"sync"
"time"
"ragflow/internal/engine"
kccommon "ragflow/internal/ingestion/component/knowledge_compiler/common"
)
// Consumer is the dataset-level post-processing worker (§11.5). Multiple
// instances compete on the MySQL scheduling rows; each KB is processed by at
// most one instance at a time via the per-KB claim (so the same KB is handled
// by a single worker that serializes the batch). The MySQL row — not the
// broker — is the scheduling system of record and the source of same-KB
// serialization.
type Consumer struct {
scheduler Claimer
reader Reader
writer Writer
factory DeduperFactory
ttl time.Duration
heartbeat time.Duration
pollInterval time.Duration
sweepInterval time.Duration
mergeThreshold float64 // KNN similarity threshold for "existing merged row is a duplicate"
mu sync.Mutex
seqs map[string]map[string]uint64 // dataset -> docID -> last applied seq (per-doc out-of-order guard)
tombs map[string]map[string]uint64 // dataset -> docID -> delete event seq (tombstone)
}
// NewConsumer constructs a Consumer driven by the given Claimer. Tests pass a
// FakeScheduler and override the Reader/Writer/Deduper via options.
func NewConsumer(scheduler Claimer, opts ...Option) *Consumer {
c := &Consumer{
scheduler: scheduler,
reader: engineReader{eng: engine.Get()},
writer: engineWriter{eng: engine.Get()},
factory: defaultDeduperFactory,
ttl: 2 * time.Minute,
heartbeat: 20 * time.Second,
pollInterval: 2 * time.Second,
sweepInterval: 30 * time.Second,
mergeThreshold: 0.99,
seqs: map[string]map[string]uint64{},
tombs: map[string]map[string]uint64{},
}
for _, o := range opts {
o(c)
}
return c
}
// Run is one owned worker loop (Option E §11.5/§11.7): it wakes on NATS notify
// and otherwise polls the scheduling table for a claimable KB, then claims the
// closed batch, processes it, and acks. It returns when ctx is cancelled.
func (c *Consumer) Run(ctx context.Context) {
if c.scheduler == nil {
return
}
notifyCh, _ := c.scheduler.SubscribeNotify(ctx)
poll := time.NewTicker(c.pollInterval)
sweep := time.NewTicker(c.sweepInterval)
defer poll.Stop()
defer sweep.Stop()
for {
select {
case <-ctx.Done():
return
case datasetID, ok := <-notifyCh:
if !ok {
notifyCh = nil
continue
}
// The notify carries the dataset that just received backlog, so
// claim that specific dataset directly instead of probing for an
// arbitrary claimable one.
c.claimAndProcess(ctx, datasetID)
case <-poll.C:
c.tryClaimAndProcess(ctx)
case <-sweep.C:
// Crash recovery: TryClaim reclaims expired inflight leases
// (§11.5) before claiming any ready batch.
c.tryClaimAndProcess(ctx)
}
}
}
// claimAndProcess claims the given dataset (a notify pointed at it) and processes
// the closed batch. ok=false means the dataset has no ready batch or a live lease
// already holds it (the race was lost).
func (c *Consumer) claimAndProcess(ctx context.Context, datasetID string) {
cr, ok, err := c.scheduler.Claim(ctx, datasetID)
if err != nil || !ok || len(cr.Entries) == 0 {
return
}
c.processClaim(ctx, cr)
}
// tryClaimAndProcess claims one closed batch (ready or reclaimed) and processes
// it. ok=false means there was nothing to do this tick.
func (c *Consumer) tryClaimAndProcess(ctx context.Context) {
cr, ok, err := c.scheduler.TryClaim(ctx)
if err != nil || !ok || len(cr.Entries) == 0 {
return // nothing to claim, or the race was lost
}
c.processClaim(ctx, cr)
}
// processClaim processes an already-claimed batch (cr) and acks on success. It
// is the Option E replacement for the old processOnce (lease + drain + merge):
// the claim returned a frozen batch boundary, so there is no moving target and
// no Nak-churn routing.
func (c *Consumer) processClaim(ctx context.Context, cr ClaimResult) {
datasetID := cr.DatasetID
// Heartbeat refreshes the claim TTL while we process; a failed touch means
// the lease was taken over (or reclaimed) and we must abort without acking.
stopHb := make(chan struct{})
hbFailed := make(chan struct{}, 1)
go func() {
t := time.NewTicker(c.heartbeat)
defer t.Stop()
for {
select {
case <-stopHb:
return
case <-ctx.Done():
return
case <-t.C:
alive, e := c.scheduler.TouchClaim(ctx, datasetID, cr.Token, c.ttl)
if e != nil || !alive {
select {
case hbFailed <- struct{}{}:
default:
}
return
}
}
}
}()
done := make(chan struct{})
var batchErr error
go func() {
defer close(done)
batchErr = c.processBatch(ctx, cr.TenantID, datasetID, cr.Entries)
}()
select {
case <-ctx.Done():
close(stopHb)
<-done
return // graceful shutdown: leave inflight for reclamation, do not ack
case <-hbFailed:
close(stopHb)
<-done
return // lease lost: do not ack; sweeper/redelivery reprocesses (idempotent)
case <-done:
close(stopHb)
}
// Ack only on success. A batch error (reader/dedup/writer failure) means we
// must leave the claimed batch in the backlog for reclamation/retry rather
// than silently dropping it (C5: never ack what we failed to merge).
if batchErr != nil {
return
}
if _, err := c.scheduler.Ack(ctx, datasetID, cr.Token, cr.Entries); err != nil {
_ = err
}
}
// processBatch applies out-of-order / tombstone handling, then recomputes and
// writes the dataset-level merged products for the claimed closed batch. It
// returns an error if any reader/dedup/writer step fails so the caller can
// leave the batch for reclamation instead of acking dropped work.
func (c *Consumer) processBatch(ctx context.Context, tenant, kb string, entries []BacklogEntry) error {
c.mu.Lock()
if c.tombs == nil {
c.tombs = map[string]map[string]uint64{}
}
if c.seqs == nil {
c.seqs = map[string]map[string]uint64{}
}
tomb := c.tombs[kb]
docSeqs := c.seqs[kb]
if docSeqs == nil {
docSeqs = map[string]uint64{}
c.seqs[kb] = docSeqs
}
var completed []BacklogEntry
var deleted []string
// Per-document seq advances and tombstone clears are staged locally and
// committed only after the write/delete paths below return without error,
// so a failed batch leaves c.seqs/c.tombs untouched (except tombstones
// recorded for the deletion events already present in this batch) and can
// be retried on reclaim.
var pendingSeq []BacklogEntry
var pendingTombClear []string
// Reconcile each document to its highest-sequence event before classifying
// it as deleted or completed. A completion and a deletion for the same doc
// can land in the same batch, and completions/deletions can also arrive
// out of order across batches. The last event (by per-doc seq) wins, so we
// must not delete a doc that was re-ingested after its deletion, nor accept
// a stale completion shadowed by a later deletion.
type docState struct {
winEvent EventType
winSeq uint64
}
byDoc := make(map[string]*docState, len(entries))
for _, e := range entries {
et := EventType(e.EventType)
st := byDoc[e.DocID]
if st == nil {
byDoc[e.DocID] = &docState{winEvent: et, winSeq: e.Seq}
continue
}
if e.Seq >= st.winSeq {
st.winEvent = et
st.winSeq = e.Seq
}
}
for docID, st := range byDoc {
switch st.winEvent {
case EventTypeDeleted:
// Record the tombstone for this batch's deletion so a stale
// completion (seq <= delete seq) is skipped. The tombstone is
// committed immediately (deletions are not rolled back on a
// failed batch because re-running the delete is idempotent).
if tomb == nil {
tomb = map[string]uint64{}
c.tombs[kb] = tomb
}
tomb[docID] = st.winSeq
deleted = append(deleted, docID)
case EventTypeCompleted:
// A re-ingest after an earlier deletion: clear the prior
// tombstone (deferred until the batch succeeds).
if _, hadTomb := tomb[docID]; hadTomb {
pendingTombClear = append(pendingTombClear, docID)
}
// Seq is per-document, so the stale/duplicate check must be
// scoped to the document, not the whole dataset (C4).
if prev, ok := docSeqs[docID]; ok && st.winSeq <= prev {
continue // stale / duplicate completion for this doc
}
// The seq advance is deferred to after the batch succeeds (see
// below), so a transient reader/deduper/writer failure does not
// permanently drop the completion on the next reclaim+retry.
pendingSeq = append(pendingSeq, BacklogEntry{DocID: docID, EventType: string(EventTypeCompleted), Seq: st.winSeq})
completed = append(completed, BacklogEntry{DocID: docID, EventType: string(EventTypeCompleted), Seq: st.winSeq})
}
}
// Sort for deterministic iteration in the delete/load passes below.
sort.Strings(deleted)
c.mu.Unlock()
if len(deleted) == 0 && len(completed) == 0 {
return nil
}
deduper, err := c.factory(tenant)
if err != nil || deduper == nil {
deduper = NewNoopDeduper()
}
deletedSet := make(map[string]bool, len(deleted))
for _, d := range deleted {
deletedSet[d] = true
}
// --- Deletion (two sequential DocEngine calls, no in-memory load) ---
// Deleted wins regardless of batch order, so we process deletions first.
// The deleted docs' products are never loaded into memory: the DocEngine
// does all the work in two calls:
// 1. DeleteDocLevelForDocs drops every per-document (doc-level) product of
// the deleted docs in a single engine call.
// 2. StripMergedSources removes the deleted doc ids from the source_doc_ids
// array of every dataset-level merged product and deletes any product
// whose array became empty.
// A merged product referencing several deleted docs is pruned in one pass,
// and an emptied product is removed exactly once.
if len(deleted) > 0 {
delIDs := make([]string, 0, len(deletedSet))
for d := range deletedSet {
delIDs = append(delIDs, d)
}
if err := c.writer.DeleteDocLevelForDocs(ctx, tenant, kb, delIDs); err != nil {
return err
}
if err := c.writer.StripMergedSources(ctx, tenant, kb, delIDs); err != nil {
return err
}
}
// --- Completion merge ---
// Load only the per-document products of the completed (and not deleted)
// docs — bounded by this batch, never the whole KB. A doc that is both
// completed and deleted is a stale tombstone: the deletion wins, so we skip
// its completion.
var incoming []kccommon.Product
for _, e := range completed {
if deletedSet[e.DocID] {
continue
}
docProducts, err := c.reader.LoadDocProducts(ctx, tenant, kb, e.DocID)
if err != nil {
return err
}
incoming = append(incoming, docProducts...)
}
// In-memory dedup among the completed batch first.
candidates, err := deduper.Dedup(ctx, incoming)
if err != nil {
return err
}
// Then dedup each candidate against the DocEngine via KNN top1 + LLM judge,
// mirroring Python _struct_doc_storage_dedup_batch. Candidates that KNN-hit
// the same existing merged row are grouped so the row is merged/updated once
// instead of being rewritten per candidate.
type mergeGroup struct {
existing kccommon.Product
candidates []kccommon.Product
score float64
}
groupsByID := make(map[string]*mergeGroup, len(candidates))
var (
unmatchedMu sync.Mutex
unmatched []kccommon.Product
groupsMu sync.Mutex
)
// The KNN pass is docengine-bounded (vector search), not CPU-bounded, so we
// fan it out across the shared global compilerPool (vCPU-sized). Output order
// is irrelevant: merged rows are upserted by their idempotent dataset-level
// id, and each candidate lands in exactly one group / the unmatched set.
jobs := make([]compilerJob, 0, len(candidates))
for _, cand := range candidates {
cand := cand
jobs = append(jobs, func() error {
var vec64 []float64
if len(cand.Vector) > 0 {
vec64 = make([]float64, len(cand.Vector))
for i, v := range cand.Vector {
vec64[i] = float64(v)
}
}
hit, score, err := c.reader.SearchSimilar(ctx, tenant, kb, cand.Variant, vec64, 1, c.mergeThreshold)
if err != nil {
return err
}
if hit.ID == "" {
// No sufficiently-similar merged row: insert the candidate as a new
// merged row.
cand.Merged = true
cand.DocID = kb
unmatchedMu.Lock()
unmatched = append(unmatched, cand)
unmatchedMu.Unlock()
return nil
}
groupsMu.Lock()
g := groupsByID[hit.ID]
if g == nil {
g = &mergeGroup{existing: hit, score: score}
groupsByID[hit.ID] = g
}
g.candidates = append(g.candidates, cand)
groupsMu.Unlock()
return nil
})
}
if err := runCompilerJobs(ctx, jobs); err != nil {
return err
}
// Fold every KNN group into the LLM in a single batch round-trip (one
// DecideBatch call instead of one Decide per pair), then collect the
// updated existing rows and the candidates judged distinct (new rows).
var newMerged []kccommon.Product
if len(groupsByID) > 0 {
batched := make([]MergeGroup, 0, len(groupsByID))
for _, g := range groupsByID {
batched = append(batched, MergeGroup{
Existing: g.existing,
Candidates: g.candidates,
Score: g.score,
})
}
batched, err = deduper.DecideBatch(ctx, batched)
if err != nil {
return err
}
for _, g := range batched {
newMerged = append(newMerged, g.Merged)
unmatched = append(unmatched, g.Distinct...)
}
}
// Write the surviving merged set (updated existing + new distinct rows).
mergedFinal := make([]kccommon.Product, 0, len(newMerged)+len(unmatched))
mergedFinal = append(mergedFinal, newMerged...)
mergedFinal = append(mergedFinal, unmatched...)
if err := c.writer.WriteMerged(ctx, tenant, kb, mergedFinal); err != nil {
return err
}
// All merge and delete paths succeeded: commit the staged per-document seq
// advances and tombstone clears. These are only now persisted so a failed
// batch leaves c.seqs/c.tombs untouched and a later reclaim can retry.
c.mu.Lock()
for _, e := range pendingSeq {
c.seqs[kb][e.DocID] = e.Seq
}
for _, docID := range pendingTombClear {
delete(c.tombs[kb], docID)
}
c.mu.Unlock()
return nil
}