mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-04 14:50:30 +08:00
Port Python agentic search to Go (nav service, harness, tools) (#17702)
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.
This commit is contained in:
345
internal/agent/tool/agentic_search.go
Normal file
345
internal/agent/tool/agentic_search.go
Normal file
@@ -0,0 +1,345 @@
|
||||
//
|
||||
// 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 tool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
einotool "github.com/cloudwego/eino/components/tool"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// Agentic search tool names mirror Python rag/advanced_rag/harness/tools/search.py.
|
||||
const (
|
||||
toolHybridSearch = "hybrid_search"
|
||||
toolVectorSearch = "vector_search"
|
||||
toolBM25Search = "bm25_search"
|
||||
toolWebSearch = "web_search"
|
||||
toolStructuredQuery = "structured_query"
|
||||
)
|
||||
|
||||
// hybridSearchArgs is the shared JSON schema for the three retrieval tools.
|
||||
type hybridSearchArgs struct {
|
||||
Query string `json:"query"`
|
||||
KbIDs []string `json:"kb_ids,omitempty"`
|
||||
TopN int `json:"top_n,omitempty"`
|
||||
DocScope []string `json:"doc_scope,omitempty"`
|
||||
Keywords string `json:"keywords,omitempty"`
|
||||
UseCompiled bool `json:"use_compiled,omitempty"`
|
||||
}
|
||||
|
||||
type agenticSearchResult struct {
|
||||
Chunks []map[string]interface{} `json:"chunks"`
|
||||
DocAggs []map[string]interface{} `json:"doc_aggs,omitempty"`
|
||||
}
|
||||
|
||||
// AgenticSearchTool is the hybrid/vector/bm25 retrieval tool. The search mode
|
||||
// selects the vector-similarity weight used by the underlying retrieval service:
|
||||
//
|
||||
// hybrid: 0.3 (hybrid of keyword + vector)
|
||||
// vector: 1.0 (vector-only)
|
||||
// bm25: 0.0 (keyword-only)
|
||||
//
|
||||
// It backs onto GetRetrievalService() (the same singleton the agent Retrieval
|
||||
// tool uses), so DocScope and KB scoping carry through automatically.
|
||||
type AgenticSearchTool struct {
|
||||
mode string // hybrid_search | vector_search | bm25_search
|
||||
weight float64
|
||||
defaults hybridSearchArgs
|
||||
}
|
||||
|
||||
// NewAgenticSearchTool returns the retrieval tool for the given mode.
|
||||
func NewAgenticSearchTool(mode string) *AgenticSearchTool {
|
||||
weight := 0.3
|
||||
switch mode {
|
||||
case toolVectorSearch:
|
||||
weight = 1.0
|
||||
case toolBM25Search:
|
||||
weight = 0.0
|
||||
}
|
||||
return &AgenticSearchTool{mode: mode, weight: weight, defaults: hybridSearchArgs{TopN: 12}}
|
||||
}
|
||||
|
||||
func (a *AgenticSearchTool) Info(_ context.Context) (*schema.ToolInfo, error) {
|
||||
return &schema.ToolInfo{
|
||||
Name: a.mode,
|
||||
Desc: fmt.Sprintf("Search the bound knowledge base(s) for the query (mode=%s). Returns relevant passages.", a.mode),
|
||||
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
|
||||
"query": {
|
||||
Type: schema.String, Required: true, Desc: "The search query.",
|
||||
},
|
||||
"kb_ids": {Type: schema.Array, Desc: "Optional dataset ids to restrict to."},
|
||||
"top_n": {Type: schema.Number, Desc: "Number of passages to return (default 12)."},
|
||||
"doc_scope": {Type: schema.Array, Desc: "Optional doc ids to restrict to."},
|
||||
"keywords": {Type: schema.String, Desc: "Comma-separated keywords to narrow results."},
|
||||
"use_compiled": {Type: schema.Boolean, Desc: "Whether to enrich with compiled products."},
|
||||
}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// InvokableRun executes the retrieval. It returns JSON with "chunks" (array of
|
||||
// chunk maps). Never returns a hard error for retrieval failures — it returns an
|
||||
// empty result so the agent can fall back.
|
||||
func (a *AgenticSearchTool) InvokableRun(ctx context.Context, argumentsInJSON string, _ ...einotool.Option) (string, error) {
|
||||
var args hybridSearchArgs
|
||||
if err := json.Unmarshal([]byte(argumentsInJSON), &args); err != nil {
|
||||
return "", fmt.Errorf("%s: parse arguments: %w", a.mode, err)
|
||||
}
|
||||
if args.TopN <= 0 {
|
||||
args.TopN = a.defaults.TopN
|
||||
}
|
||||
|
||||
svc := GetRetrievalService()
|
||||
tenantID := canvasTenantID(ctx)
|
||||
datasetIDs := args.KbIDs
|
||||
if len(datasetIDs) == 0 {
|
||||
datasetIDs = canvasDatasetIDs(ctx, nil)
|
||||
}
|
||||
if svc == nil || tenantID == "" || len(datasetIDs) == 0 {
|
||||
return jsonChunksEmpty(), nil
|
||||
}
|
||||
|
||||
weight := a.weight
|
||||
req := RetrievalRequest{
|
||||
Query: strings.TrimSpace(args.Query + " " + args.Keywords),
|
||||
DatasetIDs: datasetIDs,
|
||||
TopN: args.TopN,
|
||||
TopK: args.TopN * 4,
|
||||
SimilarityThreshold: 0.2,
|
||||
KeywordsSimilarityWeight: &weight,
|
||||
DocScope: args.DocScope,
|
||||
}
|
||||
chunks, err := svc.Search(ctx, nil, req)
|
||||
if err != nil {
|
||||
return jsonChunksEmpty(), nil // agent falls back on failure
|
||||
}
|
||||
|
||||
// Keyword narrowing (mirrors Python _narrow_by_keywords).
|
||||
if args.Keywords != "" {
|
||||
chunks = narrowByKeywords(chunks, args.Keywords)
|
||||
}
|
||||
return marshalSearchResult(chunks), nil
|
||||
}
|
||||
|
||||
// narrowByKeywords narrows each chunk to keyword-bearing sentences (+/-1
|
||||
// neighbour) and drops keyword-less chunks. A simplified port of Python's
|
||||
// _narrow_by_keywords.
|
||||
func narrowByKeywords(chunks []RetrievalChunk, keywords string) []RetrievalChunk {
|
||||
kwds := splitKeywords(keywords)
|
||||
if len(kwds) == 0 {
|
||||
return chunks
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]RetrievalChunk, 0, len(chunks))
|
||||
for _, c := range chunks {
|
||||
nc, ok := narrowContent(c.Content, kwds)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
hash := md5Hex(nc)
|
||||
if _, dup := seen[hash]; dup {
|
||||
continue
|
||||
}
|
||||
seen[hash] = struct{}{}
|
||||
c.Content = nc
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// splitKeywords normalizes a keyword string into a list of terms. When fewer
|
||||
// than 3 comma terms exist, falls back to space-split bigrams (mirrors Python).
|
||||
func splitKeywords(keywords string) []string {
|
||||
if strings.TrimSpace(keywords) == "" {
|
||||
return nil
|
||||
}
|
||||
kwds := make([]string, 0, 8)
|
||||
for _, k := range strings.Split(keywords, ",") {
|
||||
if k = strings.TrimSpace(k); k != "" {
|
||||
kwds = append(kwds, strings.ToLower(k))
|
||||
}
|
||||
}
|
||||
if len(kwds) < 3 {
|
||||
words := make([]string, 0, 8)
|
||||
for _, w := range strings.Split(keywords, " ") {
|
||||
if w = strings.TrimSpace(w); w != "" {
|
||||
words = append(words, strings.ToLower(w))
|
||||
}
|
||||
}
|
||||
bigrams := make([]string, 0, len(words))
|
||||
for i := 0; i+1 < len(words); i++ {
|
||||
bigrams = append(bigrams, words[i]+" "+words[i+1])
|
||||
}
|
||||
if len(bigrams) > 0 {
|
||||
return bigrams
|
||||
}
|
||||
}
|
||||
return kwds
|
||||
}
|
||||
|
||||
var sentEnd = regexp.MustCompile(`[。!?;!?;]+|\.`)
|
||||
|
||||
// splitSentences splits text into sentences at sentence terminators, keeping a
|
||||
// digit-guarded period intact ("3.14" / "v1.2" are not split). Implemented with
|
||||
// a simple splitter because RE2 (Go) does not support lookbehind/lookahead.
|
||||
func splitSentences(content string) []string {
|
||||
raw := sentEnd.Split(content, -1)
|
||||
sents := make([]string, 0, len(raw))
|
||||
for _, s := range raw {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
continue
|
||||
}
|
||||
// Re-join a trailing digit-period-digit that the splitter cut apart:
|
||||
// if s ends with a digit and content had ".<digit>" following, reattach.
|
||||
sents = append(sents, s)
|
||||
}
|
||||
return rejoinDigitPeriods(sents)
|
||||
}
|
||||
|
||||
// rejoinDigitPeriods merges "…1" + "2…" back into "…1.2…" when a decimal point
|
||||
// separated two digit groups.
|
||||
func rejoinDigitPeriods(sents []string) []string {
|
||||
out := make([]string, 0, len(sents))
|
||||
for i := 0; i < len(sents); i++ {
|
||||
cur := sents[i]
|
||||
// If current ends with a digit and next begins with a digit, the split
|
||||
// point was a decimal point — merge them.
|
||||
for i+1 < len(sents) && hasTrailingDigit(cur) && hasLeadingDigit(sents[i+1]) {
|
||||
cur = strings.TrimRight(cur, " \t") + "." + sents[i+1]
|
||||
i++
|
||||
}
|
||||
out = append(out, cur)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hasTrailingDigit(s string) bool {
|
||||
s = strings.TrimRight(s, " \t")
|
||||
return len(s) > 0 && s[len(s)-1] >= '0' && s[len(s)-1] <= '9'
|
||||
}
|
||||
|
||||
func hasLeadingDigit(s string) bool {
|
||||
s = strings.TrimLeft(s, " \t")
|
||||
return len(s) > 0 && s[0] >= '0' && s[0] <= '9'
|
||||
}
|
||||
|
||||
// narrowContent returns the keyword-bearing sentences (+/-1 neighbour) with the
|
||||
// keyword highlighted, or (_, false) if no keyword occurs.
|
||||
func narrowContent(content string, kwds []string) (string, bool) {
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return "", false
|
||||
}
|
||||
sents := splitSentences(content)
|
||||
if len(sents) == 0 {
|
||||
return "", false
|
||||
}
|
||||
keep := map[int]bool{}
|
||||
matched := false
|
||||
for i, s := range sents {
|
||||
low := strings.ToLower(s)
|
||||
for _, kw := range kwds {
|
||||
if kw != "" && strings.Contains(low, kw) {
|
||||
matched = true
|
||||
if i > 0 {
|
||||
keep[i-1] = true
|
||||
}
|
||||
keep[i] = true
|
||||
if i+1 < len(sents) {
|
||||
keep[i+1] = true
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return "", false
|
||||
}
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(sents); i++ {
|
||||
if keep[i] {
|
||||
b.WriteString(sents[i])
|
||||
}
|
||||
}
|
||||
return "..." + highlightKeywords(b.String(), kwds) + "...", true
|
||||
}
|
||||
|
||||
// highlightKeywords wraps keyword occurrences in <em>.
|
||||
func highlightKeywords(text string, kwds []string) string {
|
||||
if len(kwds) == 0 {
|
||||
return text
|
||||
}
|
||||
// Sort by length desc so longer terms match first.
|
||||
terms := make([]string, len(kwds))
|
||||
copy(terms, kwds)
|
||||
for i := 1; i < len(terms); i++ {
|
||||
for j := i; j > 0 && len(terms[j]) > len(terms[j-1]); j-- {
|
||||
terms[j], terms[j-1] = terms[j-1], terms[j]
|
||||
}
|
||||
}
|
||||
pattern := "("
|
||||
for i, t := range terms {
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
if i > 0 {
|
||||
pattern += "|"
|
||||
}
|
||||
pattern += regexp.QuoteMeta(t)
|
||||
}
|
||||
pattern += ")"
|
||||
re := regexp.MustCompile(`(?i)` + pattern)
|
||||
return re.ReplaceAllString(text, "<em>${1}</em>")
|
||||
}
|
||||
|
||||
func md5Hex(s string) string {
|
||||
h := uint32(2166136261)
|
||||
for i := 0; i < len(s); i++ {
|
||||
h ^= uint32(s[i])
|
||||
h *= 16777619
|
||||
}
|
||||
return fmt.Sprintf("%08x", h)
|
||||
}
|
||||
|
||||
func jsonChunksEmpty() string {
|
||||
return `{"chunks":[]}`
|
||||
}
|
||||
|
||||
func marshalSearchResult(chunks []RetrievalChunk) string {
|
||||
type outChunk struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
DocumentID string `json:"doc_id"`
|
||||
DocName string `json:"docnm_kwd"`
|
||||
Score float64 `json:"similarity"`
|
||||
}
|
||||
out := make([]outChunk, 0, len(chunks))
|
||||
for _, c := range chunks {
|
||||
out = append(out, outChunk{
|
||||
ID: c.ID, Content: c.Content, DocumentID: c.DocumentID,
|
||||
DocName: c.DocumentName, Score: c.Score,
|
||||
})
|
||||
}
|
||||
b, err := json.Marshal(map[string]interface{}{"chunks": out})
|
||||
if err != nil {
|
||||
return jsonChunksEmpty()
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
78
internal/agent/tool/agentic_search_test.go
Normal file
78
internal/agent/tool/agentic_search_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package tool
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSplitKeywords_BigramFallback asserts comma terms are used when >=3, and
|
||||
// space-split bigrams otherwise.
|
||||
func TestSplitKeywords_BigramFallback(t *testing.T) {
|
||||
got := splitKeywords("alpha, beta, gamma")
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("comma kwds = %d, want 3", len(got))
|
||||
}
|
||||
got = splitKeywords("a b c d")
|
||||
// 4 words -> 3 bigrams
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("bigram kwds = %d, want 3", len(got))
|
||||
}
|
||||
if got[0] != "a b" {
|
||||
t.Errorf("bigram[0] = %q, want \"a b\"", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestNarrowContent_KeepsKeywordSentence asserts narrowing keeps the
|
||||
// keyword-bearing sentence and its neighbours, and highlights the keyword.
|
||||
func TestNarrowContent_KeepsKeywordSentence(t *testing.T) {
|
||||
content := "The introduction is boring. The key insight about rocket engines is here. The conclusion is short."
|
||||
nc, ok := narrowContent(content, []string{"rocket"})
|
||||
if !ok {
|
||||
t.Fatal("expected keyword match")
|
||||
}
|
||||
if !strings.Contains(nc, "key insight") {
|
||||
t.Errorf("narrowed content missing keyword sentence: %q", nc)
|
||||
}
|
||||
if !strings.Contains(nc, "<em>rocket</em>") {
|
||||
t.Errorf("narrowed content missing highlight: %q", nc)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNarrowContent_NoKeyword asserts narrowing returns false when no keyword
|
||||
// occurs.
|
||||
func TestNarrowContent_NoKeyword(t *testing.T) {
|
||||
if _, ok := narrowContent("no match here at all", []string{"zzz"}); ok {
|
||||
t.Fatal("expected no match")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSplitSentences_DigitPeriod asserts "3.14" is not split into two sentences.
|
||||
func TestSplitSentences_DigitPeriod(t *testing.T) {
|
||||
sents := splitSentences("pi is 3.14 and e is 2.71. end.")
|
||||
found := false
|
||||
for _, s := range sents {
|
||||
if strings.Contains(s, "3.14") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("digit-period sentence lost: %v", sents)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNarrowByKeywords_DropsKeywordless asserts chunks without keywords are
|
||||
// dropped and deduped by narrowed content hash.
|
||||
func TestNarrowByKeywords_DropsKeywordless(t *testing.T) {
|
||||
chunks := []RetrievalChunk{
|
||||
{ID: "c1", Content: "alpha talks about beta in detail here."},
|
||||
{ID: "c2", Content: "unrelated content entirely."},
|
||||
{ID: "c3", Content: "alpha talks about beta in detail here."}, // dup of c1
|
||||
}
|
||||
out := narrowByKeywords(chunks, "beta")
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("narrowed chunks = %d, want 1 (drops keywordless + dedups)", len(out))
|
||||
}
|
||||
if out[0].ID != "c1" {
|
||||
t.Errorf("kept chunk = %q, want c1", out[0].ID)
|
||||
}
|
||||
}
|
||||
59
internal/agent/tool/canvas_ctx.go
Normal file
59
internal/agent/tool/canvas_ctx.go
Normal file
@@ -0,0 +1,59 @@
|
||||
//
|
||||
// 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 tool
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"ragflow/internal/agent/runtime"
|
||||
)
|
||||
|
||||
// canvasTenantID derives the tenant id from canvas state, falling back to
|
||||
// user_id. Shared by agentic search and dataset-navigation tools.
|
||||
func canvasTenantID(ctx context.Context) string {
|
||||
state, _, err := runtime.GetStateFromContext[*runtime.CanvasState](ctx)
|
||||
if err != nil || state == nil {
|
||||
return ""
|
||||
}
|
||||
if tenantID, _ := state.Sys["tenant_id"].(string); tenantID != "" {
|
||||
return tenantID
|
||||
}
|
||||
userID, _ := state.Sys["user_id"].(string)
|
||||
return userID
|
||||
}
|
||||
|
||||
// canvasDatasetIDs returns the explicit dataset ids (all of them, preserving
|
||||
// multi-KB sessions), else the canvas sys dataset_id as a single-element list.
|
||||
func canvasDatasetIDs(ctx context.Context, explicit []string) []string {
|
||||
if len(explicit) > 0 {
|
||||
out := make([]string, 0, len(explicit))
|
||||
for _, id := range explicit {
|
||||
if id != "" {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
state, _, err := runtime.GetStateFromContext[*runtime.CanvasState](ctx)
|
||||
if err != nil || state == nil {
|
||||
return nil
|
||||
}
|
||||
if id, _ := state.Sys["dataset_id"].(string); id != "" {
|
||||
return []string{id}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
220
internal/agent/tool/dataset_navigation.go
Normal file
220
internal/agent/tool/dataset_navigation.go
Normal file
@@ -0,0 +1,220 @@
|
||||
//
|
||||
// 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 tool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
einotool "github.com/cloudwego/eino/components/tool"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
|
||||
"ragflow/internal/service/nav"
|
||||
)
|
||||
|
||||
// datasetNavigationToolName mirrors Python's dataset_navigation_by_tree router
|
||||
// tool. It navigates the dataset nav tree and returns the doc_ids to read.
|
||||
const datasetNavigationToolName = "dataset_navigation_by_tree"
|
||||
|
||||
const datasetNavigationToolDescription = "Navigate a dataset's navigation tree by topic and return the document ids that are likely relevant."
|
||||
|
||||
// datasetNavigationArgs is the JSON schema the model sends into InvokableRun.
|
||||
type datasetNavigationArgs struct {
|
||||
Topic string `json:"topic"`
|
||||
Keywords string `json:"keywords,omitempty"`
|
||||
DatasetIDs []string `json:"dataset_ids,omitempty"`
|
||||
DocScope string `json:"doc_scope,omitempty"`
|
||||
MaxDocs int `json:"max_docs,omitempty"`
|
||||
}
|
||||
|
||||
// datasetNavigationResult is the JSON shape returned to the model.
|
||||
type datasetNavigationResult struct {
|
||||
Docs []string `json:"docs,omitempty"`
|
||||
Error string `json:"_ERROR,omitempty"`
|
||||
NotFound bool `json:"not_found,omitempty"`
|
||||
}
|
||||
|
||||
// datasetNavigationDefaultMaxDocs caps the number of doc_ids returned.
|
||||
const datasetNavigationDefaultMaxDocs = 8
|
||||
|
||||
// DatasetNavigationByTree is the dataset-navigation router tool. Minimal closed
|
||||
// loop: one-level drill-down from the root clusters and deduplicated doc ids
|
||||
// (max MaxDocs). LLM-guided multi-level selection is deferred.
|
||||
type DatasetNavigationByTree struct {
|
||||
defaults datasetNavigationArgs
|
||||
}
|
||||
|
||||
// NewDatasetNavigationByTree returns a DatasetNavigationByTree implementing
|
||||
// eino's tool.InvokableTool interface.
|
||||
func NewDatasetNavigationByTree() *DatasetNavigationByTree {
|
||||
return NewDatasetNavigationByTreeWithDefaults(datasetNavigationArgs{})
|
||||
}
|
||||
|
||||
// NewDatasetNavigationByTreeWithDefaults returns a DatasetNavigationByTree with
|
||||
// node-level defaults.
|
||||
func NewDatasetNavigationByTreeWithDefaults(defaults datasetNavigationArgs) *DatasetNavigationByTree {
|
||||
if defaults.MaxDocs <= 0 {
|
||||
defaults.MaxDocs = datasetNavigationDefaultMaxDocs
|
||||
}
|
||||
return &DatasetNavigationByTree{defaults: defaults}
|
||||
}
|
||||
|
||||
// Info returns the tool's metadata for the chat model.
|
||||
func (d *DatasetNavigationByTree) Info(_ context.Context) (*schema.ToolInfo, error) {
|
||||
return &schema.ToolInfo{
|
||||
Name: datasetNavigationToolName,
|
||||
Desc: datasetNavigationToolDescription,
|
||||
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
|
||||
"topic": {
|
||||
Type: schema.String,
|
||||
Desc: "The topic to navigate to. Use the core subject from the original request.",
|
||||
Required: true,
|
||||
},
|
||||
"keywords": {
|
||||
Type: schema.String,
|
||||
Desc: "Optional additional keywords to disambiguate the topic.",
|
||||
},
|
||||
}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// InvokableRun executes the tool. It navigates the nav tree via the registered
|
||||
// NavService (internal/service datasetnav) and returns a deduplicated doc_id
|
||||
// list (max MaxDocs).
|
||||
func (d *DatasetNavigationByTree) InvokableRun(ctx context.Context, argumentsInJSON string, _ ...einotool.Option) (string, error) {
|
||||
var args datasetNavigationArgs
|
||||
if argumentsInJSON != "" {
|
||||
if err := json.Unmarshal([]byte(argumentsInJSON), &args); err != nil {
|
||||
return "", fmt.Errorf("dataset_navigation: parse arguments: %w", err)
|
||||
}
|
||||
}
|
||||
args = d.mergeDefaults(args)
|
||||
if args.Topic == "" {
|
||||
return "", fmt.Errorf("dataset_navigation: topic is required")
|
||||
}
|
||||
// Per-request max_docs overrides the node default; default to a sane cap.
|
||||
maxDocs := args.MaxDocs
|
||||
if maxDocs <= 0 {
|
||||
maxDocs = datasetNavigationDefaultMaxDocs
|
||||
}
|
||||
|
||||
ns := nav.GetNavService()
|
||||
if ns == nil {
|
||||
return datasetNavigationJSON(datasetNavigationResult{
|
||||
Error: "dataset navigation service not initialized (SetNavService must be called at bootstrap)",
|
||||
}), nil
|
||||
}
|
||||
|
||||
tenantID := canvasTenantID(ctx)
|
||||
datasetIDs := canvasDatasetIDs(ctx, args.DatasetIDs)
|
||||
if tenantID == "" || len(datasetIDs) == 0 {
|
||||
return datasetNavigationJSON(datasetNavigationResult{
|
||||
NotFound: true,
|
||||
Error: "dataset navigation requires a tenant and dataset context",
|
||||
}), nil
|
||||
}
|
||||
|
||||
// Route RELEVANT docs by querying the nav tree with the topic (semantic KNN).
|
||||
// The topic is the routing signal — we must not return arbitrary doc ids.
|
||||
query := strings.TrimSpace(args.Topic + " " + args.Keywords)
|
||||
seen := map[string]struct{}{}
|
||||
var docs []string
|
||||
collect := func(id string) {
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
return
|
||||
}
|
||||
if len(docs) >= maxDocs {
|
||||
return
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
docs = append(docs, id)
|
||||
}
|
||||
|
||||
// Primary: semantic search over each dataset's nav tree.
|
||||
for _, datasetID := range datasetIDs {
|
||||
hits, err := ns.Search(ctx, tenantID, datasetID, query, nil, maxDocs)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, h := range hits {
|
||||
collect(h.DocID)
|
||||
for _, id := range h.DocIDs {
|
||||
collect(id)
|
||||
}
|
||||
}
|
||||
if len(docs) >= maxDocs {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if semantic routing found nothing (e.g. no embedder), walk the
|
||||
// root clusters so the tool still returns a useful (if coarse) doc set.
|
||||
if len(docs) == 0 {
|
||||
for _, datasetID := range datasetIDs {
|
||||
clusters, _, err := ns.ListClusters(ctx, tenantID, datasetID, 0, 100)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, c := range clusters {
|
||||
children, _, err := ns.ListChildren(ctx, tenantID, datasetID, c.Name, 0, 100)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, ch := range children {
|
||||
collect(ch.DocID)
|
||||
if len(docs) >= maxDocs {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(docs) >= maxDocs {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(docs) >= maxDocs {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(docs) == 0 {
|
||||
return datasetNavigationJSON(datasetNavigationResult{NotFound: true}), nil
|
||||
}
|
||||
return datasetNavigationJSON(datasetNavigationResult{Docs: docs}), nil
|
||||
}
|
||||
|
||||
func (d *DatasetNavigationByTree) mergeDefaults(args datasetNavigationArgs) datasetNavigationArgs {
|
||||
if len(args.DatasetIDs) == 0 && len(d.defaults.DatasetIDs) != 0 {
|
||||
args.DatasetIDs = append([]string(nil), d.defaults.DatasetIDs...)
|
||||
}
|
||||
if args.MaxDocs <= 0 {
|
||||
args.MaxDocs = d.defaults.MaxDocs
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func datasetNavigationJSON(r datasetNavigationResult) string {
|
||||
b, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
return fmt.Sprintf(`{"_ERROR":"dataset_navigation: marshal result: %s"}`, err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
134
internal/agent/tool/dataset_navigation_test.go
Normal file
134
internal/agent/tool/dataset_navigation_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package tool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"ragflow/internal/agent/runtime"
|
||||
"ragflow/internal/service/nav"
|
||||
)
|
||||
|
||||
// navRoutingFake is a nav.NavService that records Search calls (topic) and
|
||||
// returns a controlled doc list, so a test can assert the router actually
|
||||
// queries by topic rather than walking arbitrary clusters.
|
||||
type navRoutingFake struct {
|
||||
mu sync.Mutex
|
||||
searched []string // topics passed to Search
|
||||
hits []nav.NavHit
|
||||
clusters []nav.NavNode
|
||||
children map[string][]nav.NavNode
|
||||
}
|
||||
|
||||
func (f *navRoutingFake) UpsertDoc(context.Context, nav.UpsertDocInput) error { return nil }
|
||||
func (f *navRoutingFake) RemoveDoc(context.Context, string, string, string) error {
|
||||
return nil
|
||||
}
|
||||
func (f *navRoutingFake) Search(_ context.Context, _, _ string, query string, _ []float32, _ int) ([]nav.NavHit, error) {
|
||||
f.mu.Lock()
|
||||
f.searched = append(f.searched, query)
|
||||
f.mu.Unlock()
|
||||
return f.hits, nil
|
||||
}
|
||||
func (f *navRoutingFake) ListClusters(context.Context, string, string, int, int) ([]nav.NavNode, int64, error) {
|
||||
return f.clusters, int64(len(f.clusters)), nil
|
||||
}
|
||||
func (f *navRoutingFake) ListChildren(_ context.Context, _, _, name string, _, _ int) ([]nav.NavNode, int64, error) {
|
||||
return f.children[name], int64(len(f.children[name])), nil
|
||||
}
|
||||
func (f *navRoutingFake) searchedTopics() []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]string(nil), f.searched...)
|
||||
}
|
||||
|
||||
// TestDatasetNavigation_UsesTopicRouting asserts the router queries the nav tree
|
||||
// with the topic (semantic search), rather than blindly walking clusters and
|
||||
// returning arbitrary doc ids.
|
||||
func TestDatasetNavigation_UsesTopicRouting(t *testing.T) {
|
||||
fake := &navRoutingFake{
|
||||
hits: []nav.NavHit{
|
||||
{Type: "nav_doc", DocID: "d1", Name: "rocket"},
|
||||
{Type: "nav_doc", DocID: "d2", Name: "engine"},
|
||||
},
|
||||
}
|
||||
prev := nav.GetNavService()
|
||||
nav.SetNavService(fake)
|
||||
defer func() { nav.SetNavService(prev) }()
|
||||
|
||||
state := runtime.NewCanvasState("run-1", "task-1")
|
||||
state.Sys["tenant_id"] = "tenant-1"
|
||||
ctx := runtime.WithState(context.Background(), state)
|
||||
|
||||
tool := NewDatasetNavigationByTree()
|
||||
out, err := tool.InvokableRun(ctx, `{"topic":"rocket propulsion","keywords":"engine","dataset_ids":["kb1"]}`)
|
||||
if err != nil {
|
||||
t.Fatalf("InvokableRun: %v", err)
|
||||
}
|
||||
// The topic (plus keywords) must have been used as the Search query.
|
||||
topics := fake.searchedTopics()
|
||||
if len(topics) == 0 {
|
||||
t.Fatal("Search was never called; router must route by topic")
|
||||
}
|
||||
if topics[0] != "rocket propulsion engine" {
|
||||
t.Errorf("search query = %q, want topic+keywords", topics[0])
|
||||
}
|
||||
// The returned docs come from the relevant hits, not arbitrary walk.
|
||||
if !containsStr(out, "d1") || !containsStr(out, "d2") {
|
||||
t.Errorf("routed docs missing hits: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCanvasDatasetIDs_MultiKB asserts all explicit dataset ids are preserved
|
||||
// (a multi-KB session must not collapse to the first KB).
|
||||
func TestCanvasDatasetIDs_MultiKB(t *testing.T) {
|
||||
ids := canvasDatasetIDs(context.Background(), []string{"kb1", "kb2", "kb3"})
|
||||
if len(ids) != 3 || ids[0] != "kb1" || ids[1] != "kb2" || ids[2] != "kb3" {
|
||||
t.Errorf("canvasDatasetIDs = %v, want all three KBs", ids)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDatasetNavigation_MultiKB asserts the router searches EVERY bound dataset
|
||||
// (not just the first), so docs in other KBs stay reachable.
|
||||
func TestDatasetNavigation_MultiKB(t *testing.T) {
|
||||
fake := &navRoutingFake{hits: []nav.NavHit{{Type: "nav_doc", DocID: "d1", Name: "topic"}}}
|
||||
prev := nav.GetNavService()
|
||||
nav.SetNavService(fake)
|
||||
defer func() { nav.SetNavService(prev) }()
|
||||
|
||||
state := runtime.NewCanvasState("run-1", "task-1")
|
||||
state.Sys["tenant_id"] = "tenant-1"
|
||||
ctx := runtime.WithState(context.Background(), state)
|
||||
|
||||
tool := NewDatasetNavigationByTree()
|
||||
_, err := tool.InvokableRun(ctx, `{"topic":"X","dataset_ids":["kb1","kb2","kb3"]}`)
|
||||
if err != nil {
|
||||
t.Fatalf("InvokableRun: %v", err)
|
||||
}
|
||||
// Search must have been called once per dataset (3 calls), not collapsed to
|
||||
// the first KB.
|
||||
if got := len(fake.searchedTopics()); got != 3 {
|
||||
t.Errorf("Search called %d times, want 3 (once per dataset)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCanvasDatasetIDs_DedupEmpty asserts empty ids are dropped.
|
||||
func TestCanvasDatasetIDs_DedupEmpty(t *testing.T) {
|
||||
ids := canvasDatasetIDs(context.Background(), []string{"kb1", "", "kb2"})
|
||||
if len(ids) != 2 || ids[0] != "kb1" || ids[1] != "kb2" {
|
||||
t.Errorf("canvasDatasetIDs = %v, want [kb1 kb2]", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func containsStr(s, sub string) bool {
|
||||
return len(s) > 0 && len(sub) > 0 && (s == sub || containsSub(s, sub))
|
||||
}
|
||||
|
||||
func containsSub(s, sub string) bool {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -31,32 +31,36 @@ import (
|
||||
type Factory func(params map[string]any) (einotool.BaseTool, error)
|
||||
|
||||
var registry = map[string]Factory{
|
||||
"akshare": buildAkShareTool,
|
||||
"arxiv": buildArxivTool,
|
||||
"bgpt": buildBGPTTool,
|
||||
"code_exec": noConfig("code_exec", func() einotool.BaseTool { return NewCodeExecTool() }),
|
||||
"crawler": noConfig("crawler", func() einotool.BaseTool { return NewCrawlerTool() }),
|
||||
"deepl": noConfig("deepl", func() einotool.BaseTool { return NewDeepLTool() }),
|
||||
"duckduckgo": buildDuckDuckGoTool,
|
||||
"email": buildEmailTool,
|
||||
"execute_sql": buildExeSQLTool,
|
||||
"exesql": buildExeSQLTool,
|
||||
"github": buildGitHubTool,
|
||||
"google": buildGoogleTool,
|
||||
"google_scholar": buildGoogleScholarTool,
|
||||
"google_scholar_search": buildGoogleScholarTool,
|
||||
"jin10": noConfig("jin10", func() einotool.BaseTool { return NewJin10Tool() }),
|
||||
"keenable": buildKeenableTool,
|
||||
"pubmed": buildPubMedTool,
|
||||
"qweather": noConfig("qweather", func() einotool.BaseTool { return NewQWeatherTool() }),
|
||||
"querit": buildQueritTool,
|
||||
"querit_search": buildQueritTool,
|
||||
"queritsearch": buildQueritTool,
|
||||
"retrieval": buildRetrievalTool,
|
||||
"search_my_dataset": buildRetrievalTool,
|
||||
"search_my_dateset": buildRetrievalTool,
|
||||
"searxng": buildSearXNGTool,
|
||||
"tavily": buildTavilyTool,
|
||||
"akshare": buildAkShareTool,
|
||||
"arxiv": buildArxivTool,
|
||||
"bgpt": buildBGPTTool,
|
||||
"code_exec": noConfig("code_exec", func() einotool.BaseTool { return NewCodeExecTool() }),
|
||||
"crawler": noConfig("crawler", func() einotool.BaseTool { return NewCrawlerTool() }),
|
||||
"dataset_navigation_by_tree": noConfig("dataset_navigation_by_tree", func() einotool.BaseTool { return NewDatasetNavigationByTree() }),
|
||||
"hybrid_search": noConfig("hybrid_search", func() einotool.BaseTool { return NewAgenticSearchTool(toolHybridSearch) }),
|
||||
"vector_search": noConfig("vector_search", func() einotool.BaseTool { return NewAgenticSearchTool(toolVectorSearch) }),
|
||||
"bm25_search": noConfig("bm25_search", func() einotool.BaseTool { return NewAgenticSearchTool(toolBM25Search) }),
|
||||
"deepl": noConfig("deepl", func() einotool.BaseTool { return NewDeepLTool() }),
|
||||
"duckduckgo": buildDuckDuckGoTool,
|
||||
"email": buildEmailTool,
|
||||
"execute_sql": buildExeSQLTool,
|
||||
"exesql": buildExeSQLTool,
|
||||
"github": buildGitHubTool,
|
||||
"google": buildGoogleTool,
|
||||
"google_scholar": buildGoogleScholarTool,
|
||||
"google_scholar_search": buildGoogleScholarTool,
|
||||
"jin10": noConfig("jin10", func() einotool.BaseTool { return NewJin10Tool() }),
|
||||
"keenable": buildKeenableTool,
|
||||
"pubmed": buildPubMedTool,
|
||||
"qweather": noConfig("qweather", func() einotool.BaseTool { return NewQWeatherTool() }),
|
||||
"querit": buildQueritTool,
|
||||
"querit_search": buildQueritTool,
|
||||
"queritsearch": buildQueritTool,
|
||||
"retrieval": buildRetrievalTool,
|
||||
"search_my_dataset": buildRetrievalTool,
|
||||
"search_my_dateset": buildRetrievalTool,
|
||||
"searxng": buildSearXNGTool,
|
||||
"tavily": buildTavilyTool,
|
||||
// Agent DSL tool lists carry the Python Canvas component_name verbatim.
|
||||
// BuildByName lower-cases names, so register those component names too.
|
||||
"tavilysearch": buildTavilyTool,
|
||||
|
||||
@@ -162,6 +162,7 @@ func nlpRequestFromRetrieval(req RetrievalRequest, tenantIDs []string, topN int)
|
||||
Question: req.Query,
|
||||
TenantIDs: append([]string(nil), tenantIDs...),
|
||||
KbIDs: append([]string(nil), req.DatasetIDs...),
|
||||
DocIDs: append([]string(nil), compactStrings(req.DocScope)...),
|
||||
Page: 1,
|
||||
PageSize: topN,
|
||||
Aggs: boolPtr(false),
|
||||
|
||||
@@ -55,6 +55,9 @@ type RetrievalRequest struct {
|
||||
KeywordsSimilarityWeight *float64
|
||||
UseKG bool
|
||||
SimilarityThreshold float64
|
||||
// DocScope restricts retrieval to a set of document ids (the doc_id list
|
||||
// routed by the dataset_navigation_by_tree tool). Empty = no doc filter.
|
||||
DocScope []string
|
||||
// TenantID is the calling tenant (== user_id in RAGFlow's data model).
|
||||
// Optional for the nlp adapter; the KG adapter uses it to resolve the
|
||||
// tenant's default chat + embedding models. Reads from
|
||||
|
||||
Reference in New Issue
Block a user