mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-08 00:18:12 +08:00
- 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.
467 lines
15 KiB
Go
467 lines
15 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 tool
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"ragflow/internal/agent/runtime"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func TestRetrieval_StubsErrorWhenServiceMissing(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rt := NewRetrievalTool()
|
|
out, err := rt.InvokableRun(context.Background(), `{"query":"hello","dataset_ids":["kb-1"]}`)
|
|
if err == nil {
|
|
t.Fatal("expected stub error, got nil")
|
|
}
|
|
if !errors.Is(err, ErrRetrievalServiceMissing) {
|
|
t.Fatalf("err = %v, want ErrRetrievalServiceMissing", err)
|
|
}
|
|
|
|
// Output is a JSON envelope with the stub error message.
|
|
var got retrievalResult
|
|
if jerr := json.Unmarshal([]byte(out), &got); jerr != nil {
|
|
t.Fatalf("output is not valid JSON: %v (raw=%s)", jerr, out)
|
|
}
|
|
if !got.Stub {
|
|
t.Errorf("Stub = false, want true")
|
|
}
|
|
if !strings.Contains(got.Error, "service not yet implemented") {
|
|
t.Errorf("Error = %q, want to mention 'service not yet implemented'", got.Error)
|
|
}
|
|
}
|
|
|
|
func TestRetrieval_RejectsUseKG(t *testing.T) {
|
|
t.Parallel()
|
|
rt := NewRetrievalTool()
|
|
out, err := rt.InvokableRun(context.Background(), `{"query":"x","use_kg":true}`)
|
|
if !errors.Is(err, ErrGraphRAGNotSupported) {
|
|
t.Fatalf("err = %v, want ErrGraphRAGNotSupported", err)
|
|
}
|
|
if !strings.Contains(out, "GraphRAG") {
|
|
t.Errorf("output %q should mention GraphRAG", out)
|
|
}
|
|
}
|
|
|
|
func TestRetrieval_InfoMatchesPythonMeta(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rt := NewRetrievalTool()
|
|
info, err := rt.Info(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Info: %v", err)
|
|
}
|
|
if info.Name != "search_my_dateset" {
|
|
t.Errorf("Name = %q, want search_my_dateset (typo preserved)", info.Name)
|
|
}
|
|
if !strings.Contains(info.Desc, "datasets") {
|
|
t.Errorf("Desc = %q, want to mention 'datasets'", info.Desc)
|
|
}
|
|
// The query param must be present and required. ToJSONSchema returns
|
|
// a *jsonschema.Schema whose Properties is an *orderedmap.Map; we use
|
|
// MarshalJSON to assert the parameter set without depending on the
|
|
// map's concrete Get signature.
|
|
params, err := info.ToJSONSchema()
|
|
if err != nil {
|
|
t.Fatalf("ToJSONSchema: %v", err)
|
|
}
|
|
raw, err := json.Marshal(params)
|
|
if err != nil {
|
|
t.Fatalf("marshal schema: %v", err)
|
|
}
|
|
if !strings.Contains(string(raw), `"query"`) {
|
|
t.Errorf("schema JSON does not contain 'query' key: %s", raw)
|
|
}
|
|
for _, nodeConfig := range []string{"dataset_ids", "kb_ids", "top_n", "top_k", "similarity_threshold", "keywords_similarity_weight", "use_kg"} {
|
|
if strings.Contains(string(raw), `"`+nodeConfig+`"`) {
|
|
t.Errorf("schema JSON exposes Canvas node config %q to the model: %s", nodeConfig, raw)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRetrieval_EmptyArgsIsHandled(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rt := NewRetrievalTool()
|
|
out, err := rt.InvokableRun(context.Background(), "")
|
|
if err != nil {
|
|
t.Fatalf("InvokableRun: %v", err)
|
|
}
|
|
var result retrievalResult
|
|
if err := json.Unmarshal([]byte(out), &result); err != nil {
|
|
t.Fatalf("unmarshal result: %v", err)
|
|
}
|
|
if result.FormalizedContent != "" {
|
|
t.Fatalf("FormalizedContent = %q, want empty default", result.FormalizedContent)
|
|
}
|
|
}
|
|
|
|
func TestRetrieval_PassesTenantIDFromCanvasState(t *testing.T) {
|
|
prev := GetRetrievalService()
|
|
svc := &capturingRetrievalService{}
|
|
SetRetrievalService(svc)
|
|
t.Cleanup(func() { SetRetrievalService(prev) })
|
|
|
|
state := runtime.NewCanvasState("run-1", "task-1")
|
|
state.Sys["tenant_id"] = "tenant-1"
|
|
ctx := runtime.WithState(context.Background(), state)
|
|
|
|
rt := NewRetrievalTool()
|
|
_, err := rt.InvokableRun(ctx, `{"query":"hello","dataset_ids":["kb-1"]}`)
|
|
if err != nil {
|
|
t.Fatalf("InvokableRun: %v", err)
|
|
}
|
|
if svc.req.TenantID != "tenant-1" {
|
|
t.Fatalf("TenantID=%q want tenant-1", svc.req.TenantID)
|
|
}
|
|
}
|
|
|
|
func TestRetrieval_PassesUserIDWhenTenantIDMissing(t *testing.T) {
|
|
prev := GetRetrievalService()
|
|
svc := &capturingRetrievalService{}
|
|
SetRetrievalService(svc)
|
|
t.Cleanup(func() { SetRetrievalService(prev) })
|
|
|
|
state := runtime.NewCanvasState("run-1", "task-1")
|
|
state.Sys["user_id"] = "user-1"
|
|
ctx := runtime.WithState(context.Background(), state)
|
|
|
|
rt := NewRetrievalTool()
|
|
_, err := rt.InvokableRun(ctx, `{"query":"hello","dataset_ids":["kb-1"]}`)
|
|
if err != nil {
|
|
t.Fatalf("InvokableRun: %v", err)
|
|
}
|
|
if svc.req.TenantID != "user-1" {
|
|
t.Fatalf("TenantID=%q want user-1", svc.req.TenantID)
|
|
}
|
|
}
|
|
|
|
func TestRetrieval_UsesNodeParamsAsDefaults(t *testing.T) {
|
|
prev := GetRetrievalService()
|
|
svc := &capturingRetrievalService{}
|
|
SetRetrievalService(svc)
|
|
t.Cleanup(func() { SetRetrievalService(prev) })
|
|
|
|
built, err := BuildByName("retrieval", map[string]any{
|
|
"kb_ids": []any{"kb-1"},
|
|
"top_n": float64(3),
|
|
"top_k": float64(99),
|
|
"keywords_similarity_weight": 0.7,
|
|
"similarity_threshold": 0.42,
|
|
"rerank_id": "rerank@provider",
|
|
"cross_languages": []any{"English", "Chinese"},
|
|
"toc_enhance": true,
|
|
"meta_data_filter": map[string]any{"method": "manual"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("BuildByName(retrieval): %v", err)
|
|
}
|
|
rt, ok := built.(*RetrievalTool)
|
|
if !ok {
|
|
t.Fatalf("BuildByName(retrieval) returned %T, want *RetrievalTool", built)
|
|
}
|
|
|
|
_, err = rt.InvokableRun(context.Background(), `{"query":"hello"}`)
|
|
if err != nil {
|
|
t.Fatalf("InvokableRun: %v", err)
|
|
}
|
|
if len(svc.req.DatasetIDs) != 1 || svc.req.DatasetIDs[0] != "kb-1" {
|
|
t.Fatalf("DatasetIDs=%#v want [kb-1]", svc.req.DatasetIDs)
|
|
}
|
|
if svc.req.TopN != 3 {
|
|
t.Fatalf("TopN=%d want 3", svc.req.TopN)
|
|
}
|
|
if svc.req.TopK != 99 {
|
|
t.Fatalf("TopK=%d want 99", svc.req.TopK)
|
|
}
|
|
if svc.req.KeywordsSimilarityWeight == nil || *svc.req.KeywordsSimilarityWeight != 0.7 {
|
|
t.Fatalf("KeywordsSimilarityWeight=%v want 0.7", svc.req.KeywordsSimilarityWeight)
|
|
}
|
|
if svc.req.SimilarityThreshold == nil || *svc.req.SimilarityThreshold != 0.42 {
|
|
t.Fatalf("SimilarityThreshold=%v want 0.42", svc.req.SimilarityThreshold)
|
|
}
|
|
if svc.req.RerankID != "rerank@provider" {
|
|
t.Fatalf("RerankID=%q", svc.req.RerankID)
|
|
}
|
|
if len(svc.req.CrossLanguages) != 2 || svc.req.CrossLanguages[1] != "Chinese" {
|
|
t.Fatalf("CrossLanguages=%#v", svc.req.CrossLanguages)
|
|
}
|
|
if !svc.req.TOCEnhance || svc.req.MetaDataFilter["method"] != "manual" {
|
|
t.Fatalf("enhancement request=%#v", svc.req)
|
|
}
|
|
}
|
|
|
|
func TestRetrieval_ExplicitZeroSimilarityArgsOverrideDefaults(t *testing.T) {
|
|
previous := GetRetrievalService()
|
|
service := &capturingRetrievalService{}
|
|
SetRetrievalService(service)
|
|
t.Cleanup(func() { SetRetrievalService(previous) })
|
|
|
|
similarityThreshold := 0.42
|
|
keywordsSimilarityWeight := 0.7
|
|
retrievalTool := NewRetrievalToolWithDefaults(retrievalArgs{
|
|
DatasetIDs: []string{"kb-1"},
|
|
SimilarityThreshold: &similarityThreshold,
|
|
KeywordsSimilarityWeight: &keywordsSimilarityWeight,
|
|
})
|
|
_, err := retrievalTool.InvokableRun(context.Background(), `{"query":"hello","similarity_threshold":0,"keywords_similarity_weight":0}`)
|
|
if err != nil {
|
|
t.Fatalf("InvokableRun: %v", err)
|
|
}
|
|
if service.req.SimilarityThreshold == nil || *service.req.SimilarityThreshold != 0 {
|
|
t.Fatalf("SimilarityThreshold = %v; want explicit zero", service.req.SimilarityThreshold)
|
|
}
|
|
if service.req.KeywordsSimilarityWeight == nil || *service.req.KeywordsSimilarityWeight != 0 {
|
|
t.Fatalf("KeywordsSimilarityWeight = %v; want explicit zero", service.req.KeywordsSimilarityWeight)
|
|
}
|
|
}
|
|
|
|
func TestRetrieval_ParsesEnhancementNodeParams(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
built, err := BuildByName("retrieval", map[string]any{
|
|
"rerank_id": "rerank-1",
|
|
"toc_enhance": true,
|
|
"meta_data_filter": map[string]any{"method": "manual"},
|
|
"empty_response": "empty",
|
|
"retrieval_from": "dataset",
|
|
"memory_ids": []any{"memory-1"},
|
|
"kb_vars": map[string]any{"x": "y"},
|
|
"cross_languages": []any{"English"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("BuildByName(retrieval): %v", err)
|
|
}
|
|
rt, ok := built.(*RetrievalTool)
|
|
if !ok {
|
|
t.Fatalf("BuildByName(retrieval) returned %T, want *RetrievalTool", built)
|
|
}
|
|
if rt.defaults.RerankID != "rerank-1" || !rt.defaults.TOCEnhance {
|
|
t.Fatalf("enhancement defaults were not parsed: %#v", rt.defaults)
|
|
}
|
|
if len(rt.defaults.CrossLanguages) != 1 || rt.defaults.CrossLanguages[0] != "English" {
|
|
t.Fatalf("CrossLanguages = %#v", rt.defaults.CrossLanguages)
|
|
}
|
|
if rt.defaults.MetaDataFilter["method"] != "manual" {
|
|
t.Fatalf("MetaDataFilter = %#v", rt.defaults.MetaDataFilter)
|
|
}
|
|
if rt.defaults.EmptyResponse != "empty" {
|
|
t.Fatalf("EmptyResponse = %q, want empty", rt.defaults.EmptyResponse)
|
|
}
|
|
}
|
|
|
|
func TestRetrieval_UsesEmptyResponseForEmptyQuery(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rt := NewRetrievalToolWithDefaults(retrievalArgs{EmptyResponse: "No query or result."})
|
|
out, err := rt.InvokableRun(context.Background(), `{"query":""}`)
|
|
if err != nil {
|
|
t.Fatalf("InvokableRun: %v", err)
|
|
}
|
|
var result retrievalResult
|
|
if err := json.Unmarshal([]byte(out), &result); err != nil {
|
|
t.Fatalf("unmarshal result: %v", err)
|
|
}
|
|
if result.FormalizedContent != "No query or result." {
|
|
t.Fatalf("FormalizedContent = %q", result.FormalizedContent)
|
|
}
|
|
}
|
|
|
|
func TestRetrieval_RoutesMemoryRequests(t *testing.T) {
|
|
previous := GetMemoryRetrievalService()
|
|
memoryService := &capturingRetrievalService{}
|
|
SetMemoryRetrievalService(memoryService)
|
|
t.Cleanup(func() { SetMemoryRetrievalService(previous) })
|
|
|
|
rt := NewRetrievalToolWithDefaults(retrievalArgs{
|
|
MemoryIDs: []string{"memory-1"},
|
|
})
|
|
if _, err := rt.InvokableRun(t.Context(), `{"query":"remember this"}`); err != nil {
|
|
t.Fatalf("InvokableRun: %v", err)
|
|
}
|
|
if memoryService.req.RetrievalFrom != "memory" {
|
|
t.Fatalf("RetrievalFrom = %q, want memory", memoryService.req.RetrievalFrom)
|
|
}
|
|
if len(memoryService.req.MemoryIDs) != 1 || memoryService.req.MemoryIDs[0] != "memory-1" {
|
|
t.Fatalf("MemoryIDs = %#v", memoryService.req.MemoryIDs)
|
|
}
|
|
}
|
|
|
|
func TestRetrieval_ResolvesCanvasVariables(t *testing.T) {
|
|
state := runtime.NewCanvasState("run-1", "session-1")
|
|
state.SetVar("source", "ids", []any{"kb-1", "kb-2"})
|
|
state.SetVar("source", "query", "semantic question")
|
|
state.SetVar("source", "value", "2026")
|
|
ctx := runtime.WithState(context.Background(), state)
|
|
|
|
ids, err := resolveRetrievalDatasetIDs(ctx, []string{"source@ids"})
|
|
if err != nil {
|
|
t.Fatalf("resolveRetrievalDatasetIDs: %v", err)
|
|
}
|
|
if len(ids) != 2 || ids[0] != "kb-1" || ids[1] != "kb-2" {
|
|
t.Fatalf("resolved dataset IDs = %#v", ids)
|
|
}
|
|
query, err := resolveRetrievalQuery(ctx, "{{source@query}}")
|
|
if err != nil || query != "semantic question" {
|
|
t.Fatalf("resolved query = %q, err = %v", query, err)
|
|
}
|
|
filter, err := resolveRetrievalFilter(ctx, map[string]any{
|
|
"method": "manual",
|
|
"value": "{{source@value}}",
|
|
})
|
|
if err != nil || filter["value"] != "2026" {
|
|
t.Fatalf("resolved filter = %#v, err = %v", filter, err)
|
|
}
|
|
}
|
|
|
|
func TestRetrieval_OmitsUnsetEmptyResponseFromArguments(t *testing.T) {
|
|
arguments, err := json.Marshal(retrievalArgs{Query: "love"})
|
|
if err != nil {
|
|
t.Fatalf("marshal arguments: %v", err)
|
|
}
|
|
if strings.Contains(string(arguments), `"empty_response"`) {
|
|
t.Fatalf("arguments unexpectedly include empty_response: %s", arguments)
|
|
}
|
|
}
|
|
|
|
func TestRetrieval_UsesEmptyResponseWhenSearchHasNoChunks(t *testing.T) {
|
|
prev := GetRetrievalService()
|
|
SetRetrievalService(staticRetrievalService{})
|
|
t.Cleanup(func() { SetRetrievalService(prev) })
|
|
|
|
rt := NewRetrievalToolWithDefaults(retrievalArgs{DatasetIDs: []string{"kb-1"}, EmptyResponse: "No matching chunk."})
|
|
out, err := rt.InvokableRun(context.Background(), `{"query":"love"}`)
|
|
if err != nil {
|
|
t.Fatalf("InvokableRun: %v", err)
|
|
}
|
|
var result retrievalResult
|
|
if err := json.Unmarshal([]byte(out), &result); err != nil {
|
|
t.Fatalf("unmarshal result: %v", err)
|
|
}
|
|
if result.FormalizedContent != "No matching chunk." {
|
|
t.Fatalf("FormalizedContent = %q", result.FormalizedContent)
|
|
}
|
|
}
|
|
|
|
func TestRetrieval_IgnoresCanvasMetadataNodeParams(t *testing.T) {
|
|
built, err := BuildByName("retrieval", map[string]any{
|
|
"kb_ids": []any{"kb-1"},
|
|
"inputs": map[string]any{"query": "upstream"},
|
|
"outputs": map[string]any{"formalized_content": "downstream"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("BuildByName(retrieval): %v", err)
|
|
}
|
|
rt, ok := built.(*RetrievalTool)
|
|
if !ok {
|
|
t.Fatalf("BuildByName(retrieval) returned %T, want *RetrievalTool", built)
|
|
}
|
|
if len(rt.defaults.DatasetIDs) != 1 || rt.defaults.DatasetIDs[0] != "kb-1" {
|
|
t.Fatalf("defaults.DatasetIDs=%#v want [kb-1]", rt.defaults.DatasetIDs)
|
|
}
|
|
}
|
|
|
|
func TestRetrieval_ModelArgsOverrideNodeDatasetIDs(t *testing.T) {
|
|
prev := GetRetrievalService()
|
|
svc := &capturingRetrievalService{}
|
|
SetRetrievalService(svc)
|
|
t.Cleanup(func() { SetRetrievalService(prev) })
|
|
|
|
rt := NewRetrievalToolWithDefaults(retrievalArgs{DatasetIDs: []string{"kb-default"}, TopN: 3})
|
|
_, err := rt.InvokableRun(context.Background(), `{"query":"hello","dataset_ids":["kb-call"],"top_n":5}`)
|
|
if err != nil {
|
|
t.Fatalf("InvokableRun: %v", err)
|
|
}
|
|
if len(svc.req.DatasetIDs) != 1 || svc.req.DatasetIDs[0] != "kb-call" {
|
|
t.Fatalf("DatasetIDs=%#v want [kb-call]", svc.req.DatasetIDs)
|
|
}
|
|
if svc.req.TopN != 5 {
|
|
t.Fatalf("TopN=%d want 5", svc.req.TopN)
|
|
}
|
|
}
|
|
|
|
func TestRetrieval_RecordsFrontendReferencePayload(t *testing.T) {
|
|
prev := GetRetrievalService()
|
|
SetRetrievalService(staticRetrievalService{chunks: []RetrievalChunk{
|
|
{
|
|
ID: "ck-1",
|
|
Content: "answer",
|
|
DocumentID: "doc-1",
|
|
DocumentName: "paper.pdf",
|
|
DatasetID: "kb-1",
|
|
ImageID: "img-1",
|
|
Positions: [][]float64{{1, 2, 3, 4}},
|
|
Score: 0.9,
|
|
TermSimilarity: 0.7,
|
|
VectorSimilarity: 0.8,
|
|
},
|
|
}})
|
|
t.Cleanup(func() { SetRetrievalService(prev) })
|
|
|
|
state := runtime.NewCanvasState("run-1", "task-1")
|
|
ctx := runtime.WithState(context.Background(), state)
|
|
|
|
rt := NewRetrievalTool()
|
|
_, err := rt.InvokableRun(ctx, `{"query":"hello","dataset_ids":["kb-1"]}`)
|
|
if err != nil {
|
|
t.Fatalf("InvokableRun: %v", err)
|
|
}
|
|
|
|
reference := state.GetRetrievalReference()
|
|
chunks, _ := reference["chunks"].([]any)
|
|
if len(chunks) != 1 {
|
|
t.Fatalf("reference chunks length = %d, want 1", len(chunks))
|
|
}
|
|
chunk, _ := chunks[0].(map[string]any)
|
|
if chunk["document_name"] != "paper.pdf" || chunk["image_id"] != "img-1" {
|
|
t.Fatalf("reference chunk = %#v, want document_name/image_id", chunk)
|
|
}
|
|
docAggs, _ := reference["doc_aggs"].([]any)
|
|
if len(docAggs) != 1 {
|
|
t.Fatalf("reference doc_aggs length = %d, want 1", len(docAggs))
|
|
}
|
|
docAgg, _ := docAggs[0].(map[string]any)
|
|
if docAgg["doc_id"] != "doc-1" || docAgg["doc_name"] != "paper.pdf" || docAgg["count"] != 1 {
|
|
t.Fatalf("reference doc_agg = %#v, want doc metadata", docAgg)
|
|
}
|
|
}
|
|
|
|
type capturingRetrievalService struct {
|
|
req RetrievalRequest
|
|
}
|
|
|
|
func (s *capturingRetrievalService) Search(_ context.Context, _ *gorm.DB, req RetrievalRequest) ([]RetrievalChunk, error) {
|
|
s.req = req
|
|
return []RetrievalChunk{{ID: "ck-1", Content: "answer"}}, nil
|
|
}
|
|
|
|
type staticRetrievalService struct {
|
|
chunks []RetrievalChunk
|
|
}
|
|
|
|
func (s staticRetrievalService) Search(_ context.Context, _ *gorm.DB, _ RetrievalRequest) ([]RetrievalChunk, error) {
|
|
return s.chunks, nil
|
|
}
|