- Introduced a new TODO.md file to outline the implementation plan and progress for the search module. - Updated DESIGN.md to reflect changes in the directory structure and clarify the roles of various components, including the new Handler + Registry pattern for reranking and keyword extraction. - Refactored the Searcher struct to utilize a direct reference to the rerank package, enhancing modularity and clarity in the search process. - Modified the Search and SearchMultiple methods to include context parameters, improving flexibility for agent mode operations. - Revised the Reranker interface to require context for Agent and MCP modes, ensuring compatibility with different reranking strategies. - Enhanced documentation to provide comprehensive guidance on the updated search architecture and its components.
232 lines
6.3 KiB
Go
232 lines
6.3 KiB
Go
package rerank
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/yaoapp/yao/agent/caller"
|
|
"github.com/yaoapp/yao/agent/context"
|
|
"github.com/yaoapp/yao/agent/search/types"
|
|
)
|
|
|
|
// AgentProvider implements reranking by delegating to another agent
|
|
// The agent should have a Next Hook that accepts rerank request and returns reordered items
|
|
type AgentProvider struct {
|
|
agentID string // Assistant ID to delegate to
|
|
}
|
|
|
|
// NewAgentProvider creates a new agent reranker
|
|
func NewAgentProvider(agentID string) *AgentProvider {
|
|
return &AgentProvider{agentID: agentID}
|
|
}
|
|
|
|
// Rerank delegates reranking to an LLM-powered assistant
|
|
// The assistant receives items and query, returns reordered item IDs or items
|
|
func (p *AgentProvider) Rerank(ctx *context.Context, query string, items []*types.ResultItem, opts *types.RerankOptions) ([]*types.ResultItem, error) {
|
|
if ctx == nil {
|
|
return nil, fmt.Errorf("context is required for agent rerank")
|
|
}
|
|
|
|
// Get agent via caller interface (avoids circular dependency)
|
|
agent, err := caller.AgentGetterFunc(p.agentID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get agent %s: %w", p.agentID, err)
|
|
}
|
|
|
|
// Build request message with items to rerank
|
|
requestData := map[string]interface{}{
|
|
"query": query,
|
|
"items": items,
|
|
"top_n": opts.TopN,
|
|
"action": "rerank",
|
|
}
|
|
requestJSON, _ := json.Marshal(requestData)
|
|
|
|
// Create messages for agent
|
|
messages := []context.Message{
|
|
{
|
|
Role: "user",
|
|
Content: string(requestJSON),
|
|
},
|
|
}
|
|
|
|
// Call agent's Stream method with skip options (no history, no output)
|
|
options := &context.Options{
|
|
Skip: &context.Skip{
|
|
History: true,
|
|
Output: true,
|
|
},
|
|
}
|
|
|
|
result, err := agent.Stream(ctx, messages, options)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("agent stream failed: %w", err)
|
|
}
|
|
|
|
// Parse response
|
|
return p.parseResponse(result, items, opts)
|
|
}
|
|
|
|
// parseResponse extracts reranked items from agent response
|
|
// The response format from agent.Stream is typically:
|
|
// { "next": { "data": { "order": [...] } } }
|
|
func (p *AgentProvider) parseResponse(result interface{}, originalItems []*types.ResultItem, opts *types.RerankOptions) ([]*types.ResultItem, error) {
|
|
if result == nil {
|
|
return originalItems, nil
|
|
}
|
|
|
|
// Build index map for quick lookup
|
|
itemMap := make(map[string]*types.ResultItem)
|
|
for _, item := range originalItems {
|
|
if item.CitationID != "" {
|
|
itemMap[item.CitationID] = item
|
|
}
|
|
}
|
|
|
|
// Extract response data
|
|
response := extractResponseData(result)
|
|
if response == nil {
|
|
return originalItems, nil
|
|
}
|
|
|
|
// Try to get reranked order from response
|
|
// Expected format: { "order": ["ref_001", "ref_003", "ref_002"] }
|
|
// Or: { "items": [{ "citation_id": "ref_001", ... }, ...] }
|
|
|
|
var reranked []*types.ResultItem
|
|
|
|
// Try "order" field (list of citation IDs)
|
|
if order, ok := response["order"]; ok {
|
|
if orderList := toStringSlice(order); len(orderList) > 0 {
|
|
for _, id := range orderList {
|
|
if item, exists := itemMap[id]; exists {
|
|
reranked = append(reranked, item)
|
|
delete(itemMap, id) // Avoid duplicates
|
|
}
|
|
}
|
|
// Append remaining items not in order
|
|
for _, item := range originalItems {
|
|
if _, exists := itemMap[item.CitationID]; exists {
|
|
reranked = append(reranked, item)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try "items" field (full items or items with citation_id)
|
|
if len(reranked) == 0 {
|
|
if items, ok := response["items"]; ok {
|
|
if itemsList := toItemsList(items); len(itemsList) > 0 {
|
|
for _, respItem := range itemsList {
|
|
// Check if it's just a reference or full item
|
|
if citationID, ok := respItem["citation_id"].(string); ok {
|
|
if item, exists := itemMap[citationID]; exists {
|
|
reranked = append(reranked, item)
|
|
delete(itemMap, citationID)
|
|
}
|
|
}
|
|
}
|
|
// Append remaining items
|
|
for _, item := range originalItems {
|
|
if _, exists := itemMap[item.CitationID]; exists {
|
|
reranked = append(reranked, item)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// If no valid response, return original items
|
|
if len(reranked) == 0 {
|
|
reranked = originalItems
|
|
}
|
|
|
|
// Apply top N
|
|
if opts.TopN > 0 && opts.TopN < len(reranked) {
|
|
reranked = reranked[:opts.TopN]
|
|
}
|
|
|
|
return reranked, nil
|
|
}
|
|
|
|
// extractResponseData extracts the actual response data from agent.Stream result
|
|
// Handles nested structures like { "next": { "data": { ... } } }
|
|
func extractResponseData(result interface{}) map[string]interface{} {
|
|
switch v := result.(type) {
|
|
case map[string]interface{}:
|
|
// Check for "next" wrapper (from NextHookResponse)
|
|
if next, ok := v["next"].(map[string]interface{}); ok {
|
|
// Check for "data" inside next
|
|
if data, ok := next["data"].(map[string]interface{}); ok {
|
|
return data
|
|
}
|
|
return next
|
|
}
|
|
// Check for direct "data" wrapper
|
|
if data, ok := v["data"].(map[string]interface{}); ok {
|
|
return data
|
|
}
|
|
return v
|
|
case string:
|
|
// Try to parse as JSON
|
|
var data map[string]interface{}
|
|
if err := json.Unmarshal([]byte(v), &data); err == nil {
|
|
return extractResponseData(data)
|
|
}
|
|
}
|
|
// Try to handle other types by converting to JSON and back
|
|
if result != nil {
|
|
if bytes, err := json.Marshal(result); err == nil {
|
|
var data map[string]interface{}
|
|
if err := json.Unmarshal(bytes, &data); err == nil {
|
|
return extractResponseData(data)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// toStringSlice converts interface to string slice
|
|
func toStringSlice(v interface{}) []string {
|
|
switch val := v.(type) {
|
|
case []string:
|
|
return val
|
|
case []interface{}:
|
|
result := make([]string, 0, len(val))
|
|
for _, item := range val {
|
|
if s, ok := item.(string); ok {
|
|
result = append(result, s)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// toItemsList converts interface to list of maps
|
|
func toItemsList(v interface{}) []map[string]interface{} {
|
|
switch val := v.(type) {
|
|
case []map[string]interface{}:
|
|
return val
|
|
case []interface{}:
|
|
result := make([]map[string]interface{}, 0, len(val))
|
|
for _, item := range val {
|
|
if m, ok := item.(map[string]interface{}); ok {
|
|
result = append(result, m)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// extractAgentID extracts assistant ID from uses.rerank value
|
|
// For backward compatibility, strips any prefix if present
|
|
func extractAgentID(usesRerank string) string {
|
|
// Remove any prefix like "agent:" if present
|
|
if strings.HasPrefix(usesRerank, "agent:") {
|
|
return strings.TrimPrefix(usesRerank, "agent:")
|
|
}
|
|
return usesRerank
|
|
}
|