Files
ragflow/internal/ingestion/knowledge_compile/wiki_graph_test.go
Zhichang Yu f12c0ec08a feat(knowledge_compile): materialize wiki page graph (wiki_entity/wiki_relation) (#17976)
Re-materialize wiki page graph from merged wiki_page rows after each
batch merge. Adds ProjectWikiGraph/DropWikiGraph, full page_type/slug
identity, delete-then-insert, tests.
2026-08-07 17:47:59 +08:00

275 lines
9.4 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"
"testing"
)
// page builds a wikiPageProjection with the full "<page_type>/<slug>" identity.
func page(pageType, slug string, outlinks ...string) wikiPageProjection {
return wikiPageProjection{
Slug: pageType + "/" + slug,
PageType: pageType,
Title: slug,
Outlinks: outlinks,
}
}
func findEntity(rows []map[string]interface{}, slug string) map[string]interface{} {
for _, r := range rows {
if r["compile_kwd"] == compileKwdWikiEntity && r["slug_kwd"] == slug {
return r
}
}
return nil
}
func findRelation(rows []map[string]interface{}, from, to string) map[string]interface{} {
for _, r := range rows {
if r["compile_kwd"] == compileKwdWikiRelation && r["from_kwd"] == from && r["to_kwd"] == to {
return r
}
}
return nil
}
const kbForTest = "kb1"
// TestProjectWikiGraphRowsCrossBatchEdgeSurvives verifies a relation whose two
// endpoints land in different "batches" (here modeled as two pages) is still
// materialized: the projection is global over the full page set, so edges never
// dangle due to batching.
func TestProjectWikiGraphRowsCrossBatchEdgeSurvives(t *testing.T) {
w := engineWriter{}
pages := []wikiPageProjection{
page("entity", "alpha", "entity/beta"),
page("entity", "beta"),
}
rows, err := w.projectWikiGraphRows(context.Background(), "t1", kbForTest, pages)
if err != nil {
t.Fatalf("project: %v", err)
}
if findEntity(rows, "entity/alpha") == nil {
t.Fatalf("missing entity entity/alpha")
}
if findEntity(rows, "entity/beta") == nil {
t.Fatalf("missing entity entity/beta")
}
if findRelation(rows, "entity/alpha", "entity/beta") == nil {
t.Fatalf("cross-page edge entity/alpha -> entity/beta not materialized")
}
}
// TestProjectWikiGraphRowsDropAfterReprojection verifies a page removed from the
// set is gone from the reprojected graph: no orphaned entity and no dangling
// relation remain.
func TestProjectWikiGraphRowsDropAfterReprojection(t *testing.T) {
w := engineWriter{}
// First projection has alpha -> beta and beta -> gamma.
first := []wikiPageProjection{
page("entity", "alpha", "entity/beta"),
page("entity", "beta", "entity/gamma"),
page("entity", "gamma"),
}
r1, err := w.projectWikiGraphRows(context.Background(), "t1", kbForTest, first)
if err != nil {
t.Fatalf("project1: %v", err)
}
if findRelation(r1, "entity/beta", "entity/gamma") == nil {
t.Fatalf("expected beta->gamma before drop")
}
// beta is deleted: reproject from alpha + gamma only.
second := []wikiPageProjection{
page("entity", "alpha"), // alpha no longer links anywhere
page("entity", "gamma"),
}
r2, err := w.projectWikiGraphRows(context.Background(), "t1", kbForTest, second)
if err != nil {
t.Fatalf("project2: %v", err)
}
if findEntity(r2, "entity/beta") != nil {
t.Fatalf("beta should be gone after drop")
}
if findRelation(r2, "entity/alpha", "entity/beta") != nil {
t.Fatalf("orphan alpha->beta must not survive after beta dropped")
}
if findRelation(r2, "entity/beta", "entity/gamma") != nil {
t.Fatalf("orphan beta->gamma must not survive after beta dropped")
}
}
// TestProjectWikiGraphRowsZeroOutlinkWeightIsZero verifies a page with no
// outlinks gets weight_int == 0 (Python parity: raw outlink count, 0 allowed).
func TestProjectWikiGraphRowsZeroOutlinkWeightIsZero(t *testing.T) {
w := engineWriter{}
pages := []wikiPageProjection{page("entity", "lonely")}
rows, err := w.projectWikiGraphRows(context.Background(), "t1", kbForTest, pages)
if err != nil {
t.Fatalf("project: %v", err)
}
e := findEntity(rows, "entity/lonely")
if e == nil {
t.Fatalf("missing entity")
}
if wt, ok := e["weight_int"].(int); !ok || wt != 0 {
t.Fatalf("expected weight_int==0 for zero-outlink page, got %v", e["weight_int"])
}
}
// TestProjectWikiGraphRowsWeightEqualsOutlinkCount verifies weight_int equals
// the raw outlink count (N outlinks -> weight N), not max(1, N).
func TestProjectWikiGraphRowsWeightEqualsOutlinkCount(t *testing.T) {
w := engineWriter{}
pages := []wikiPageProjection{
page("entity", "hub", "entity/a", "entity/b", "entity/c"),
page("entity", "a"),
page("entity", "b"),
page("entity", "c"),
}
rows, err := w.projectWikiGraphRows(context.Background(), "t1", kbForTest, pages)
if err != nil {
t.Fatalf("project: %v", err)
}
e := findEntity(rows, "entity/hub")
if e == nil {
t.Fatalf("missing hub entity")
}
if wt, ok := e["weight_int"].(int); !ok || wt != 3 {
t.Fatalf("expected weight_int==3 for 3-outlink page, got %v", e["weight_int"])
}
}
// TestProjectWikiGraphRowsFieldMapping verifies the reader-facing display
// columns are written (aliases_kwd from entity_names_kwd, description_with_weight
// from summary_with_weight), and the id is the xxhash colon-namespaced form.
func TestProjectWikiGraphRowsFieldMapping(t *testing.T) {
w := engineWriter{}
pages := []wikiPageProjection{
{
Slug: "concept/alpha",
PageType: "concept",
Title: "Alpha",
Aliases: []string{"A", "Alpha Prime"},
Summary: "the alpha page",
Outlinks: nil,
SourceDocIDs: []string{"d1", "d2"},
},
}
rows, err := w.projectWikiGraphRows(context.Background(), "t1", kbForTest, pages)
if err != nil {
t.Fatalf("project: %v", err)
}
// Use page_type "concept" (not "entity") so the writer's entity_type_kwd
// mapping ("wiki_"+PageType) is observable — asserting wiki_entity would
// match by coincidence with the compileKwdWikiEntity constant.
e := findEntity(rows, "concept/alpha")
if e == nil {
t.Fatalf("missing entity")
}
if e["aliases_kwd"] == nil {
t.Fatalf("expected aliases_kwd (mapped from entity_names_kwd) to be set")
}
if e["description_with_weight"] != "the alpha page" {
t.Fatalf("expected description_with_weight=summary, got %v", e["description_with_weight"])
}
if e["entity_type_kwd"] != "wiki_concept" {
t.Fatalf("expected entity_type_kwd=wiki_concept, got %v", e["entity_type_kwd"])
}
// id format: wiki_entity:{kb}:{slug} xxhashed to 16 hex chars.
id, _ := e["id"].(string)
if len(id) != 16 {
t.Fatalf("expected 16-char xxhash id, got %q (len %d)", id, len(id))
}
// Recompute and compare against wikiGraphXXHash.
wantID := wikiGraphXXHash("wiki_entity", kbForTest, "concept/alpha")
if id != wantID {
t.Fatalf("id mismatch: got %q want %q", id, wantID)
}
// content_with_weight JSON must round-trip the slug/page_type/title/aliases/summary/weight.
raw, _ := e["content_with_weight"].(string)
var decoded map[string]any
if err := json.Unmarshal([]byte(raw), &decoded); err != nil {
t.Fatalf("content_with_weight not valid JSON: %v", err)
}
if decoded["slug"] != "concept/alpha" || decoded["page_type"] != "concept" {
t.Fatalf("content_with_weight missing slug/page_type: %v", decoded)
}
}
// TestProjectWikiGraphRowsBareSlugNoCollision verifies two pages with the same
// bare slug but different page types get distinct entities (full-slug identity).
func TestProjectWikiGraphRowsBareSlugNoCollision(t *testing.T) {
w := engineWriter{}
pages := []wikiPageProjection{
page("entity", "foo"),
page("concept", "foo"),
}
rows, err := w.projectWikiGraphRows(context.Background(), "t1", kbForTest, pages)
if err != nil {
t.Fatalf("project: %v", err)
}
if findEntity(rows, "entity/foo") == nil || findEntity(rows, "concept/foo") == nil {
t.Fatalf("both entity/foo and concept/foo must exist as distinct entities")
}
e1 := findEntity(rows, "entity/foo")
e2 := findEntity(rows, "concept/foo")
if e1["id"] == e2["id"] {
t.Fatalf("entity/foo and concept/foo must have distinct ids")
}
}
// TestProjectWikiGraphRowsDanglingEdgeSkipped verifies an outlink to a page not
// present in the projection is skipped (no dangling relation).
func TestProjectWikiGraphRowsDanglingEdgeSkipped(t *testing.T) {
w := engineWriter{}
pages := []wikiPageProjection{
page("entity", "alpha", "entity/missing"),
}
rows, err := w.projectWikiGraphRows(context.Background(), "t1", kbForTest, pages)
if err != nil {
t.Fatalf("project: %v", err)
}
if findRelation(rows, "entity/alpha", "entity/missing") != nil {
t.Fatalf("dangling edge to missing target must be skipped")
}
if findEntity(rows, "entity/missing") != nil {
t.Fatalf("missing target must not be materialized as an entity")
}
}
// TestProjectWikiGraphRowsSelfLoopSkipped verifies a page whose outlinks include
// itself does not produce a self-edge (Python parity: src == tgt is skipped).
func TestProjectWikiGraphRowsSelfLoopSkipped(t *testing.T) {
w := engineWriter{}
pages := []wikiPageProjection{
page("entity", "alpha", "entity/alpha"),
}
rows, err := w.projectWikiGraphRows(context.Background(), "t1", kbForTest, pages)
if err != nil {
t.Fatalf("project: %v", err)
}
if findRelation(rows, "entity/alpha", "entity/alpha") != nil {
t.Fatalf("self-loop edge must be skipped (Python parity)")
}
if findEntity(rows, "entity/alpha") == nil {
t.Fatalf("the self-referencing entity must still exist")
}
}