mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-05 07:10:29 +08:00
Port Python rag/advanced_rag agentic search to Go: ES-backed dataset-nav service, agentic-search harness, and agent tools. Includes agentic-search port plan and self-review docs.
575 lines
17 KiB
Go
575 lines
17 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 nlp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"ragflow/internal/engine"
|
|
"ragflow/internal/engine/types"
|
|
"ragflow/internal/service/nav"
|
|
)
|
|
|
|
// Dataset-nav constants. These mirror Python's dataset_nav.py: nav rows live in
|
|
// the document index as compile_kwd="dataset_nav" rows with available_int=0
|
|
// (invisible to the default retriever, which filters available_int=1), and the
|
|
// tree is threaded through parent_kwd ("root" for depth-0 clusters).
|
|
const (
|
|
navCompileKwd = "dataset_nav"
|
|
navRootParent = "root"
|
|
|
|
navMergeThreshold = 0.80 // sim >= this -> merge doc into cluster
|
|
navRecurse = 0.65 // descend while sim >= this
|
|
navMinSim = 0.50 // sim >= this -> new sibling cluster
|
|
navMaxDepth = 6 // max descent levels during best-cluster search
|
|
)
|
|
|
|
// NavEmbedder embeds text. tenantID lets a production implementation resolve
|
|
// the tenant's embedding model. Kept as an interface so tests inject a stub.
|
|
type NavEmbedder interface {
|
|
Encode(ctx context.Context, tenantID string, texts []string) ([][]float32, error)
|
|
}
|
|
|
|
// NavService is the concrete, ES-backed implementation of nav.NavService.
|
|
type NavService struct {
|
|
embed NavEmbedder
|
|
engine engine.DocEngine // optional; falls back to engine.Get() when nil
|
|
}
|
|
|
|
// NewNavService builds the ES-backed NavService. embed may be nil when the
|
|
// caller guarantees every UpsertDocInput carries a precomputed Embedd.
|
|
func NewNavService(embed NavEmbedder) *NavService {
|
|
return &NavService{embed: embed}
|
|
}
|
|
|
|
func (s *NavService) docEngine() (engine.DocEngine, error) {
|
|
if s.engine != nil {
|
|
return s.engine, nil
|
|
}
|
|
de := engine.Get()
|
|
if de == nil {
|
|
return nil, fmt.Errorf("document engine is not initialized")
|
|
}
|
|
return de, nil
|
|
}
|
|
|
|
// navIndexName returns the tenant document index name (ragflow_<tenantID>).
|
|
func (s *NavService) navIndexName(tenantID string) string {
|
|
return fmt.Sprintf("ragflow_%s", tenantID)
|
|
}
|
|
|
|
// navFilter builds the common nav filter that pins compile_kwd.
|
|
func navFilter(extra map[string]interface{}) map[string]interface{} {
|
|
f := map[string]interface{}{"compile_kwd": []string{navCompileKwd}}
|
|
for k, v := range extra {
|
|
f[k] = v
|
|
}
|
|
return f
|
|
}
|
|
|
|
// navSearch runs a filtered read over the tenant index for the dataset.
|
|
func (s *NavService) navSearch(ctx context.Context, tenantID, kbID string, filter map[string]interface{}, selectFields []string, offset, limit int, matchExprs []interface{}) ([]map[string]interface{}, int64, error) {
|
|
de, err := s.docEngine()
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
merged := make(map[string]interface{}, len(filter)+1)
|
|
for k, v := range filter {
|
|
merged[k] = v
|
|
}
|
|
merged["kb_id"] = []string{kbID}
|
|
req := &types.SearchRequest{
|
|
IndexNames: []string{s.navIndexName(tenantID)},
|
|
KbIDs: []string{kbID},
|
|
Offset: offset,
|
|
Limit: limit,
|
|
SelectFields: selectFields,
|
|
Filter: merged,
|
|
MatchExprs: matchExprs,
|
|
}
|
|
res, err := de.Search(ctx, req)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if res == nil {
|
|
return nil, 0, nil
|
|
}
|
|
return res.Chunks, res.Total, nil
|
|
}
|
|
|
|
// ListClusters returns the depth-0 clusters (parent_kwd=root).
|
|
func (s *NavService) ListClusters(ctx context.Context, tenantID, kbID string, page, pageSize int) ([]nav.NavNode, int64, error) {
|
|
if pageSize <= 0 {
|
|
pageSize = 100
|
|
}
|
|
offset := page * pageSize
|
|
chunks, total, err := s.navSearch(ctx, tenantID, kbID,
|
|
navFilter(map[string]interface{}{
|
|
"type_kwd": []string{"nav_cluster"},
|
|
"parent_kwd": []string{navRootParent},
|
|
}),
|
|
[]string{"title_kwd", "content_with_weight", "doc_count_int", "type_kwd"}, offset, pageSize, nil)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
nodes := make([]nav.NavNode, 0, len(chunks))
|
|
for _, c := range chunks {
|
|
nodes = append(nodes, s.nodeFromRow(c, "cluster"))
|
|
}
|
|
return nodes, total, nil
|
|
}
|
|
|
|
// ListChildren returns the direct children of a cluster (parent_kwd=name).
|
|
func (s *NavService) ListChildren(ctx context.Context, tenantID, kbID, name string, page, pageSize int) ([]nav.NavNode, int64, error) {
|
|
if pageSize <= 0 {
|
|
pageSize = 100
|
|
}
|
|
offset := page * pageSize
|
|
chunks, total, err := s.navSearch(ctx, tenantID, kbID,
|
|
navFilter(map[string]interface{}{"parent_kwd": []string{name}}),
|
|
[]string{"title_kwd", "content_with_weight", "doc_count_int", "type_kwd", "doc_id"}, offset, pageSize, nil)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
nodes := make([]nav.NavNode, 0, len(chunks))
|
|
for _, c := range chunks {
|
|
typ := firstStringValue(c["type_kwd"])
|
|
nodeType := "doc"
|
|
if typ == "nav_cluster" {
|
|
nodeType = "cluster"
|
|
}
|
|
nodes = append(nodes, s.nodeFromRow(c, nodeType))
|
|
}
|
|
return nodes, total, nil
|
|
}
|
|
|
|
// nodeFromRow converts an engine row into a NavNode.
|
|
func (s *NavService) nodeFromRow(row map[string]interface{}, fallbackType string) nav.NavNode {
|
|
node := nav.NavNode{
|
|
Name: firstStringValue(row["title_kwd"]),
|
|
DocCount: intValue(row["doc_count_int"]),
|
|
Type: fallbackType,
|
|
DocID: firstStringValue(row["doc_id"]),
|
|
}
|
|
if t := firstStringValue(row["type_kwd"]); t != "" {
|
|
if t == "nav_cluster" {
|
|
node.Type = "cluster"
|
|
} else {
|
|
node.Type = "doc"
|
|
}
|
|
}
|
|
if payload, ok := row["content_with_weight"].(string); ok {
|
|
var m map[string]interface{}
|
|
if err := json.Unmarshal([]byte(payload), &m); err == nil {
|
|
if d, ok := m["description"].(string); ok {
|
|
node.Description = d
|
|
}
|
|
}
|
|
}
|
|
node.HasChildren = node.Type == "cluster"
|
|
if node.DocCount <= 0 {
|
|
node.DocCount = 1
|
|
}
|
|
return node
|
|
}
|
|
|
|
// Search runs query KNN over nav rows and returns routed doc ids.
|
|
func (s *NavService) Search(ctx context.Context, tenantID, kbID, query string, embd []float32, topK int) ([]nav.NavHit, error) {
|
|
if topK <= 0 {
|
|
topK = 8
|
|
}
|
|
vec := embd
|
|
if len(vec) == 0 {
|
|
if s.embed == nil {
|
|
return nil, fmt.Errorf("datasetnav: no embedding available for Search")
|
|
}
|
|
embeddings, err := s.embed.Encode(ctx, tenantID, []string{query})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(embeddings) == 0 {
|
|
return nil, fmt.Errorf("datasetnav: embedding produced no vector")
|
|
}
|
|
vec = embeddings[0]
|
|
}
|
|
f64 := f32ToF64Slice(vec)
|
|
chunks, _, err := s.navSearch(ctx, tenantID, kbID,
|
|
navFilter(nil),
|
|
[]string{"type_kwd", "title_kwd", "doc_id", "doc_ids_kwd", "_score"}, 0, topK,
|
|
[]interface{}{&types.MatchDenseExpr{
|
|
VectorColumnName: fmt.Sprintf("q_%d_vec", len(f64)),
|
|
EmbeddingData: f64,
|
|
EmbeddingDataType: "float",
|
|
DistanceType: "cosine",
|
|
TopN: topK,
|
|
ExtraOptions: map[string]interface{}{"similarity": 0.0},
|
|
}})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
hits := make([]nav.NavHit, 0, len(chunks))
|
|
for _, c := range chunks {
|
|
h := nav.NavHit{
|
|
Type: firstStringValue(c["type_kwd"]),
|
|
Name: firstStringValue(c["title_kwd"]),
|
|
DocID: firstStringValue(c["doc_id"]),
|
|
}
|
|
if sc, ok := c["_score"].(float64); ok {
|
|
h.Score = sc
|
|
} else if sc, ok := c["_score"].(float32); ok {
|
|
h.Score = float64(sc)
|
|
}
|
|
if ds, ok := c["doc_ids_kwd"].([]interface{}); ok {
|
|
for _, d := range ds {
|
|
if dd, ok := d.(string); ok {
|
|
h.DocIDs = append(h.DocIDs, dd)
|
|
}
|
|
}
|
|
}
|
|
hits = append(hits, h)
|
|
}
|
|
return hits, nil
|
|
}
|
|
|
|
// UpsertDoc places one document summary into the nav tree. Minimal closed loop:
|
|
// deterministic placement (KNN find best cluster -> merge if sim>=0.80, else a
|
|
// new root-level cluster). No LLM, no split/rebalance, no cascade cleanup.
|
|
func (s *NavService) UpsertDoc(ctx context.Context, in nav.UpsertDocInput) error {
|
|
if strings.TrimSpace(in.Summary) == "" {
|
|
return nil
|
|
}
|
|
de, err := s.docEngine()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if s.embed == nil && len(in.Embedd) == 0 {
|
|
return fmt.Errorf("datasetnav: embedder required for UpsertDoc")
|
|
}
|
|
vec := in.Embedd
|
|
if len(vec) == 0 {
|
|
embeddings, err := s.embed.Encode(ctx, in.TenantID, []string{in.Summary})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(embeddings) == 0 {
|
|
return nil
|
|
}
|
|
vec = embeddings[0]
|
|
}
|
|
|
|
// storeGet: skip if a nav_doc for this doc already exists with same summary.
|
|
existing, _, err := s.navSearch(ctx, in.TenantID, in.KbID,
|
|
navFilter(map[string]interface{}{"doc_id": []string{in.DocID}}),
|
|
[]string{"content_with_weight"}, 0, 1, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(existing) > 0 {
|
|
if payload, ok := existing[0]["content_with_weight"].(string); ok {
|
|
var m map[string]interface{}
|
|
if err := json.Unmarshal([]byte(payload), &m); err == nil {
|
|
if d, _ := m["description"].(string); d == in.Summary {
|
|
return nil // unchanged
|
|
}
|
|
}
|
|
}
|
|
// Changed summary: remove the old nav_doc first (no cascade in minimal loop).
|
|
if _, err := s.deleteNavDoc(ctx, in.TenantID, in.KbID, in.DocID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
bestName, sim, bestDepth, err := s.findBestCluster(ctx, in.TenantID, in.KbID, vec)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
idx := s.navIndexName(in.TenantID)
|
|
|
|
if bestName != "" && sim >= navMergeThreshold {
|
|
parent := bestName
|
|
if err := s.appendDocToCluster(ctx, de, in.TenantID, in.KbID, bestName, in.DocID); err != nil {
|
|
return err
|
|
}
|
|
_, err = de.InsertChunks(ctx, []map[string]interface{}{{
|
|
"compile_kwd": navCompileKwd,
|
|
"available_int": 0,
|
|
"type_kwd": "nav_doc",
|
|
"title_kwd": in.DocID,
|
|
"parent_kwd": parent,
|
|
// The nav_doc sits one level below its (possibly nested) parent
|
|
// cluster, so its depth is parentDepth+1 — not a hard-coded 1.
|
|
"depth_int": bestDepth + 1,
|
|
"doc_id": in.DocID,
|
|
"doc_count_int": 1,
|
|
"content_with_weight": payloadJSONNav(map[string]interface{}{"type": "nav_doc", "description": in.Summary}),
|
|
"q_" + fmt.Sprintf("%d", len(vec)) + "_vec": f32ToF64Slice(vec),
|
|
}}, idx, in.KbID)
|
|
return err
|
|
}
|
|
|
|
// A similar-but-not-mergeable cluster creates a sibling sub-cluster (Python
|
|
// _MIN_SIM=0.50); otherwise a fresh root cluster. This keeps the nav tree
|
|
// from degrading into one root per document.
|
|
parent := navRootParent
|
|
depth := 0
|
|
if bestName != "" && sim >= navMinSim {
|
|
parent = bestName
|
|
// A sibling of the (possibly nested) best cluster is one level deeper
|
|
// than it, so depth = parentDepth+1 — not a hard-coded 1.
|
|
depth = bestDepth + 1
|
|
}
|
|
name := navDocName(in.DocID, in.Summary)
|
|
_, err = de.InsertChunks(ctx, []map[string]interface{}{{
|
|
"compile_kwd": navCompileKwd,
|
|
"available_int": 0,
|
|
"type_kwd": "nav_cluster",
|
|
"title_kwd": name,
|
|
"parent_kwd": parent,
|
|
"depth_int": depth,
|
|
"doc_count_int": 1,
|
|
"doc_ids_kwd": []string{in.DocID},
|
|
"content_with_weight": payloadJSONNav(map[string]interface{}{"type": "nav_cluster", "description": in.Summary}),
|
|
"q_" + fmt.Sprintf("%d", len(vec)) + "_vec": f32ToF64Slice(vec),
|
|
}}, idx, in.KbID)
|
|
return err
|
|
}
|
|
|
|
// findBestCluster finds the best-matching cluster via level-by-level descent
|
|
// (mirroring Python _find_best_cluster). It KNNs the current level's clusters
|
|
// and, when the best match is >= recurse threshold, descends into that cluster's
|
|
// children. Returns the best cluster name, similarity, and its depth (0 = root)
|
|
// so callers can assign consistent child depth_int values.
|
|
func (s *NavService) findBestCluster(ctx context.Context, tenantID, kbID string, vec []float32) (string, float64, int, error) {
|
|
f64 := f32ToF64Slice(vec)
|
|
parent := navRootParent
|
|
bestName := ""
|
|
bestSim := 0.0
|
|
bestDepth := 0
|
|
for level := 0; level < navMaxDepth; level++ {
|
|
// KNN among clusters whose parent is the current level.
|
|
chunks, _, err := s.navSearch(ctx, tenantID, kbID,
|
|
navFilter(map[string]interface{}{
|
|
"type_kwd": []string{"nav_cluster"},
|
|
"parent_kwd": []string{parent},
|
|
}),
|
|
[]string{"title_kwd", "_score"}, 0, 1,
|
|
[]interface{}{&types.MatchDenseExpr{
|
|
VectorColumnName: fmt.Sprintf("q_%d_vec", len(f64)),
|
|
EmbeddingData: f64,
|
|
EmbeddingDataType: "float",
|
|
DistanceType: "cosine",
|
|
TopN: 1,
|
|
ExtraOptions: map[string]interface{}{"similarity": 0.0},
|
|
}})
|
|
if err != nil {
|
|
return "", 0, 0, err
|
|
}
|
|
if len(chunks) == 0 {
|
|
break
|
|
}
|
|
name := firstStringValue(chunks[0]["title_kwd"])
|
|
sim := rowScore(chunks[0])
|
|
// Keep the STRONGEST match seen so far across all levels, so a strong
|
|
// ancestor is never displaced by a weaker descendant. Record its depth
|
|
// so the caller can set consistent child depth_int values.
|
|
if sim > bestSim {
|
|
bestName, bestSim, bestDepth = name, sim, level
|
|
}
|
|
// Descend only while the current match is strong enough that a deeper
|
|
// child could be a better target.
|
|
if sim < navRecurse {
|
|
break
|
|
}
|
|
parent = name
|
|
}
|
|
return bestName, bestSim, bestDepth, nil
|
|
}
|
|
|
|
// rowScore extracts the engine's _score field as float64.
|
|
func rowScore(row map[string]interface{}) float64 {
|
|
if sc, ok := row["_score"].(float64); ok {
|
|
return sc
|
|
}
|
|
if sc, ok := row["_score"].(float32); ok {
|
|
return float64(sc)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// appendDocToCluster appends a doc id to a cluster's doc_ids_kwd and bumps its
|
|
// doc_count_int. Implemented as a read-modify-write.
|
|
func (s *NavService) appendDocToCluster(ctx context.Context, de engine.DocEngine, tenantID, kbID, clusterName, docID string) error {
|
|
chunks, _, err := s.navSearch(ctx, tenantID, kbID,
|
|
navFilter(map[string]interface{}{"type_kwd": []string{"nav_cluster"}, "title_kwd": []string{clusterName}}),
|
|
[]string{"doc_ids_kwd", "doc_count_int"}, 0, 1, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(chunks) == 0 {
|
|
return nil
|
|
}
|
|
ids := []string{}
|
|
if raw, ok := chunks[0]["doc_ids_kwd"].([]interface{}); ok {
|
|
for _, d := range raw {
|
|
if dd, ok := d.(string); ok {
|
|
ids = append(ids, dd)
|
|
}
|
|
}
|
|
}
|
|
found := false
|
|
for _, id := range ids {
|
|
if id == docID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
ids = append(ids, docID)
|
|
}
|
|
count := intValue(chunks[0]["doc_count_int"])
|
|
if !found {
|
|
count++
|
|
}
|
|
// Pin the update to the nav_cluster row only: a regular chunk sharing the
|
|
// same title_kwd must never be clobbered. The read-modify-write here is
|
|
// expected to run under a per-dataset lock held by the UpsertDoc caller;
|
|
// without it, concurrent appends to the same cluster can lose updates.
|
|
return de.UpdateChunks(ctx,
|
|
map[string]interface{}{
|
|
"compile_kwd": []string{navCompileKwd},
|
|
"type_kwd": []string{"nav_cluster"},
|
|
"title_kwd": []string{clusterName},
|
|
"kb_id": kbID,
|
|
},
|
|
map[string]interface{}{"doc_ids_kwd": ids, "doc_count_int": count},
|
|
s.navIndexName(tenantID), kbID)
|
|
}
|
|
|
|
// deleteNavDoc deletes a nav_doc row by doc_id.
|
|
func (s *NavService) deleteNavDoc(ctx context.Context, tenantID, kbID, docID string) (int64, error) {
|
|
de, err := s.docEngine()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
chunks, _, err := s.navSearch(ctx, tenantID, kbID,
|
|
navFilter(map[string]interface{}{"doc_id": []string{docID}}),
|
|
[]string{"id"}, 0, 100, nil)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
ids := make([]string, 0, len(chunks))
|
|
for _, c := range chunks {
|
|
if id, ok := c["id"].(string); ok {
|
|
ids = append(ids, id)
|
|
}
|
|
}
|
|
if len(ids) == 0 {
|
|
return 0, nil
|
|
}
|
|
return de.DeleteChunks(ctx, map[string]interface{}{"id": ids, "kb_id": kbID}, s.navIndexName(tenantID), kbID)
|
|
}
|
|
|
|
// RemoveDoc removes a document's nav_doc (no cascade cleanup in minimal loop).
|
|
func (s *NavService) RemoveDoc(ctx context.Context, tenantID, kbID, docID string) error {
|
|
_, err := s.deleteNavDoc(ctx, tenantID, kbID, docID)
|
|
return err
|
|
}
|
|
|
|
// f32ToF64Slice converts a float32 vector to float64.
|
|
func f32ToF64Slice(v []float32) []float64 {
|
|
out := make([]float64, len(v))
|
|
for i, x := range v {
|
|
out[i] = float64(x)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// payloadJSONNav marshals a nav payload map into the content_with_weight JSON.
|
|
func payloadJSONNav(v map[string]interface{}) string {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return "{}"
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// navDocName builds a deterministic cluster name for the minimal loop.
|
|
func navDocName(docID, summary string) string {
|
|
return docID + "_" + contentHash8(summary)
|
|
}
|
|
|
|
// contentHash8 is a stable 8-char hash.
|
|
func contentHash8(s string) string {
|
|
h := uint32(2166136261)
|
|
for i := 0; i < len(s); i++ {
|
|
h ^= uint32(s[i])
|
|
h *= 16777619
|
|
}
|
|
return fmt.Sprintf("%08x", h)
|
|
}
|
|
|
|
// firstStringValue returns the first string value of a (possibly list-wrapped)
|
|
// engine field.
|
|
func firstStringValue(v interface{}) string {
|
|
switch tv := v.(type) {
|
|
case string:
|
|
return tv
|
|
case []string:
|
|
if len(tv) > 0 {
|
|
return tv[0]
|
|
}
|
|
case []interface{}:
|
|
if len(tv) > 0 {
|
|
if s, ok := tv[0].(string); ok {
|
|
return s
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// intValue returns the integer value of an engine field.
|
|
func intValue(v interface{}) int {
|
|
switch tv := v.(type) {
|
|
case float64:
|
|
return int(tv)
|
|
case float32:
|
|
return int(tv)
|
|
case int:
|
|
return tv
|
|
case int64:
|
|
return int(tv)
|
|
case []float64:
|
|
if len(tv) > 0 {
|
|
return int(tv[0])
|
|
}
|
|
case []interface{}:
|
|
if len(tv) > 0 {
|
|
switch n := tv[0].(type) {
|
|
case float64:
|
|
return int(n)
|
|
case int:
|
|
return n
|
|
}
|
|
}
|
|
}
|
|
return 0
|
|
}
|