Files
ragflow/internal/ingestion/component/knowledge_compiler/wiki/wiki_test.go
Zhichang Yu 2e37997ab9 Go knowledge compiler with scheduler-driven dataset compilation (#17913)
Ports dataset knowledge compilation (wiki/graph/tree/mindmap) to the Go
scheduler with a status contract, aligns wiki storage/retrieval with
Python, sizes prompts by content_length, and resolves embedding batch
size from provider capability.
2026-08-06 15:54:00 +08:00

356 lines
11 KiB
Go

package wiki
import (
"context"
"strings"
"sync"
"testing"
"time"
"ragflow/internal/ingestion/component/knowledge_compiler/common"
)
func TestReduceExtracts_MergesProvenance(t *testing.T) {
reduced := reduceExtracts([]wikiExtract{
{
Entities: []wikiEntity{{Name: "Alpha", Type: "thing", SourceChunkIDs: []string{"c1"}}},
Claims: []wikiClaim{{Statement: "Alpha exists", Subject: "Alpha", SourceChunkIDs: []string{"c1"}}},
},
{
Entities: []wikiEntity{{Name: "Alpha", Type: "thing", SourceChunkIDs: []string{"c2"}}},
Claims: []wikiClaim{{Statement: "Alpha exists", Subject: "Alpha", SourceChunkIDs: []string{"c2"}}},
},
})
if len(reduced.Entities) != 1 {
t.Fatalf("entities=%d, want 1", len(reduced.Entities))
}
if ids := reduced.Entities[0].SourceChunkIDs; len(ids) != 2 {
t.Fatalf("entity provenance = %#v, want 2 chunk ids", ids)
}
if len(reduced.Claims) != 2 {
t.Fatalf("claims=%d, want 2", len(reduced.Claims))
}
}
func TestPackWikiPlanBatches_SplitsLargeInput(t *testing.T) {
reduced := wikiExtract{
Entities: []wikiEntity{
{Name: strings.Repeat("a", 1000)},
{Name: strings.Repeat("b", 1000)},
{Name: strings.Repeat("c", 1000)},
},
}
batches := packWikiPlanBatches(reduced, 1)
if len(batches) < 2 {
t.Fatalf("expected multiple batches, got %d", len(batches))
}
}
// TestWikiMapMaxTokens_OutputBudgetTracksInputBudget locks the input/output
// budget coupling: the extraction MaxTokens must leave at least the whole
// wikiMapTokenBudget input budget of headroom and, with a roomy model, give the
// output the rest of the context window after the batch's input is reserved.
func TestWikiMapMaxTokens_OutputBudgetTracksInputBudget(t *testing.T) {
// Unknown model context -> default window (DefaultLLMContextLength). Output
// gets the whole window minus the input budget.
got := wikiMapMaxTokens(0)
if want := common.DefaultLLMContextLength - wikiMapTokenBudget; got != want {
t.Fatalf("wikiMapMaxTokens(0) = %d, want %d", got, want)
}
// A model window that barely fits one batch must still grant at least the
// input budget of output space (never starve the output).
if got := wikiMapMaxTokens(2048); got != wikiMapTokenBudget {
t.Fatalf("wikiMapMaxTokens(2048) = %d, want %d (floor at input budget)", got, wikiMapTokenBudget)
}
// A roomy model: output = window - input budget.
if got := wikiMapMaxTokens(16384); got != 16384-wikiMapTokenBudget {
t.Fatalf("wikiMapMaxTokens(16384) = %d, want %d", got, 16384-wikiMapTokenBudget)
}
}
func TestRunMapBatches_PreservesBatchOrderWithSubmitter(t *testing.T) {
previous := batchSubmitter
defer SetBatchSubmitter(previous)
SetBatchSubmitter(func(ctx context.Context, jobs []func() error) error {
var wg sync.WaitGroup
errs := make(chan error, len(jobs))
for _, job := range jobs {
job := job
wg.Add(1)
go func() {
defer wg.Done()
errs <- job()
}()
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
return err
}
}
return ctx.Err()
})
batches := [][]common.Chunk{
{{ID: "slow", Text: "slow"}},
{{ID: "fast-1", Text: "fast-1"}},
{{ID: "fast-2", Text: "fast-2"}},
}
got, err := runMapBatches(context.Background(), batches, func(batch []common.Chunk) (wikiExtract, error) {
if batch[0].ID == "slow" {
time.Sleep(25 * time.Millisecond)
}
return wikiExtract{Topics: []string{batch[0].ID}}, nil
})
if err != nil {
t.Fatalf("runMapBatches err = %v", err)
}
if len(got) != len(batches) {
t.Fatalf("runMapBatches len = %d, want %d", len(got), len(batches))
}
for i, want := range []string{"slow", "fast-1", "fast-2"} {
if len(got[i].Topics) != 1 || got[i].Topics[0] != want {
t.Fatalf("runMapBatches[%d] = %#v, want topic %q", i, got[i], want)
}
}
}
func TestBuildSourceContext_SelectsKnownChunks(t *testing.T) {
ctx := buildSourceContext([]common.Chunk{
{ID: "c1", Text: "alpha text"},
{ID: "c2", Text: "beta text"},
{ID: "c3", Text: "gamma text"},
}, []string{"c2", "c3"})
if strings.Contains(ctx, "alpha text") {
t.Fatalf("source context leaked unselected chunk: %q", ctx)
}
if !strings.Contains(ctx, "beta text") || !strings.Contains(ctx, "gamma text") {
t.Fatalf("source context missing selected chunks: %q", ctx)
}
}
func TestNormalizeWikiPlanPages_FallbacksToEntitiesAndConcepts(t *testing.T) {
plan := normalizeWikiPlan(wikiPlan{}, "doc-1", wikiExtract{
Entities: []wikiEntity{{Name: "Alpha", Aliases: []string{"A"}}},
Concepts: []wikiConcept{{Term: "Beta"}},
})
if len(plan.Pages) < 2 {
t.Fatalf("normalizeWikiPlan generated %d pages, want at least 2", len(plan.Pages))
}
if plan.Pages[0].Slug == "" || plan.Pages[1].Slug == "" {
t.Fatalf("fallback pages missing slugs: %#v", plan.Pages)
}
}
func TestMergePlanCandidates_DeduplicatesWithoutLLMMerge(t *testing.T) {
p := &wikiPipeline{
docID: "doc-1",
reduced: wikiExtract{
Entities: []wikiEntity{{Name: "Alpha"}},
},
}
merged := p.mergePlanCandidates([]wikiPlan{
{
Title: "Alpha",
Pages: []wikiPlanPage{
{
Slug: "entity/alpha",
Title: "Alpha",
PageType: "entity",
Topic: "Alpha",
EntityNames: []string{"Alpha"},
RelatedKB: []string{"entity/beta", "missing", "entity/alpha"},
Priority: 2,
},
},
},
{
Pages: []wikiPlanPage{
{
Slug: "entity/beta",
Title: "Beta",
PageType: "entity",
Topic: "Beta",
EntityNames: []string{"Beta"},
RelatedKB: []string{"entity/alpha"},
Priority: 1,
},
{
Slug: "entity/alpha",
Title: "Alpha duplicate",
PageType: "entity",
Topic: "Alpha",
EntityNames: []string{"Alpha"},
Priority: 3,
},
},
},
}, p.reduced)
if len(merged.Pages) != 2 {
t.Fatalf("merged pages = %d, want 2", len(merged.Pages))
}
if merged.Pages[0].Slug != "entity/beta" || merged.Pages[1].Slug != "entity/alpha" {
t.Fatalf("merged page order = %#v", merged.Pages)
}
if got := merged.Pages[1].RelatedKB; len(got) != 1 || got[0] != "entity/beta" {
t.Fatalf("alpha related links = %#v, want [entity/beta]", got)
}
}
type reconcileChatStub struct {
resp string
}
func (s reconcileChatStub) Chat(_ context.Context, req common.ChatRequest) (*common.ChatResponse, error) {
return &common.ChatResponse{Content: s.resp}, nil
}
type reconcileEmbedStub struct{}
func (reconcileEmbedStub) Encode(_ context.Context, texts []string) ([][]float32, error) {
out := make([][]float32, len(texts))
for i := range texts {
out[i] = []float32{0.1, 0.2, 0.3}
}
return out, nil
}
func (reconcileEmbedStub) Dimensions() int { return 3 }
type wikiStoreStub struct {
slugHit *common.WikiPageCandidate
similar []common.WikiPageCandidate
}
func (s wikiStoreStub) FindSimilarPages(_ context.Context, _, _ string, _ []float32, _ int) ([]common.WikiPageCandidate, error) {
return s.similar, nil
}
func (s wikiStoreStub) GetPageBySlug(_ context.Context, _, _, slug string) (*common.WikiPageCandidate, error) {
if s.slugHit != nil && s.slugHit.Slug == slug {
return s.slugHit, nil
}
return nil, nil
}
func TestReconcilePlanPage_MaybeUsesLLMDecision(t *testing.T) {
p := &wikiPipeline{
ctx: context.Background(),
tenantID: "t1",
datasetID: "kb1",
llmID: "llm1",
deps: common.Deps{
Chat: reconcileChatStub{resp: `{"action":"UPDATE","slug":"entity/existing","reason":"same entity"}`},
Embed: reconcileEmbedStub{},
WikiPages: wikiStoreStub{similar: []common.WikiPageCandidate{{Slug: "entity/existing", Title: "Existing", Score: 0.81}}},
},
}
got, err := p.reconcilePlanPage(wikiPlanPage{
Slug: "entity/new-alpha",
Title: "Alpha",
PageType: "entity",
Topic: "Alpha",
EntityNames: []string{"Alpha Prime"},
}, []float32{0.1, 0.2, 0.3})
if err != nil {
t.Fatalf("reconcilePlanPage err = %v", err)
}
if got == nil || got.Slug != "entity/existing" {
t.Fatalf("reconcilePlanPage = %#v, want entity/existing", got)
}
}
// TestReconcilePlanPage_OverlapHeuristicSkipsLLM locks the Go-only enhancement:
// a candidate whose score is inside [maybe, update) but whose topic matches the
// planned page's topic is promoted straight to UPDATE without an LLM round.
func TestReconcilePlanPage_OverlapHeuristicSkipsLLM(t *testing.T) {
called := false
p := &wikiPipeline{
ctx: context.Background(),
tenantID: "t1",
datasetID: "kb1",
llmID: "llm1",
deps: common.Deps{
Chat: chatFunc(func(_ context.Context, _ common.ChatRequest) (*common.ChatResponse, error) {
called = true
return &common.ChatResponse{Content: `{"action":"CREATE"}`}, nil
}),
Embed: reconcileEmbedStub{},
WikiPages: wikiStoreStub{similar: []common.WikiPageCandidate{{Slug: "topic/alpha", Title: "Alpha topic", Topic: "Alpha", Score: 0.85}}},
},
}
got, err := p.reconcilePlanPage(wikiPlanPage{
Slug: "topic/alpha-new",
Title: "Alpha Topic",
PageType: "topic",
Topic: "Alpha",
}, []float32{0.1, 0.2, 0.3})
if err != nil {
t.Fatalf("reconcilePlanPage err = %v", err)
}
if got == nil || got.Slug != "topic/alpha" {
t.Fatalf("reconcilePlanPage = %#v, want topic/alpha (topic overlap promotes to UPDATE)", got)
}
if called {
t.Fatalf("overlap heuristic should not invoke the LLM")
}
}
type chatFunc func(context.Context, common.ChatRequest) (*common.ChatResponse, error)
func (f chatFunc) Chat(ctx context.Context, req common.ChatRequest) (*common.ChatResponse, error) {
return f(ctx, req)
}
func TestReconcilePlanPage_LowScoreSkipsLLM(t *testing.T) {
p := &wikiPipeline{
ctx: context.Background(),
tenantID: "t1",
datasetID: "kb1",
llmID: "llm1",
deps: common.Deps{
Chat: reconcileChatStub{resp: `{"action":"UPDATE","slug":"entity/existing","reason":"same entity"}`},
Embed: reconcileEmbedStub{},
WikiPages: wikiStoreStub{similar: []common.WikiPageCandidate{{Slug: "entity/existing", Title: "Existing", Score: 0.6}}},
},
}
got, err := p.reconcilePlanPage(wikiPlanPage{
Slug: "entity/new-alpha",
Title: "Alpha",
PageType: "entity",
Topic: "Alpha",
EntityNames: []string{"Alpha Prime"},
}, []float32{0.1, 0.2, 0.3})
if err != nil {
t.Fatalf("reconcilePlanPage err = %v", err)
}
if got != nil {
t.Fatalf("reconcilePlanPage = %#v, want nil", got)
}
}
func TestMergeWikiPageContent_PreservesShortExistingPage(t *testing.T) {
p := &wikiPipeline{
ctx: context.Background(),
deps: common.Deps{
Chat: reconcileChatStub{resp: "# Alpha\n\nAlpha launched a new process in 2026.\n"},
},
}
merged, err := p.mergeWikiPageContent(
"# Alpha\n\nExisting fact.\n",
"# Alpha\n\nAlpha launched a new process in 2026.\n",
"entity/alpha",
)
if err != nil {
t.Fatalf("mergeWikiPageContent err = %v", err)
}
if !strings.Contains(merged, "Existing fact.") {
t.Fatalf("merged page dropped existing content: %q", merged)
}
if !strings.Contains(merged, "Alpha launched a new process in 2026.") {
t.Fatalf("merged page dropped incoming content: %q", merged)
}
}