mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-01 21:37:33 +08:00
Ports doc-level auto-metadata extraction to Go and adds the knowledge_compiler component with scheduler/routing. Fixes Extractor metadata injection type assertion and enable_metadata default-on.
562 lines
18 KiB
Go
562 lines
18 KiB
Go
package structure
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
|
|
"ragflow/internal/ingestion/component/knowledge_compiler/common"
|
|
)
|
|
|
|
// Merge prompts, verbatim from Python structure.py. The merge is driven by
|
|
// the user-supplied prompts; the decision instruction lets us branch on the
|
|
// LLM's verdict via GenJSON.
|
|
const mergeSystemPrompt = `You are an intelligent data merging assistant.
|
|
You will merge two JSON objects representing the same entity: Item A (existing) and Item B (incoming).
|
|
|
|
Merge strategy:
|
|
1. Combine information from both items.
|
|
2. If fields conflict, use your best judgment to pick the more detailed or recent-looking value.
|
|
3. If one item has a null/missing value and the other has data, keep the data.
|
|
4. For list fields, combine unique elements from both.
|
|
5. Do not invent new information not present in the inputs.
|
|
6. Return the result in the exact JSON format of the input items.`
|
|
|
|
const mergeUserPrompt = "Item A (existing):\n%s\n\nItem B (incoming):\n%s"
|
|
|
|
const mergeDecisionInstruction = `First decide whether Item A and Item B refer to the same logical entity (for entities) or the same logical relation (for relations). Use the merge strategy above only if they are the same.
|
|
|
|
Return ONLY a JSON object with this exact structure (no markdown fences, no commentary):
|
|
{
|
|
"duplicated": <true | false>,
|
|
"merged": <merged JSON object using the same keys as the inputs when duplicated=true; otherwise null>
|
|
}`
|
|
|
|
// mergeJudgeTemperature mirrors Python's gen_conf for merge judging
|
|
// (_struct_merge_pair uses temperature 0.0).
|
|
var mergeJudgeTemperature = 0.0
|
|
|
|
// MergeDecision is the variant-level decision a MergeDecider returns for one
|
|
// incoming product against its best in-run match.
|
|
type MergeDecision int
|
|
|
|
const (
|
|
// DecisionKeepBoth keeps the incoming product as a new entry.
|
|
DecisionKeepBoth MergeDecision = iota
|
|
// DecisionDropIncoming discards the incoming product (duplicate).
|
|
DecisionDropIncoming
|
|
// DecisionMerge replaces the existing entry with the returned product
|
|
// (identity preserved via the existing id).
|
|
DecisionMerge
|
|
)
|
|
|
|
// MergeDecider decides how to treat an incoming product given its best
|
|
// in-run cosine match. When it returns DecisionMerge it also returns the
|
|
// merged replacement product; for other decisions the product is ignored.
|
|
type MergeDecider interface {
|
|
Decide(ctx context.Context, existing, incoming common.Product, bestScore float64) (MergeDecision, common.Product, error)
|
|
}
|
|
|
|
// CosineDecider drops the incoming product when its best cosine match meets
|
|
// (>=) Threshold. It performs no LLM call, so it is safe under the store lock.
|
|
type CosineDecider struct{ Threshold float64 }
|
|
|
|
func (d CosineDecider) Decide(_ context.Context, _, _ common.Product, bestScore float64) (MergeDecision, common.Product, error) {
|
|
if bestScore >= d.Threshold {
|
|
return DecisionDropIncoming, common.Product{}, nil
|
|
}
|
|
return DecisionKeepBoth, common.Product{}, nil
|
|
}
|
|
|
|
// LLMMergeDecider mirrors Python's local-dedup judge (_struct_merge_pair):
|
|
// pairs above Threshold are adjudicated by the LLM; on a duplicated verdict
|
|
// the merged payload replaces the existing row (re-embedded, provenance
|
|
// unioned, id preserved). Entity merges record aliases so relation endpoints
|
|
// can be rewritten afterwards (mirrors _struct_local_dedup's entity_aliases).
|
|
type LLMMergeDecider struct {
|
|
Chat common.ChatInvoker
|
|
LLMID string
|
|
Embed common.Embedder
|
|
Threshold float64
|
|
|
|
mu sync.Mutex
|
|
aliases map[string]string
|
|
}
|
|
|
|
// NewLLMMergeDecider constructs the decider used by the structure variant.
|
|
func NewLLMMergeDecider(chat common.ChatInvoker, llmID string, embed common.Embedder, threshold float64) *LLMMergeDecider {
|
|
return &LLMMergeDecider{Chat: chat, LLMID: llmID, Embed: embed, Threshold: threshold, aliases: map[string]string{}}
|
|
}
|
|
|
|
// Decide implements MergeDecider.
|
|
func (d *LLMMergeDecider) Decide(ctx context.Context, existing, incoming common.Product, bestScore float64) (MergeDecision, common.Product, error) {
|
|
if bestScore < d.Threshold {
|
|
return DecisionKeepBoth, common.Product{}, nil
|
|
}
|
|
merged, err := mergePair(ctx, d.Chat, d.LLMID, existing.Content, incoming.Content)
|
|
if err != nil {
|
|
return DecisionKeepBoth, common.Product{}, err
|
|
}
|
|
if merged == nil {
|
|
return DecisionKeepBoth, common.Product{}, nil
|
|
}
|
|
|
|
kind, _ := existing.Meta["kind"].(string)
|
|
if kind == "entity" {
|
|
oldName := entityNameValue(existing)
|
|
incomingName := entityNameValue(incoming)
|
|
canonical := entityName(merged)
|
|
if canonical == "" {
|
|
canonical = oldName
|
|
}
|
|
for _, alias := range []string{oldName, incomingName} {
|
|
if alias != "" && alias != canonical {
|
|
d.recordAlias(alias, canonical)
|
|
}
|
|
}
|
|
}
|
|
merged = applyMergeInvariants(existing, merged)
|
|
|
|
chunkIDs := unionOrdered(metaStrings(existing.Meta, "source_chunk_ids"), metaStrings(incoming.Meta, "source_chunk_ids"))
|
|
texts := []string{payloadDescription(merged)}
|
|
vecs, err := d.Embed.Encode(ctx, texts)
|
|
if err != nil {
|
|
return DecisionKeepBoth, common.Product{}, err
|
|
}
|
|
if len(vecs) == 0 {
|
|
return DecisionKeepBoth, common.Product{}, fmt.Errorf("knowledge_compiler: re-embed of merged payload returned no vector")
|
|
}
|
|
|
|
meta := map[string]any{}
|
|
for k, v := range existing.Meta {
|
|
meta[k] = v
|
|
}
|
|
meta["source_chunk_ids"] = chunkIDs
|
|
refreshMetaFromPayload(meta, kind, merged)
|
|
replacement := common.Product{
|
|
ID: existing.ID,
|
|
DocID: existing.DocID,
|
|
TenantID: existing.TenantID,
|
|
Variant: existing.Variant,
|
|
Content: payloadJSON(merged),
|
|
Vector: vecs[0],
|
|
Meta: meta,
|
|
}
|
|
return DecisionMerge, replacement, nil
|
|
}
|
|
|
|
// recordAlias adds one alias→canonical mapping (thread-safe).
|
|
func (d *LLMMergeDecider) recordAlias(alias, canonical string) {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
d.aliases[alias] = canonical
|
|
}
|
|
|
|
// Aliases returns the accumulated alias→canonical map (a copy).
|
|
func (d *LLMMergeDecider) Aliases() map[string]string {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
out := make(map[string]string, len(d.aliases))
|
|
for k, v := range d.aliases {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
// mergePair mirrors _struct_merge_pair: LLM-judged merge of two payload JSON
|
|
// documents. It returns the merged payload when the pair is a duplicate, nil
|
|
// when not (including unparseable inputs, which Python logs and skips).
|
|
func mergePair(ctx context.Context, chat common.ChatInvoker, llmID, existingContent, incomingContent string) (map[string]any, error) {
|
|
existingPayload := parsePayload(existingContent)
|
|
incomingPayload := parsePayload(incomingContent)
|
|
if existingPayload == nil || incomingPayload == nil {
|
|
return nil, nil
|
|
}
|
|
res, err := common.GenJSON(ctx, chat, common.ChatRequest{
|
|
LLMID: llmID,
|
|
SystemPrompt: mergeSystemPrompt + "\n\n" + mergeDecisionInstruction,
|
|
UserPrompt: fmt.Sprintf(mergeUserPrompt, payloadJSON(existingPayload), payloadJSON(incomingPayload)),
|
|
Temperature: &mergeJudgeTemperature,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if dup, _ := res["duplicated"].(bool); !dup {
|
|
return nil, nil
|
|
}
|
|
merged, ok := res["merged"].(map[string]any)
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
return merged, nil
|
|
}
|
|
|
|
// applyMergeInvariants mirrors _struct_apply_merge_invariants: for relations,
|
|
// force the source/target fields back to the existing payload's values —
|
|
// from_entity_kwd / to_entity_kwd must not change across a merge.
|
|
func applyMergeInvariants(existing common.Product, mergedPayload map[string]any) map[string]any {
|
|
kind, _ := existing.Meta["kind"].(string)
|
|
if kind != "relation" {
|
|
return mergedPayload
|
|
}
|
|
existingPayload := parsePayload(existing.Content)
|
|
if existingPayload == nil {
|
|
return mergedPayload
|
|
}
|
|
for _, field := range []string{"source", "src", "from"} {
|
|
if v, ok := existingPayload[field]; ok {
|
|
mergedPayload[field] = v
|
|
}
|
|
}
|
|
for _, field := range []string{"target", "tgt", "to"} {
|
|
if v, ok := existingPayload[field]; ok {
|
|
mergedPayload[field] = v
|
|
}
|
|
}
|
|
return mergedPayload
|
|
}
|
|
|
|
// resolveAlias mirrors _struct_resolve_entity_alias: follow the alias chain
|
|
// until a non-aliased name, tolerating cycles (stop at the first repeat).
|
|
func resolveAlias(name string, aliases map[string]string) string {
|
|
current := strings.TrimSpace(name)
|
|
seen := map[string]bool{}
|
|
for {
|
|
next, ok := aliases[current]
|
|
if !ok || seen[current] {
|
|
return current
|
|
}
|
|
seen[current] = true
|
|
current = next
|
|
}
|
|
}
|
|
|
|
// rewriteRelationPayload mirrors _struct_rewrite_relation_payload: resolve
|
|
// the relation's endpoint fields through the alias map in place. Returns
|
|
// whether anything changed.
|
|
func rewriteRelationPayload(payload map[string]any, aliases map[string]string) bool {
|
|
changed := false
|
|
for _, fields := range [][]string{{"source", "src", "from"}, {"target", "tgt", "to"}} {
|
|
for _, field := range fields {
|
|
v, ok := payload[field]
|
|
if !ok || v == nil {
|
|
continue
|
|
}
|
|
old := strings.TrimSpace(stringOf(v))
|
|
if old == "" {
|
|
continue
|
|
}
|
|
if newName := resolveAlias(old, aliases); newName != old {
|
|
payload[field] = newName
|
|
changed = true
|
|
}
|
|
}
|
|
}
|
|
return changed
|
|
}
|
|
|
|
// refreshMetaFromPayload re-derives the name/type/endpoint meta keys of a
|
|
// merged row from its new payload (mirrors the column re-derivation in
|
|
// _struct_rebuild_doc_storage_doc).
|
|
func refreshMetaFromPayload(meta map[string]any, kind string, payload map[string]any) {
|
|
if kind == "entity" {
|
|
if name := entityName(payload); name != "" {
|
|
meta["name"] = name
|
|
} else {
|
|
delete(meta, "name")
|
|
}
|
|
if typ := strings.TrimSpace(stringOf(payload["type"])); typ != "" {
|
|
meta["entity_type"] = typ
|
|
}
|
|
if desc := strings.TrimSpace(stringOf(payload["description"])); desc != "" {
|
|
meta["description"] = desc
|
|
}
|
|
return
|
|
}
|
|
if from := relationEndpoint(payload, "", "source", "src", "from"); from != "" {
|
|
meta["from"] = from
|
|
}
|
|
if to := relationEndpoint(payload, "", "target", "tgt", "to"); to != "" {
|
|
meta["to"] = to
|
|
}
|
|
if typ := strings.TrimSpace(stringOf(payload["type"])); typ != "" {
|
|
meta["relation_type"] = typ
|
|
}
|
|
}
|
|
|
|
// entityNameValue reads an entity row's canonical name (payload first, then
|
|
// the meta stamp), mirroring _struct_entity_name.
|
|
func entityNameValue(p common.Product) string {
|
|
if payload := parsePayload(p.Content); payload != nil {
|
|
if name := entityName(payload); name != "" {
|
|
return name
|
|
}
|
|
}
|
|
name, _ := p.Meta["name"].(string)
|
|
return strings.TrimSpace(name)
|
|
}
|
|
|
|
// metaStrings reads a []string meta value (tolerating []any).
|
|
func metaStrings(meta map[string]any, key string) []string {
|
|
switch v := meta[key].(type) {
|
|
case []string:
|
|
return v
|
|
case []any:
|
|
out := make([]string, 0, len(v))
|
|
for _, e := range v {
|
|
if s, ok := e.(string); ok {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// unionOrdered mirrors _common.union_ordered: concatenated, deduped,
|
|
// first-seen order preserved.
|
|
func unionOrdered(lists ...[]string) []string {
|
|
seen := map[string]bool{}
|
|
var out []string
|
|
for _, lst := range lists {
|
|
for _, v := range lst {
|
|
if v == "" || seen[v] {
|
|
continue
|
|
}
|
|
seen[v] = true
|
|
out = append(out, v)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// MergeStats accumulates dedup outcomes across a run.
|
|
type MergeStats struct {
|
|
Inserted int
|
|
Updated int
|
|
DuplicatesDropped int
|
|
}
|
|
|
|
// MergeIntoStore folds the incoming rows into store, using DedupeAdd so the
|
|
// check-then-act is atomic and the decider (which may call the LLM) runs
|
|
// outside the store lock. It returns counts of inserted/merged/dropped rows.
|
|
//
|
|
// When a row is dropped as a duplicate, its source_chunk_ids are still merged
|
|
// into the surviving entry's source_chunk_ids (order-preserving union), so an
|
|
// entity that appears across multiple chunks accumulates provenance from all
|
|
// of them — mirroring Python's _struct_merge_graph_entities.
|
|
func MergeIntoStore(ctx context.Context, store *common.MemStore, decider MergeDecider, rows []common.Product) (MergeStats, error) {
|
|
var stats MergeStats
|
|
for _, row := range rows {
|
|
var droppedExisting common.Product
|
|
var dropped bool
|
|
action, err := store.DedupeAdd(row, 0, func(existing common.Product, score float64) (common.KeepAction, common.Product, error) {
|
|
d, replacement, e := decider.Decide(ctx, existing, row, score)
|
|
if e != nil {
|
|
return common.KeepAdd, common.Product{}, e
|
|
}
|
|
switch d {
|
|
case DecisionDropIncoming:
|
|
// Capture the existing entry so we can fold the incoming
|
|
// source_chunk_ids into it after the drop (provenance union,
|
|
// mirroring Python's _struct_merge_graph_entities).
|
|
droppedExisting = existing
|
|
dropped = true
|
|
return common.KeepDrop, common.Product{}, nil
|
|
case DecisionMerge:
|
|
return common.KeepMerge, replacement, nil
|
|
default:
|
|
return common.KeepAdd, common.Product{}, nil
|
|
}
|
|
})
|
|
if err != nil {
|
|
return stats, err
|
|
}
|
|
switch action {
|
|
case common.KeepDrop:
|
|
stats.DuplicatesDropped++
|
|
// Fold the incoming source_chunk_ids into the surviving entry.
|
|
if dropped && droppedExisting.ID != "" {
|
|
if ids := metaStrings(row.Meta, "source_chunk_ids"); len(ids) > 0 {
|
|
store.MergeSourceChunkIDs(droppedExisting.ID, ids)
|
|
}
|
|
}
|
|
case common.KeepMerge:
|
|
stats.Updated++
|
|
stats.DuplicatesDropped++
|
|
default:
|
|
stats.Inserted++
|
|
}
|
|
}
|
|
return stats, nil
|
|
}
|
|
|
|
// GroupedDeduper mirrors _struct_local_dedup's group-by-filter-key behaviour:
|
|
// rows are bucketed by their relation endpoints (entities share one bucket,
|
|
// mirroring _struct_filter_key where entity rows carry no from/to), so an
|
|
// entity never merges with a relation and relations only merge with
|
|
// same-endpoint relations. One MemStore per bucket keeps cosine candidates
|
|
// group-scoped. doc/compile/template are constant per run and therefore not
|
|
// part of the key (they are in Python's key for the cross-document ES case).
|
|
type GroupedDeduper struct {
|
|
decider MergeDecider
|
|
|
|
mu sync.Mutex
|
|
order []string
|
|
stores map[string]*common.MemStore
|
|
stats MergeStats
|
|
}
|
|
|
|
// NewGroupedDeduper constructs a deduper around the given decider.
|
|
func NewGroupedDeduper(decider MergeDecider) *GroupedDeduper {
|
|
return &GroupedDeduper{decider: decider, stores: map[string]*common.MemStore{}}
|
|
}
|
|
|
|
// groupKey mirrors _struct_filter_key minus the per-run constants.
|
|
func groupKey(row common.Product) string {
|
|
from, _ := row.Meta["from"].(string)
|
|
to, _ := row.Meta["to"].(string)
|
|
return from + "\x00" + to
|
|
}
|
|
|
|
// Add folds one row into its endpoint bucket. Safe for concurrent use, but
|
|
// callers that need deterministic merge outcomes (mirroring Python's
|
|
// sequential _struct_local_dedup) should call it sequentially in batch order.
|
|
func (g *GroupedDeduper) Add(ctx context.Context, row common.Product) error {
|
|
key := groupKey(row)
|
|
g.mu.Lock()
|
|
store, ok := g.stores[key]
|
|
if !ok {
|
|
store = common.NewMemStore()
|
|
g.stores[key] = store
|
|
g.order = append(g.order, key)
|
|
}
|
|
g.mu.Unlock()
|
|
|
|
s, err := MergeIntoStore(ctx, store, g.decider, []common.Product{row})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
g.mu.Lock()
|
|
g.stats.Inserted += s.Inserted
|
|
g.stats.Updated += s.Updated
|
|
g.stats.DuplicatesDropped += s.DuplicatesDropped
|
|
g.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// Stats returns the accumulated dedup counters.
|
|
func (g *GroupedDeduper) Stats() MergeStats {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
return g.stats
|
|
}
|
|
|
|
// Rows concatenates every bucket's surviving rows in first-seen bucket order.
|
|
func (g *GroupedDeduper) Rows() []common.Product {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
var out []common.Product
|
|
for _, key := range g.order {
|
|
out = append(out, g.stores[key].Snapshot()...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// RewriteRelations mirrors the alias-rewrite tail of _struct_local_dedup:
|
|
// when entity merges recorded aliases, every relation row's endpoints are
|
|
// resolved through them (re-embedding the changed payloads, preserving row
|
|
// ids), then ALL relation rows are re-folded through the deduper so relations
|
|
// that became identical collapse (Python's second pass with
|
|
// rewrite_relations=False). It is a no-op without aliases.
|
|
func (g *GroupedDeduper) RewriteRelations(ctx context.Context, aliases map[string]string, embed common.Embedder) error {
|
|
if len(aliases) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Detach every relation row from its bucket (entity rows stay put).
|
|
g.mu.Lock()
|
|
var relations []common.Product
|
|
keptOrder := make([]string, 0, len(g.order))
|
|
for _, key := range g.order {
|
|
store := g.stores[key]
|
|
var remaining []common.Product
|
|
for _, row := range store.Snapshot() {
|
|
if kind, _ := row.Meta["kind"].(string); kind == "relation" {
|
|
relations = append(relations, row)
|
|
} else {
|
|
remaining = append(remaining, row)
|
|
}
|
|
}
|
|
if len(remaining) == len(store.Snapshot()) {
|
|
keptOrder = append(keptOrder, key)
|
|
continue
|
|
}
|
|
delete(g.stores, key)
|
|
if len(remaining) > 0 {
|
|
fresh := common.NewMemStore()
|
|
for _, row := range remaining {
|
|
fresh.Add(row)
|
|
}
|
|
g.stores[key] = fresh
|
|
keptOrder = append(keptOrder, key)
|
|
}
|
|
}
|
|
g.order = keptOrder
|
|
g.mu.Unlock()
|
|
|
|
if len(relations) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Rewrite endpoints; re-embed only the rows whose payload changed
|
|
// (mirrors _struct_rewrite_relation_doc). Row ids are preserved.
|
|
var changedIdx []int
|
|
for i, row := range relations {
|
|
payload := parsePayload(row.Content)
|
|
if payload == nil {
|
|
continue
|
|
}
|
|
if !rewriteRelationPayload(payload, aliases) {
|
|
continue
|
|
}
|
|
row.Content = payloadJSON(payload)
|
|
if row.Meta == nil {
|
|
row.Meta = map[string]any{}
|
|
}
|
|
if from := relationEndpoint(payload, "", "source", "src", "from"); from != "" {
|
|
row.Meta["from"] = from
|
|
}
|
|
if to := relationEndpoint(payload, "", "target", "tgt", "to"); to != "" {
|
|
row.Meta["to"] = to
|
|
}
|
|
relations[i] = row
|
|
changedIdx = append(changedIdx, i)
|
|
}
|
|
if len(changedIdx) > 0 {
|
|
texts := make([]string, len(changedIdx))
|
|
for i, idx := range changedIdx {
|
|
texts[i] = payloadDescription(parsePayload(relations[idx].Content))
|
|
}
|
|
vecs, err := embed.Encode(ctx, texts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(vecs) != len(changedIdx) {
|
|
return fmt.Errorf("knowledge_compiler: re-embed after relation rewrite returned %d vectors for %d rows", len(vecs), len(changedIdx))
|
|
}
|
|
for i, idx := range changedIdx {
|
|
relations[idx].Vector = vecs[i]
|
|
}
|
|
}
|
|
|
|
// Second dedup pass over the rewritten relation set.
|
|
for _, row := range relations {
|
|
if err := g.Add(ctx, row); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|