mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-29 04:08:12 +08:00
fix(go-agent): return configured retrieval empty responses (#17484)
## Summary - Return the configured `empty_response` when retrieval has no query or no chunks. - Preserve `formalized_content` for downstream Message nodes. ## Testing - `bash build.sh --go` - `ok ragflow/internal/agent/tool` - `ok ragflow/internal/agent/component`
This commit is contained in:
52
internal/agent/component/retrieval_empty_response_test.go
Normal file
52
internal/agent/component/retrieval_empty_response_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
//
|
||||
// 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"
|
||||
"testing"
|
||||
|
||||
agenttool "ragflow/internal/agent/tool"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type emptyRetrievalService struct{}
|
||||
|
||||
func (emptyRetrievalService) Search(context.Context, *gorm.DB, agenttool.RetrievalRequest) ([]agenttool.RetrievalChunk, error) {
|
||||
return []agenttool.RetrievalChunk{}, nil
|
||||
}
|
||||
|
||||
func TestRetrievalComponent_PreservesConfiguredEmptyResponse(t *testing.T) {
|
||||
previous := agenttool.GetRetrievalService()
|
||||
agenttool.SetRetrievalService(emptyRetrievalService{})
|
||||
t.Cleanup(func() { agenttool.SetRetrievalService(previous) })
|
||||
|
||||
component, err := newRetrievalComponent(map[string]any{
|
||||
"empty_response": "No relevant content.",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("newRetrievalComponent: %v", err)
|
||||
}
|
||||
output, err := component.Invoke(context.Background(), nil, map[string]any{"query": "love"})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if output["formalized_content"] != "No relevant content." {
|
||||
t.Fatalf("formalized_content = %#v", output["formalized_content"])
|
||||
}
|
||||
}
|
||||
@@ -80,9 +80,7 @@ type retrievalParams struct {
|
||||
// "default everything". This matches Python's
|
||||
// component.retrieval.RetrievalParam.__init__ tolerance.
|
||||
func parseRetrievalParams(params map[string]any) retrievalParams {
|
||||
out := retrievalParams{
|
||||
EmptyResponse: "Sorry, no relevant content was found in the knowledge base.",
|
||||
}
|
||||
out := retrievalParams{}
|
||||
if params == nil {
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -403,6 +403,13 @@ func buildRetrievalTool(params map[string]any) (einotool.BaseTool, error) {
|
||||
}
|
||||
defaults.KeywordsSimilarityWeight = &v
|
||||
}
|
||||
if value, ok := params["empty_response"]; ok {
|
||||
emptyResponse, ok := value.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("agent tool: retrieval tool requires string node-level param empty_response")
|
||||
}
|
||||
defaults.EmptyResponse = emptyResponse
|
||||
}
|
||||
return NewRetrievalToolWithDefaults(defaults), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -64,13 +64,14 @@ type retrievalArgs struct {
|
||||
KeywordsSimilarityWeight *float64 `json:"keywords_similarity_weight,omitempty"`
|
||||
UseKG bool `json:"use_kg,omitempty"`
|
||||
SimilarityThreshold float64 `json:"similarity_threshold,omitempty"`
|
||||
EmptyResponse string `json:"empty_response,omitempty"`
|
||||
}
|
||||
|
||||
// retrievalResult is the JSON shape returned to the model. The `_ERROR`
|
||||
// field matches the Python tool's output convention; downstream components
|
||||
// can pattern-match on it.
|
||||
type retrievalResult struct {
|
||||
FormalizedContent string `json:"formalized_content,omitempty"`
|
||||
FormalizedContent string `json:"formalized_content"`
|
||||
Chunks []chunkPayload `json:"chunks,omitempty"`
|
||||
Stub bool `json:"stub,omitempty"`
|
||||
Error string `json:"_ERROR,omitempty"`
|
||||
@@ -146,6 +147,9 @@ func (r *RetrievalTool) InvokableRun(ctx context.Context, argumentsInJSON string
|
||||
zap.Float64p("keywords_similarity_weight", args.KeywordsSimilarityWeight),
|
||||
zap.Bool("use_kg", args.UseKG),
|
||||
)
|
||||
if args.Query == "" {
|
||||
return stubJSONWithErr(retrievalResult{FormalizedContent: args.EmptyResponse})
|
||||
}
|
||||
|
||||
if args.UseKG {
|
||||
// Plan + §9 Q3: GraphRAG is out of scope for the Go
|
||||
@@ -193,10 +197,11 @@ func (r *RetrievalTool) InvokableRun(ctx context.Context, argumentsInJSON string
|
||||
Score: c.Score,
|
||||
})
|
||||
}
|
||||
out := retrievalResult{
|
||||
FormalizedContent: renderChunks(chunks, args.Query),
|
||||
Chunks: payload,
|
||||
formalizedContent := renderChunks(chunks, args.Query)
|
||||
if len(chunks) == 0 {
|
||||
formalizedContent = args.EmptyResponse
|
||||
}
|
||||
out := retrievalResult{FormalizedContent: formalizedContent, Chunks: payload}
|
||||
// Record chunks into canvas state so the Agent's post-stream
|
||||
// citation grounding call can read them. The recording is
|
||||
// best-effort — when the canvas state is not
|
||||
@@ -230,6 +235,9 @@ func (r *RetrievalTool) mergeDefaults(args retrievalArgs) retrievalArgs {
|
||||
if args.SimilarityThreshold <= 0 {
|
||||
args.SimilarityThreshold = r.defaults.SimilarityThreshold
|
||||
}
|
||||
if args.EmptyResponse == "" {
|
||||
args.EmptyResponse = r.defaults.EmptyResponse
|
||||
}
|
||||
args.UseKG = args.UseKG || r.defaults.UseKG
|
||||
return args
|
||||
}
|
||||
|
||||
@@ -106,12 +106,16 @@ func TestRetrieval_EmptyArgsIsHandled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := NewRetrievalTool()
|
||||
// Empty arguments should still return a stub error (not panic) — the
|
||||
// Python tool defaults to empty_response in this case. Without
|
||||
// wiring, the Go side surfaces the service-missing error.
|
||||
_, err := rt.InvokableRun(context.Background(), "")
|
||||
if !errors.Is(err, ErrRetrievalServiceMissing) {
|
||||
t.Fatalf("err = %v, want ErrRetrievalServiceMissing", err)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +201,7 @@ func TestRetrieval_UsesNodeParamsAsDefaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetrieval_IgnoresPythonOnlyNodeParams(t *testing.T) {
|
||||
func TestRetrieval_AcceptsEmptyResponseNodeParam(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
built, err := BuildByName("retrieval", map[string]any{
|
||||
@@ -218,7 +222,56 @@ func TestRetrieval_IgnoresPythonOnlyNodeParams(t *testing.T) {
|
||||
t.Fatalf("BuildByName(retrieval) returned %T, want *RetrievalTool", built)
|
||||
}
|
||||
if rt.defaults.TopN != 0 || rt.defaults.TopK != 0 || rt.defaults.KeywordsSimilarityWeight != nil {
|
||||
t.Fatalf("python-only params should not mutate retrieval defaults: %#v", rt.defaults)
|
||||
t.Fatalf("unimplemented params should not mutate retrieval defaults: %#v", rt.defaults)
|
||||
}
|
||||
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_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{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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user