mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-08 00:18:12 +08:00
fix(go-agent): finish retrieval component (#17845)
- Prefer canonical `dataset_ids` over legacy `kb_ids` in Canvas retrieval components. - Cover selected and explicitly cleared dataset IDs with regression tests. - Add cross-language retrieval with dataset-specific embedding and rerank models. - Support vector and keyword similarity controls, metadata filtering, TOC enhancement, and child-chunk expansion. - Route Canvas retrieval across datasets and memories with compatible embedding validation.
This commit is contained in:
@@ -159,6 +159,62 @@ func TestRetrieval_KbIDsTranslatedToDatasetIDs(t *testing.T) {
|
||||
if !ok || len(ds3) != 1 || ds3[0] != "kb-new" {
|
||||
t.Errorf("dataset_ids should keep call-time value %v, got %v", "kb-new", merged3["dataset_ids"])
|
||||
}
|
||||
|
||||
// Case 4: canonical node-level dataset_ids override stale kb_ids.
|
||||
canonical, err := newRetrievalComponent(map[string]any{
|
||||
"dataset_ids": []any{"kb-current"},
|
||||
"kb_ids": []any{"kb-stale"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("newRetrievalComponent with canonical dataset_ids: %v", err)
|
||||
}
|
||||
merged4 := canonical.(*retrievalComponent).applyDefaults(nil)
|
||||
if ds, ok := merged4["dataset_ids"].([]any); !ok || len(ds) != 1 || ds[0] != "kb-current" {
|
||||
t.Errorf("node dataset_ids should override stale kb_ids, got %v", merged4["dataset_ids"])
|
||||
}
|
||||
|
||||
// Case 5: an explicitly empty canonical list clears stale kb_ids.
|
||||
cleared, err := newRetrievalComponent(map[string]any{
|
||||
"dataset_ids": []any{},
|
||||
"kb_ids": []any{"kb-stale"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("newRetrievalComponent with empty dataset_ids: %v", err)
|
||||
}
|
||||
merged5 := cleared.(*retrievalComponent).applyDefaults(nil)
|
||||
if _, ok := merged5["dataset_ids"]; ok {
|
||||
t.Errorf("empty dataset_ids should clear stale kb_ids, got %v", merged5["dataset_ids"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetrieval_NodeQueryResolvedFromCanvasState(t *testing.T) {
|
||||
previous := agenttool.GetRetrievalService()
|
||||
agenttool.SetSimpleRetrievalService()
|
||||
t.Cleanup(func() { agenttool.SetRetrievalService(previous) })
|
||||
|
||||
c, err := newRetrievalComponent(map[string]any{
|
||||
"query": "{sys.query}",
|
||||
"kb_ids": []any{"kb-1"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("newRetrievalComponent: %v", err)
|
||||
}
|
||||
|
||||
state := runtime.NewCanvasState("run-1", "session-1")
|
||||
state.Sys["query"] = "AirPure X200 vs AirPure X300"
|
||||
ctx := runtime.WithState(context.Background(), state)
|
||||
out, err := c.Invoke(ctx, nil, map[string]any{
|
||||
"category": "Product Feature Comparison",
|
||||
"category_name": "Product Feature Comparison",
|
||||
"_next": []string{"Retrieval:EightyDaysHappen"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
formalizedContent, _ := out["formalized_content"].(string)
|
||||
if !strings.Contains(formalizedContent, "AirPure X200 vs AirPure X300") {
|
||||
t.Fatalf("formalized_content = %q, want resolved canvas query", formalizedContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetrieval_LegacyQueryStringNormalized(t *testing.T) {
|
||||
|
||||
149
internal/agent/component/retrieval_params_test.go
Normal file
149
internal/agent/component/retrieval_params_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
//
|
||||
// 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 component
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
agenttool "ragflow/internal/agent/tool"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type retrievalRequestRecorder struct {
|
||||
request agenttool.RetrievalRequest
|
||||
}
|
||||
|
||||
func (r *retrievalRequestRecorder) Search(_ context.Context, _ *gorm.DB, request agenttool.RetrievalRequest) ([]agenttool.RetrievalChunk, error) {
|
||||
r.request = request
|
||||
return []agenttool.RetrievalChunk{}, nil
|
||||
}
|
||||
|
||||
func TestRetrievalComponentAppliesAdvancedNodeParams(t *testing.T) {
|
||||
component, err := newRetrievalComponent(map[string]any{
|
||||
"dataset_ids": []any{"dataset-1"},
|
||||
"memory_ids": []any{"memory-1", "memory-2"},
|
||||
"cross_languages": []any{"English", "Chinese", "Spanish"},
|
||||
"toc_enhance": true,
|
||||
"use_kg": true,
|
||||
"meta_data_filter": map[string]any{"method": "manual"},
|
||||
"retrieval_from": "memory",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("newRetrievalComponent: %v", err)
|
||||
}
|
||||
|
||||
merged := component.(*retrievalComponent).applyDefaults(nil)
|
||||
if got := merged["memory_ids"]; !reflect.DeepEqual(got, []string{"memory-1", "memory-2"}) {
|
||||
t.Errorf("memory_ids = %#v", got)
|
||||
}
|
||||
if got := merged["cross_languages"]; !reflect.DeepEqual(got, []string{"English", "Chinese", "Spanish"}) {
|
||||
t.Errorf("cross_languages = %#v", got)
|
||||
}
|
||||
if got := merged["toc_enhance"]; got != true {
|
||||
t.Errorf("toc_enhance = %#v", got)
|
||||
}
|
||||
if got := merged["use_kg"]; got != true {
|
||||
t.Errorf("use_kg = %#v", got)
|
||||
}
|
||||
if got := merged["meta_data_filter"]; !reflect.DeepEqual(got, map[string]any{"method": "manual"}) {
|
||||
t.Errorf("meta_data_filter = %#v", got)
|
||||
}
|
||||
if got := merged["retrieval_from"]; got != "memory" {
|
||||
t.Errorf("retrieval_from = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetrievalComponentForwardsAdvancedNodeParams(t *testing.T) {
|
||||
previous := agenttool.GetRetrievalService()
|
||||
recorder := &retrievalRequestRecorder{}
|
||||
agenttool.SetRetrievalService(recorder)
|
||||
t.Cleanup(func() { agenttool.SetRetrievalService(previous) })
|
||||
|
||||
component, err := newRetrievalComponent(map[string]any{
|
||||
"dataset_ids": []any{"dataset-1"},
|
||||
"memory_ids": []any{"memory-1"},
|
||||
"cross_languages": []any{"English", "Chinese", "Spanish"},
|
||||
"toc_enhance": true,
|
||||
"use_kg": false,
|
||||
"meta_data_filter": map[string]any{"method": "manual"},
|
||||
"retrieval_from": "dataset",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("newRetrievalComponent: %v", err)
|
||||
}
|
||||
|
||||
if _, err := component.Invoke(context.Background(), nil, map[string]any{"query": "爱"}); err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
|
||||
request := recorder.request
|
||||
if !reflect.DeepEqual(request.MemoryIDs, []string{"memory-1"}) {
|
||||
t.Errorf("MemoryIDs = %#v", request.MemoryIDs)
|
||||
}
|
||||
if !reflect.DeepEqual(request.CrossLanguages, []string{"English", "Chinese", "Spanish"}) {
|
||||
t.Errorf("CrossLanguages = %#v", request.CrossLanguages)
|
||||
}
|
||||
if !request.TOCEnhance {
|
||||
t.Error("TOCEnhance = false")
|
||||
}
|
||||
if request.UseKG {
|
||||
t.Error("UseKG = true")
|
||||
}
|
||||
if !reflect.DeepEqual(request.MetaDataFilter, map[string]any{"method": "manual"}) {
|
||||
t.Errorf("MetaDataFilter = %#v", request.MetaDataFilter)
|
||||
}
|
||||
if request.RetrievalFrom != "dataset" {
|
||||
t.Errorf("RetrievalFrom = %q", request.RetrievalFrom)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetrievalComponentForwardsExplicitZeroSimilarityParams(t *testing.T) {
|
||||
previous := agenttool.GetRetrievalService()
|
||||
recorder := &retrievalRequestRecorder{}
|
||||
agenttool.SetRetrievalService(recorder)
|
||||
t.Cleanup(func() { agenttool.SetRetrievalService(previous) })
|
||||
|
||||
component, err := newRetrievalComponent(map[string]any{
|
||||
"dataset_ids": []any{"dataset-1"},
|
||||
"similarity_threshold": 0,
|
||||
"keywords_similarity_weight": 0,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("newRetrievalComponent: %v", err)
|
||||
}
|
||||
|
||||
merged := component.(*retrievalComponent).applyDefaults(nil)
|
||||
if value, ok := merged["similarity_threshold"]; !ok || value != float64(0) {
|
||||
t.Fatalf("similarity_threshold = %#v, present = %v; want explicit zero", value, ok)
|
||||
}
|
||||
if value, ok := merged["keywords_similarity_weight"]; !ok || value != float64(0) {
|
||||
t.Fatalf("keywords_similarity_weight = %#v, present = %v; want explicit zero", value, ok)
|
||||
}
|
||||
|
||||
if _, err := component.Invoke(context.Background(), nil, map[string]any{"query": "zero"}); err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if recorder.request.SimilarityThreshold == nil || *recorder.request.SimilarityThreshold != 0 {
|
||||
t.Fatalf("SimilarityThreshold = %v; want explicit zero", recorder.request.SimilarityThreshold)
|
||||
}
|
||||
if recorder.request.KeywordsSimilarityWeight == nil || *recorder.request.KeywordsSimilarityWeight != 0 {
|
||||
t.Fatalf("KeywordsSimilarityWeight = %v; want explicit zero", recorder.request.KeywordsSimilarityWeight)
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,9 @@ func TestSearchMyDataset_AliasDelegatesToRealWrapper(t *testing.T) {
|
||||
agenttool.SetSimpleRetrievalService()
|
||||
t.Cleanup(func() { agenttool.SetRetrievalService(prev) })
|
||||
|
||||
c, err := New("SearchMyDataset", nil)
|
||||
c, err := New("SearchMyDataset", map[string]any{
|
||||
"kb_ids": []any{"kb-1"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New(SearchMyDataset) errored: %v", err)
|
||||
}
|
||||
|
||||
@@ -65,13 +65,20 @@ func anySlice(v any) []any {
|
||||
// defaults to the per-invocation RetrievalRequest. The fields are
|
||||
// the same the Python agent/component/retrieval.py exposes.
|
||||
type retrievalParams struct {
|
||||
Query string
|
||||
KbIDs []string
|
||||
MemoryIDs []string
|
||||
TopN int
|
||||
TopK int
|
||||
SimilarityThreshold float64
|
||||
KeywordsSimilarityWeight float64
|
||||
SimilarityThreshold *float64
|
||||
KeywordsSimilarityWeight *float64
|
||||
RerankID string
|
||||
EmptyResponse string
|
||||
CrossLanguages []string
|
||||
TOCEnhance bool
|
||||
UseKG bool
|
||||
MetaDataFilter map[string]any
|
||||
RetrievalFrom string
|
||||
}
|
||||
|
||||
// parseRetrievalParams reads the v1 DSL node params for Retrieval.
|
||||
@@ -84,6 +91,12 @@ func parseRetrievalParams(params map[string]any) retrievalParams {
|
||||
if params == nil {
|
||||
return out
|
||||
}
|
||||
if ids, ok := params["dataset_ids"]; ok {
|
||||
params["kb_ids"] = ids
|
||||
}
|
||||
if v, ok := params["query"].(string); ok {
|
||||
out.Query = v
|
||||
}
|
||||
if v, ok := params["kb_ids"].([]any); ok {
|
||||
for _, x := range v {
|
||||
if s, ok := x.(string); ok {
|
||||
@@ -94,6 +107,7 @@ func parseRetrievalParams(params map[string]any) retrievalParams {
|
||||
if v, ok := params["kb_ids"].([]string); ok {
|
||||
out.KbIDs = append(out.KbIDs, v...)
|
||||
}
|
||||
out.MemoryIDs = toStringSlice(params["memory_ids"])
|
||||
if v, ok := params["top_n"]; ok {
|
||||
out.TopN = toIntParam(v)
|
||||
}
|
||||
@@ -101,10 +115,12 @@ func parseRetrievalParams(params map[string]any) retrievalParams {
|
||||
out.TopK = toIntParam(v)
|
||||
}
|
||||
if v, ok := params["similarity_threshold"]; ok {
|
||||
out.SimilarityThreshold = toFloatParam(v)
|
||||
value := toFloatParam(v)
|
||||
out.SimilarityThreshold = &value
|
||||
}
|
||||
if v, ok := params["keywords_similarity_weight"]; ok {
|
||||
out.KeywordsSimilarityWeight = toFloatParam(v)
|
||||
value := toFloatParam(v)
|
||||
out.KeywordsSimilarityWeight = &value
|
||||
}
|
||||
if v, ok := params["rerank_id"].(string); ok {
|
||||
out.RerankID = v
|
||||
@@ -112,14 +128,25 @@ func parseRetrievalParams(params map[string]any) retrievalParams {
|
||||
if v, ok := params["empty_response"].(string); ok {
|
||||
out.EmptyResponse = v
|
||||
}
|
||||
out.CrossLanguages = toStringSlice(params["cross_languages"])
|
||||
if v, ok := params["toc_enhance"].(bool); ok {
|
||||
out.TOCEnhance = v
|
||||
}
|
||||
if v, ok := params["use_kg"].(bool); ok {
|
||||
out.UseKG = v
|
||||
}
|
||||
if v, ok := params["meta_data_filter"].(map[string]any); ok {
|
||||
out.MetaDataFilter = cloneAnyMap(v)
|
||||
}
|
||||
if v, ok := params["retrieval_from"].(string); ok {
|
||||
out.RetrievalFrom = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// retrievalComponent delegates to internal/agent/tool/RetrievalTool.
|
||||
// The wrapper captures the v1 DSL node params (kb_ids, top_n,
|
||||
// top_k, similarity_threshold, keywords_similarity_weight,
|
||||
// rerank_id, empty_response) at build time and applies them as
|
||||
// defaults to each invocation. Per-call inputs override the
|
||||
// The wrapper captures the Retrieval node's DSL params at build time and
|
||||
// applies them as defaults to each invocation. Per-call inputs override the
|
||||
// defaults.
|
||||
type retrievalComponent struct {
|
||||
inner *agenttool.RetrievalTool
|
||||
@@ -213,6 +240,9 @@ func (c *retrievalComponent) applyDefaults(inputs map[string]any) map[string]any
|
||||
for k, v := range inputs {
|
||||
out[k] = v
|
||||
}
|
||||
if _, ok := out["query"]; !ok && c.params.Query != "" {
|
||||
out["query"] = c.params.Query
|
||||
}
|
||||
if _, ok := out["kb_ids"]; !ok && len(c.params.KbIDs) > 0 {
|
||||
ids := make([]any, len(c.params.KbIDs))
|
||||
for i, s := range c.params.KbIDs {
|
||||
@@ -226,11 +256,11 @@ func (c *retrievalComponent) applyDefaults(inputs map[string]any) map[string]any
|
||||
if _, ok := out["top_k"]; !ok && c.params.TopK > 0 {
|
||||
out["top_k"] = c.params.TopK
|
||||
}
|
||||
if _, ok := out["similarity_threshold"]; !ok && c.params.SimilarityThreshold > 0 {
|
||||
out["similarity_threshold"] = c.params.SimilarityThreshold
|
||||
if _, ok := out["similarity_threshold"]; !ok && c.params.SimilarityThreshold != nil {
|
||||
out["similarity_threshold"] = *c.params.SimilarityThreshold
|
||||
}
|
||||
if _, ok := out["keywords_similarity_weight"]; !ok && c.params.KeywordsSimilarityWeight > 0 {
|
||||
out["keywords_similarity_weight"] = c.params.KeywordsSimilarityWeight
|
||||
if _, ok := out["keywords_similarity_weight"]; !ok && c.params.KeywordsSimilarityWeight != nil {
|
||||
out["keywords_similarity_weight"] = *c.params.KeywordsSimilarityWeight
|
||||
}
|
||||
if _, ok := out["rerank_id"]; !ok && c.params.RerankID != "" {
|
||||
out["rerank_id"] = c.params.RerankID
|
||||
@@ -238,6 +268,24 @@ func (c *retrievalComponent) applyDefaults(inputs map[string]any) map[string]any
|
||||
if _, ok := out["empty_response"]; !ok && c.params.EmptyResponse != "" {
|
||||
out["empty_response"] = c.params.EmptyResponse
|
||||
}
|
||||
if _, ok := out["memory_ids"]; !ok && len(c.params.MemoryIDs) > 0 {
|
||||
out["memory_ids"] = append([]string(nil), c.params.MemoryIDs...)
|
||||
}
|
||||
if _, ok := out["cross_languages"]; !ok && len(c.params.CrossLanguages) > 0 {
|
||||
out["cross_languages"] = append([]string(nil), c.params.CrossLanguages...)
|
||||
}
|
||||
if _, ok := out["toc_enhance"]; !ok && c.params.TOCEnhance {
|
||||
out["toc_enhance"] = true
|
||||
}
|
||||
if _, ok := out["use_kg"]; !ok && c.params.UseKG {
|
||||
out["use_kg"] = true
|
||||
}
|
||||
if _, ok := out["meta_data_filter"]; !ok && c.params.MetaDataFilter != nil {
|
||||
out["meta_data_filter"] = cloneAnyMap(c.params.MetaDataFilter)
|
||||
}
|
||||
if _, ok := out["retrieval_from"]; !ok && c.params.RetrievalFrom != "" {
|
||||
out["retrieval_from"] = c.params.RetrievalFrom
|
||||
}
|
||||
// Translate v1 DSL name `kb_ids` to the tool's expected
|
||||
// name `dataset_ids`. dataset_ids already-set wins; kb_ids
|
||||
// is consumed and removed so the marshalled JSON carries a
|
||||
|
||||
Reference in New Issue
Block a user