mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-04 23:00:30 +08:00
## 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>
442 lines
14 KiB
Go
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
|
|
}
|