Files
Zhichang Yu 6677f14bdf Port dataset nav and structure graph fixes to Go, merge agents list (#18183)
Fix compilation template config validation for JSONMap; merge template groups into agents list ordered by category/name; install nav service in ingestor; write readable nav cluster/doc names and emit nav_doc leaves;
port tree-to-graph projection and full document structure graph endpoint parity.
2026-08-12 22:46:24 +08:00

357 lines
9.9 KiB
Go

package tree
import (
"bytes"
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"ragflow/internal/ingestion/component/knowledge_compiler/common"
)
// stringsContains reports whether s contains substring sub.
func stringsContains(s, sub string) bool {
return strings.Contains(s, sub)
}
// strSliceContains reports whether s contains v.
func strSliceContains(s []string, v string) bool {
for _, e := range s {
if e == v {
return true
}
}
return false
}
// stringsJoinNonEmpty joins the non-empty parts with sep.
func stringsJoinNonEmpty(parts []string, sep string) string {
var kept []string
for _, p := range parts {
if p != "" {
kept = append(kept, p)
}
}
return strings.Join(kept, sep)
}
// stringMetaSlice coerces a Product.Meta value ([]string or []any of strings)
// into a []string.
func stringMetaSlice(v any) []string {
switch x := v.(type) {
case []string:
return x
case []any:
out := make([]string, 0, len(x))
for _, e := range x {
if s, ok := e.(string); ok && s != "" {
out = append(out, s)
}
}
return out
}
return nil
}
// payloadChunkIDs extracts the source_chunk_ids from a tree-graph payload.
func payloadChunkIDs(payload map[string]any) []string {
switch v := payload["source_chunk_ids"].(type) {
case []string:
return v
case []any:
var out []string
for _, e := range v {
if s, ok := e.(string); ok && s != "" {
out = append(out, s)
}
}
return out
}
return nil
}
// payloadDescription is the embedding input for a tree-graph entity/relation:
// the concatenated string values of every field except description (lists
// flattened), matching Python _struct_payload_description.
func payloadDescription(payload map[string]any) string {
keys := make([]string, 0, len(payload))
for k := range payload {
if k != "description" {
keys = append(keys, k)
}
}
sort.Strings(keys)
var parts []string
for _, k := range keys {
switch v := payload[k].(type) {
case string:
if v != "" {
parts = append(parts, v)
}
case []string:
for _, e := range v {
if e != "" {
parts = append(parts, e)
}
}
case []any:
for _, e := range v {
if s, ok := e.(string); ok && s != "" {
parts = append(parts, s)
}
}
}
}
return strings.Join(parts, " ")
}
// payloadJSON serialises a payload the way Python's json.dumps(ensure_ascii=
// False) does (no HTML escaping), with alphabetically sorted keys for a
// canonical, hash-stable form.
func payloadJSON(payload map[string]any) string {
var b bytes.Buffer
enc := json.NewEncoder(&b)
enc.SetEscapeHTML(false)
if err := enc.Encode(payload); err != nil {
return "{}"
}
return strings.TrimSpace(b.String())
}
// graphNode mirrors Python's RAPTOR tree node dict (title/description/children/
// source_chunk_ids) reconstructed from the flat products emitted by buildTree,
// so the tree can be projected to a {entities, relations} graph exactly like
// Python's raptor_tree_to_graph (chunk_post_processor.py:470).
type graphNode struct {
title string
description string
sourceChunkIDs []string
children []*graphNode
}
// collapseUnary merges a node that wraps exactly one child into that child,
// mirroring Python raptor_tree_to_graph._collapse_unary: the parent's and the
// child's descriptions/source-chunk-ids are concatenated (dedup'd), then the
// collapsed node adopts the child's children.
func collapseUnary(node *graphNode) *graphNode {
collapsed := &graphNode{
title: node.title,
description: node.description,
sourceChunkIDs: node.sourceChunkIDs,
}
for _, c := range node.children {
collapsed.children = append(collapsed.children, collapseUnary(c))
}
for len(collapsed.children) == 1 {
child := collapsed.children[0]
parentTitle := collapsed.title
childTitle := child.title
parentDesc := collapsed.description
if parentDesc == "" {
parentDesc = parentTitle
}
childDesc := child.description
if childDesc == "" {
childDesc = childTitle
}
var descriptions []string
descriptions = append(descriptions, parentDesc)
if childTitle != "" && childTitle != parentTitle &&
!stringsContains(childDesc, childTitle) {
descriptions = append(descriptions, childTitle)
}
if childDesc != "" && !strSliceContains(descriptions, childDesc) {
descriptions = append(descriptions, childDesc)
}
sourceChunkIDs := append([]string{}, collapsed.sourceChunkIDs...)
for _, id := range child.sourceChunkIDs {
if id != "" && !strSliceContains(sourceChunkIDs, id) {
sourceChunkIDs = append(sourceChunkIDs, id)
}
}
collapsed.description = stringsJoinNonEmpty(descriptions, "\n\n")
collapsed.sourceChunkIDs = sourceChunkIDs
collapsed.children = child.children
}
return collapsed
}
// raptorTreeToGraph projects a RAPTOR tree onto {entities, relations}, matching
// Python raptor_tree_to_graph: every node becomes an entity of type "tree_node";
// every parent→child edge (that is not a self-loop) becomes a "child" relation.
func raptorTreeToGraph(root *graphNode) ([]map[string]any, []map[string]any) {
var entities []map[string]any
var relations []map[string]any
var walk func(node *graphNode, parentTitle string)
walk = func(node *graphNode, parentTitle string) {
if node == nil {
return
}
title := node.title
ent := map[string]any{
"name": title,
"type": "tree_node",
"description": firstNonEmpty(node.description, title),
"mention_count": 1,
}
if len(node.sourceChunkIDs) > 0 {
ent["source_chunk_ids"] = node.sourceChunkIDs
}
entities = append(entities, ent)
if parentTitle != "" && parentTitle != title {
relations = append(relations, map[string]any{
"from": parentTitle,
"to": title,
"type": "child",
})
}
for _, child := range node.children {
walk(child, title)
}
}
walk(root, "")
return entities, relations
}
// buildTreeGraph reconstructs the tree from the flat summary products and
// produces the entity/relation/graph products Python writes for a tree variant
// (_struct_upsert_tree_graph_rows + _struct_upsert_graph_json):
// - one entity product per tree node (kind "entity", knowledge_graph_kwd via
// the writer), type "tree_node";
// - one relation product per parent→child edge (kind "relation");
// - one compact graph blob product (kind "graph") carrying the whole
// {entities, relations} projection, which is the /structure/graph discovery
// row (Python scans knowledge_graph_kwd="graph").
//
// templateID is stamped into each row so the document-structure endpoint can
// group by template id; compileKWD is "tree".
func buildTreeGraph(ctx context.Context, deps common.Deps, docID string, products []common.Product) ([]common.Product, error) {
if deps.Embed == nil {
return nil, fmt.Errorf("tree: embedder required to build the tree graph")
}
root := reconstructTree(products)
if root == nil {
// No root summary survived; there is no tree to project.
return nil, nil
}
root = collapseUnary(root)
entities, relations := raptorTreeToGraph(root)
var out []common.Product
var descs []string
var payloads []map[string]any
var kinds []string
for _, ent := range entities {
descs = append(descs, payloadDescription(ent))
payloads = append(payloads, ent)
kinds = append(kinds, "entity")
}
for _, rel := range relations {
descs = append(descs, payloadDescription(rel))
payloads = append(payloads, rel)
kinds = append(kinds, "relation")
}
vecs, err := deps.Embed.Encode(ctx, descs)
if err != nil {
return nil, err
}
for i, payload := range payloads {
kind := kinds[i]
var vec []float32
if i < len(vecs) {
vec = vecs[i]
}
meta := map[string]any{
"kind": kind,
"compile_kwd": "tree",
"source_chunk_ids": payloadChunkIDs(payload),
"mention_count": 1,
}
if kind == "entity" {
if name, ok := payload["name"].(string); ok && name != "" {
meta["name"] = name
}
if typ, ok := payload["type"].(string); ok && typ != "" {
meta["entity_type"] = typ
} else {
meta["entity_type"] = "other"
}
} else {
if from, ok := payload["from"].(string); ok {
meta["from"] = from
}
if to, ok := payload["to"].(string); ok {
meta["to"] = to
}
}
out = append(out, common.Product{
ID: common.StableRowID(payloadJSON(payload), docID),
DocID: docID,
TenantID: deps.TenantID,
Variant: common.VariantTree,
Content: payloadJSON(payload),
Vector: vec,
Meta: meta,
})
}
// Compact graph blob discovery row (knowledge_graph_kwd="graph").
graph := map[string]any{"entities": entities, "relations": relations}
graphContent := payloadJSON(graph)
graphVecs, err := deps.Embed.Encode(ctx, []string{graphContent})
if err != nil {
return nil, err
}
var gv []float32
if len(graphVecs) > 0 {
gv = graphVecs[0]
}
out = append(out, common.Product{
ID: common.StableRowID(docID, "tree", "structure_graph"),
DocID: docID,
TenantID: deps.TenantID,
Variant: common.VariantTree,
Content: graphContent,
Vector: gv,
Meta: map[string]any{
"kind": "graph",
"compile_kwd": "tree",
},
})
return out, nil
}
// reconstructTree assembles a graphNode tree from the flat summary products:
// the root has Meta.kind=="root"; every other node's parent is the product with
// ID == node.ParentID. Node title comes from Meta.title, description from
// Content, source chunk ids from Meta.source_chunk_ids.
func reconstructTree(products []common.Product) *graphNode {
byID := make(map[string]*graphNode, len(products))
for _, p := range products {
title, _ := p.Meta["title"].(string)
byID[p.ID] = &graphNode{
title: title,
description: p.Content,
sourceChunkIDs: stringMetaSlice(p.Meta["source_chunk_ids"]),
}
}
var root *graphNode
for _, p := range products {
kind, _ := p.Meta["kind"].(string)
node := byID[p.ID]
if kind == "root" {
root = node
continue
}
if parent := byID[p.ParentID]; parent != nil {
parent.children = append(parent.children, node)
}
}
return root
}