Week 2 Day 10:Hybrid Retrieval - 苏格拉底教学

💡 昨天的向量搜索很强,但有致命盲点:用户搜索"Q3 sales report",向量搜索可能找不到,因为这是专有名词和数字,embedding 模型看不出语义。答案是混合检索。

第一部分:问题驱动

🤔 问题1:向量搜索的盲点在哪里?

引导问题:

  1. 用户搜索"invoice #INV-2024-0932",向量搜索能精确命中吗?
  2. 技术文档里有 "error code 0x8007001F",向量搜索会找到它吗?
  3. 新词汇(模型训练后才出现的词,如 "GPT-4o")embedding 有意义吗?

答案揭示:

  • 向量搜索擅长:语义理解、同义词、模糊匹配
  • 向量搜索盲点:精确字符串(订单号、错误码、专有名词)、新词汇
  • 关键词搜索(BM25)擅长:精确词语匹配、数字、ID、专有名词
  • 两者互补 → 混合检索

🤔 问题2:BM25 是什么?为什么比 TF-IDF 更好?

引导问题:

  1. TF-IDF 的"词频"有什么问题?一篇1000词的文档和一篇100词的文档公平吗?
  2. 一个词在所有文档都出现(如"的""是"),它的权重应该怎样?
  3. 同一个词出现50次和5次,相关性真的是10倍吗?

BM25 公式(面试必背):

score(q, d) = Σ IDF(t) × (tf(t,d) × (k1+1)) / (tf(t,d) + k1×(1-b+b×|d|/avgdl)) 其中: tf(t,d) = 词 t 在文档 d 中的频率 |d| = 文档长度 avgdl = 平均文档长度 k1 = 词频饱和参数(通常 1.2-2.0),控制词频增长上限 b = 长度归一化参数(通常 0.75),0=不归一化,1=完全归一化 IDF(t) = log((N-df+0.5)/(df+0.5)+1),N=文档总数,df=包含词t的文档数

直觉理解:

  • k1 控制词频的"边际效益递减":词出现50次 vs 5次,不是10倍相关
  • b 控制文档长度的影响:长文档不应该因为词多就占优势

🤔 问题3:如何合并两个搜索的结果?

错误做法: 直接平均分数

vector_score = 0.87, keyword_score = 0.65 合并 = (0.87 + 0.65) / 2 = 0.76

问题: 两种方法的分数量纲不同!

  • 向量搜索 cosine score:[0, 1]
  • BM25 score:[0, ∞),取决于文档集合大小

正确做法: Reciprocal Rank Fusion(RRF)


第二部分:关键概念

Reciprocal Rank Fusion(RRF)

核心思想: 只看排名(rank),不看原始分数,消除量纲差异。

RRF_score(d) = Σ 1 / (k + rank_i(d)) 其中: k = 平滑参数(通常 60),防止排名第1的文档分数过高 rank = 文档在某个搜索结果中的排名(从1开始)

具体示例:

文档A:向量搜索排名 #1,关键词搜索排名 #3 文档B:向量搜索排名 #2,关键词搜索排名 #1 文档A 的 RRF = 1/(60+1) + 1/(60+3) = 0.01639 + 0.01563 = 0.03202 文档B 的 RRF = 1/(60+2) + 1/(60+1) = 0.01613 + 0.01639 = 0.03252 文档B 胜出(在两个搜索中都排名靠前)

为什么 k=60?

  • k 太小:第1名和第2名分数差距太大(k=0时第1名=1,第2名=0.5)
  • k 太大:所有排名差距被平滑掉,区分度消失
  • k=60 是实践中的经验值,来自于 Cormack et al. 2009 的论文

权重调参策略

场景化调权:

// 默认:vector 偏重语义
weightConfig := map[string]float64{
    "vector":  0.6,
    "keyword": 0.4,
}

// 精确查询(含数字、编号):keyword 更重要
if containsExactTerms(query) {
    weightConfig["vector"] = 0.3
    weightConfig["keyword"] = 0.7
}

// 模糊语义查询("如何..."、"为什么..."):vector 更重要
if isSemanticQuery(query) {
    weightConfig["vector"] = 0.8
    weightConfig["keyword"] = 0.2
}

如何判断 query 类型:

func containsExactTerms(query string) bool {
    // 包含数字、特殊符号、全大写缩写
    hasNumbers := regexp.MustCompile(`\d`).MatchString(query)
    hasSpecial := regexp.MustCompile(`[#_\-/]`).MatchString(query)
    return hasNumbers || hasSpecial
}

Metadata Filter 设计

为什么需要 filter?

  • 财务部员工不应该看到 HR 的薪酬数据
  • 用户只想搜 2024 Q3 的文档
  • 敏感文档只对有权限的用户可见

Filter 结合向量搜索(Qdrant 原生支持):

// 只搜索 engineering 部门的 public 文档
filter := &qdrant.Filter{
    Must: []*qdrant.Condition{
        {
            ConditionOneOf: &qdrant.Condition_Field{
                Field: &qdrant.FieldCondition{
                    Key: "department",
                    Match: &qdrant.Match{
                        MatchValue: &qdrant.Match_Keyword{
                            Keyword: "engineering",
                        },
                    },
                },
            },
        },
        {
            ConditionOneOf: &qdrant.Condition_Field{
                Field: &qdrant.FieldCondition{
                    Key: "classification",
                    Match: &qdrant.Match{
                        MatchValue: &qdrant.Match_Keyword{
                            Keyword: "public",
                        },
                    },
                },
            },
        },
    },
}

第三部分:动手实现

✅ 版本1:BM25 关键词搜索(用 bleve)

// internal/rag/keyword_search.go
package rag

import (
	"fmt"
	"log/slog"
	"os"

	"github.com/blevesearch/bleve/v2"
)

type KeywordSearcher struct {
	index bleve.Index
}

// bleveDoc 是写入 bleve 的文档结构
type bleveDoc struct {
	ChunkID        string `json:"chunk_id"`
	Text           string `json:"text"`
	Title          string `json:"title"`
	Department     string `json:"department"`
	Classification string `json:"classification"`
}

// NewKeywordSearcher 创建或打开 bleve 索引
func NewKeywordSearcher(indexPath string) (*KeywordSearcher, error) {
	var index bleve.Index
	var err error

	if _, statErr := os.Stat(indexPath); os.IsNotExist(statErr) {
		// 创建新索引
		mapping := bleve.NewIndexMapping()
		index, err = bleve.New(indexPath, mapping)
	} else {
		// 打开已有索引
		index, err = bleve.Open(indexPath)
	}

	if err != nil {
		return nil, fmt.Errorf("bleve index: %w", err)
	}

	return &KeywordSearcher{index: index}, nil
}

// IndexChunks 批量写入 chunks 到 bleve
func (ks *KeywordSearcher) IndexChunks(chunks []Chunk) error {
	batch := ks.index.NewBatch()

	for _, chunk := range chunks {
		doc := bleveDoc{
			ChunkID:        chunk.ID,
			Text:           chunk.Text,
			Title:          chunk.Title,
			Department:     chunk.Metadata["department"],
			Classification: chunk.Metadata["classification"],
		}

		if err := batch.Index(chunk.ID, doc); err != nil {
			return fmt.Errorf("batch index chunk %s: %w", chunk.ID, err)
		}
	}

	if err := ks.index.Batch(batch); err != nil {
		return fmt.Errorf("commit batch: %w", err)
	}

	slog.Info("keyword index updated", "chunks", len(chunks))
	return nil
}

// SearchResult 关键词搜索结果
type SearchResult struct {
	ChunkID string
	Score   float64
	Rank    int
}

// Search 执行关键词搜索,返回带排名的结果
func (ks *KeywordSearcher) Search(query string, topK int) ([]SearchResult, error) {
	searchQuery := bleve.NewMatchQuery(query)
	searchReq := bleve.NewSearchRequestOptions(searchQuery, topK, 0, false)
	searchReq.Fields = []string{"chunk_id", "text", "title"}

	results, err := ks.index.Search(searchReq)
	if err != nil {
		return nil, fmt.Errorf("bleve search: %w", err)
	}

	var out []SearchResult
	for i, hit := range results.Hits {
		out = append(out, SearchResult{
			ChunkID: hit.ID,
			Score:   hit.Score,
			Rank:    i + 1, // 1-indexed
		})
	}

	return out, nil
}

✅ 版本2:RRF 融合逻辑

// internal/rag/hybrid_search.go
package rag

import (
	"sort"
)

const rrfK = 60 // RRF 平滑参数

// RankedResult 融合后的结果
type RankedResult struct {
	ChunkID  string
	RRFScore float64
	// 调试信息
	VectorRank  int
	KeywordRank int
}

// ReciprocalRankFusion 合并向量搜索和关键词搜索的排名
func ReciprocalRankFusion(
	vectorResults []SearchResult,   // 向量搜索结果(已按 rank 排序)
	keywordResults []SearchResult,  // 关键词搜索结果(已按 rank 排序)
	vectorWeight float64,           // 向量搜索权重(如 0.6)
	keywordWeight float64,          // 关键词搜索权重(如 0.4)
) []RankedResult {
	// chunk_id → RRF 分数累加
	scores := make(map[string]*RankedResult)

	// 处理向量搜索结果
	for _, r := range vectorResults {
		if _, ok := scores[r.ChunkID]; !ok {
			scores[r.ChunkID] = &RankedResult{ChunkID: r.ChunkID}
		}
		scores[r.ChunkID].RRFScore += vectorWeight * (1.0 / float64(rrfK+r.Rank))
		scores[r.ChunkID].VectorRank = r.Rank
	}

	// 处理关键词搜索结果
	for _, r := range keywordResults {
		if _, ok := scores[r.ChunkID]; !ok {
			scores[r.ChunkID] = &RankedResult{ChunkID: r.ChunkID}
		}
		scores[r.ChunkID].RRFScore += keywordWeight * (1.0 / float64(rrfK+r.Rank))
		scores[r.ChunkID].KeywordRank = r.Rank
	}

	// 转为列表并排序
	var merged []RankedResult
	for _, v := range scores {
		merged = append(merged, *v)
	}

	sort.Slice(merged, func(i, j int) bool {
		return merged[i].RRFScore > merged[j].RRFScore
	})

	return merged
}

✅ 版本3:带 Metadata filter 的向量搜索扩展

// internal/rag/vectorstore.go(扩展 Search 方法)

// FilterOptions 过滤条件
type FilterOptions struct {
	Department     string // 为空表示不过滤
	Classification string // "public" | "internal" | "confidential"
}

// SearchWithFilter 带过滤条件的向量搜索
func (vs *VectorStore) SearchWithFilter(
	ctx context.Context,
	queryVec []float32,
	topK uint64,
	filter FilterOptions,
) ([]*qdrant.ScoredPoint, error) {
	req := &qdrant.QueryPoints{
		CollectionName: vs.collectionName,
		Query:          qdrant.NewQuery(queryVec...),
		Limit:          &topK,
		WithPayload:    qdrant.NewWithPayload(true),
	}

	// 按需添加过滤条件
	var conditions []*qdrant.Condition
	if filter.Department != "" {
		conditions = append(conditions, &qdrant.Condition{
			ConditionOneOf: &qdrant.Condition_Field{
				Field: &qdrant.FieldCondition{
					Key: "department",
					Match: &qdrant.Match{
						MatchValue: &qdrant.Match_Keyword{
							Keyword: filter.Department,
						},
					},
				},
			},
		})
	}
	if filter.Classification != "" {
		conditions = append(conditions, &qdrant.Condition{
			ConditionOneOf: &qdrant.Condition_Field{
				Field: &qdrant.FieldCondition{
					Key: "classification",
					Match: &qdrant.Match{
						MatchValue: &qdrant.Match_Keyword{
							Keyword: filter.Classification,
						},
					},
				},
			},
		})
	}

	if len(conditions) > 0 {
		req.Filter = &qdrant.Filter{Must: conditions}
	}

	return vs.client.Query(ctx, req)
}

✅ 版本4:完整 Hybrid Search 实现

// internal/rag/hybrid.go
package rag

import (
	"context"
	"fmt"
	"log/slog"
)

type HybridSearcher struct {
	vectorStore     *VectorStore
	keywordSearcher *KeywordSearcher
	embedder        *Embedder
}

func NewHybridSearcher(vs *VectorStore, ks *KeywordSearcher, emb *Embedder) *HybridSearcher {
	return &HybridSearcher{
		vectorStore:     vs,
		keywordSearcher: ks,
		embedder:        emb,
	}
}

// HybridSearchRequest 检索请求
type HybridSearchRequest struct {
	Query         string
	TopK          int
	Filter        FilterOptions
	VectorWeight  float64 // 默认 0.6
	KeywordWeight float64 // 默认 0.4
}

// HybridSearchResult 最终检索结果
type HybridSearchResult struct {
	ChunkID        string
	Text           string
	Title          string
	Source         string
	RRFScore       float64
	VectorRank     int
	KeywordRank    int
}

// Search 执行混合检索
func (h *HybridSearcher) Search(ctx context.Context, req HybridSearchRequest) ([]HybridSearchResult, error) {
	// 默认权重
	if req.VectorWeight == 0 {
		req.VectorWeight = 0.6
	}
	if req.KeywordWeight == 0 {
		req.KeywordWeight = 0.4
	}
	if req.TopK == 0 {
		req.TopK = 10
	}

	// 候选集加大(融合前各取 topK×3)
	candidateK := req.TopK * 3

	// 1. 向量搜索
	queryVec, err := h.embedder.EmbedOne(ctx, req.Query)
	if err != nil {
		return nil, fmt.Errorf("embed query: %w", err)
	}

	vectorPoints, err := h.vectorStore.SearchWithFilter(ctx, queryVec, uint64(candidateK), req.Filter)
	if err != nil {
		return nil, fmt.Errorf("vector search: %w", err)
	}

	// 2. 关键词搜索
	keywordHits, err := h.keywordSearcher.Search(req.Query, candidateK)
	if err != nil {
		return nil, fmt.Errorf("keyword search: %w", err)
	}

	// 3. 转换向量搜索结果为 SearchResult(带排名)
	var vectorResults []SearchResult
	// 构建 payload 缓存(chunk_id → payload),供后续获取文本
	payloadCache := make(map[string]map[string]*qdrant.Value)
	for i, p := range vectorPoints {
		chunkID := p.Payload["chunk_id"].GetStringValue()
		vectorResults = append(vectorResults, SearchResult{
			ChunkID: chunkID,
			Score:   float64(p.Score),
			Rank:    i + 1,
		})
		payloadCache[chunkID] = p.Payload
	}

	// 4. RRF 融合
	merged := ReciprocalRankFusion(vectorResults, keywordHits, req.VectorWeight, req.KeywordWeight)

	// 5. 取 top-K,填充文本内容
	if len(merged) > req.TopK {
		merged = merged[:req.TopK]
	}

	var finalResults []HybridSearchResult
	for _, m := range merged {
		payload := payloadCache[m.ChunkID]
		text := ""
		title := ""
		source := ""
		if payload != nil {
			text = payload["text"].GetStringValue()
			title = payload["title"].GetStringValue()
			source = payload["source"].GetStringValue()
		}

		finalResults = append(finalResults, HybridSearchResult{
			ChunkID:     m.ChunkID,
			Text:        text,
			Title:       title,
			Source:      source,
			RRFScore:    m.RRFScore,
			VectorRank:  m.VectorRank,
			KeywordRank: m.KeywordRank,
		})
	}

	slog.Info("hybrid search done",
		"query", req.Query,
		"vector_candidates", len(vectorResults),
		"keyword_candidates", len(keywordHits),
		"merged", len(merged),
		"returned", len(finalResults),
	)

	return finalResults, nil
}

✅ 版本5:命令行测试工具

// cmd/search/main.go
package main

import (
	"context"
	"fmt"
	"log/slog"
	"os"
	"time"

	"github.com/yourname/agent-runtime/internal/rag"
)

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	apiKey := os.Getenv("OPENAI_API_KEY")
	query := os.Getenv("SEARCH_QUERY")
	if query == "" {
		query = "如何重置密码"
	}

	// 初始化各组件
	embedder := rag.NewEmbedder(apiKey)

	vs, err := rag.NewVectorStore("localhost", 6334)
	if err != nil {
		slog.Error("vectorstore init", "error", err)
		os.Exit(1)
	}

	ks, err := rag.NewKeywordSearcher("data/bleve.index")
	if err != nil {
		slog.Error("keyword searcher init", "error", err)
		os.Exit(1)
	}

	searcher := rag.NewHybridSearcher(vs, ks, embedder)

	// 执行混合检索
	results, err := searcher.Search(ctx, rag.HybridSearchRequest{
		Query:         query,
		TopK:          5,
		VectorWeight:  0.6,
		KeywordWeight: 0.4,
	})
	if err != nil {
		slog.Error("search failed", "error", err)
		os.Exit(1)
	}

	fmt.Printf("\n搜索:%q\n找到 %d 个结果:\n\n", query, len(results))
	for i, r := range results {
		fmt.Printf("[%d] RRF=%.5f (向量排名#%d, 关键词排名#%d)\n",
			i+1, r.RRFScore, r.VectorRank, r.KeywordRank)
		fmt.Printf("    标题: %s\n", r.Title)
		fmt.Printf("    来源: %s\n", r.Source)
		fmt.Printf("    内容: %s\n\n", truncate(r.Text, 150))
	}
}

func truncate(s string, n int) string {
	runes := []rune(s)
	if len(runes) <= n {
		return s
	}
	return string(runes[:n]) + "..."
}

第四部分:关键概念深入

BM25 参数调优

// bleve 默认使用 BM25,参数可以通过自定义 scorer 调整
// 默认:k1=1.2, b=0.75

// 什么时候调 k1?
// - 文档长度变化不大(如 FAQ 条目):k1 可以降低到 1.0
// - 长文档(如政策手册):k1 保持 1.2-2.0

// 什么时候调 b?
// - 文档长度差异很大:b=0.75(归一化)
// - 文档长度基本一致:b=0(不归一化,稍快)

RRF 权重调优实验

// 推荐方法:在 eval 数据集上网格搜索最优权重
func evaluateRRFWeights(evalSet []EvalCase, vectorW, keywordW float64) float64 {
    var totalNDCG float64
    for _, ec := range evalSet {
        results := searcher.Search(ctx, HybridSearchRequest{
            Query:         ec.Query,
            VectorWeight:  vectorW,
            KeywordWeight: keywordW,
        })
        totalNDCG += computeNDCG(results, ec.RelevantChunks)
    }
    return totalNDCG / float64(len(evalSet))
}

// 网格搜索
for vw := 0.3; vw <= 0.9; vw += 0.1 {
    kw := 1.0 - vw
    score := evaluateRRFWeights(evalSet, vw, kw)
    fmt.Printf("vector=%.1f keyword=%.1f NDCG=%.4f\n", vw, kw, score)
}

第五部分:自测清单

运行前,问自己:

  • 向量搜索在哪些场景下会失败?
  • BM25 的 k1 和 b 参数分别控制什么?
  • RRF 为什么用排名而不用原始分数?
  • k=60 是怎么来的,改成 k=10 会有什么效果?
  • 关键词搜索和向量搜索候选集各取多少个合适?
  • Metadata filter 在向量搜索中如何实现(pre-filter vs post-filter)?
  • 权重 0.6/0.4 是拍脑袋的吗,怎么系统调优?

第六部分:作业

任务1:建立 bleve 索引

# 将 Day 8 的 chunks 写入 bleve
go run cmd/indexer/main.go --keyword

# 验证索引
# bleve 会在 data/bleve.index/ 目录下创建文件
ls data/bleve.index/

任务2:验证关键词搜索效果

# 对比向量搜索和关键词搜索的结果差异
SEARCH_QUERY="error code 0x8007001F" go run cmd/search/main.go
SEARCH_QUERY="如何提交申请" go run cmd/search/main.go

任务3:对比实验

创建一个对比测试,分别记录以下配置的召回率:

  • 纯向量搜索(weight: 1.0/0.0)
  • 纯关键词搜索(weight: 0.0/1.0)
  • 混合检索(weight: 0.6/0.4)
  • 反向混合(weight: 0.4/0.6)

任务4:实现权重自适应

// 根据 query 特征自动选择权重
func autoWeight(query string) (vectorW, keywordW float64) {
    // TODO: 实现以下逻辑
    // - 包含数字、#、- 等符号 → keyword 偏重
    // - 以"如何"、"为什么"、"什么是"开头 → vector 偏重
    // - 其他 → 默认 0.6/0.4
}

第七部分:常见问题

Q: bleve 和 Elasticsearch 相比,适合什么规模?

A: 对比如下:

  • bleve:嵌入式(in-process),无需部署,适合 < 100 万文档
  • Elasticsearch:独立服务,水平扩展,适合 100 万+ 文档
  • 本项目先用 bleve,万一需要扩展再迁移 ES

Q: RRF 候选集大小怎么设置?

A: 经验规则:

候选集 = topK × 3 最终结果 = topK 例:要返回 5 个结果 向量搜索:取 top-15 关键词搜索:取 top-15 RRF 融合:在 15+15 个候选中选 top-5

候选集太小:好结果被截断 候选集太大:RRF 计算量增加(但通常可忽略)

Q: 如果用户 query 很短(1-2个词),两种搜索哪个更好?

A: 短 query 通常关键词搜索更好:

  • "重置密码":关键词精确
  • "how to reset my work account password because it expired":向量更好
  • 可以根据 query 长度动态调权

Q: Qdrant filter 是 pre-filter 还是 post-filter?

A: Qdrant 默认是 pre-filter(先过滤,再在过滤后的子集内搜索)。

  • 优点:精确,不会出现"搜了10个但过滤后只剩2个"
  • 缺点:如果过滤后子集很小,HNSW 精度下降(需要设置 exact: true 退化为暴力搜索)
// 当过滤后子集很小时,用精确搜索
req.Filter = filter
// Qdrant 会自动检测并切换到暴力搜索模式

第八部分:配套算法

算法1:子集(Subsets)

为什么? 混合检索的候选集合并和去重,本质上是集合操作。Subsets 是集合思维的基础。

// LeetCode 78: Subsets
// 给定不含重复元素的整数数组,返回所有可能的子集
func subsets(nums []int) [][]int {
    result := [][]int{{}} // 空集是任何集合的子集

    for _, num := range nums {
        n := len(result)
        for i := 0; i < n; i++ {
            // 在已有每个子集的基础上加入 num,形成新子集
            newSubset := make([]int, len(result[i]))
            copy(newSubset, result[i])
            newSubset = append(newSubset, num)
            result = append(result, newSubset)
        }
    }

    return result
}

// 理解:每个元素只有两种选择:选 or 不选
// n 个元素 → 2^n 个子集
// 时间复杂度 O(2^n),空间复杂度 O(2^n)

// 回溯版本(更通用)
func subsetsBacktrack(nums []int) [][]int {
    var result [][]int
    var current []int

    var backtrack func(start int)
    backtrack = func(start int) {
        // 每个状态都是一个合法子集
        tmp := make([]int, len(current))
        copy(tmp, current)
        result = append(result, tmp)

        for i := start; i < len(nums); i++ {
            current = append(current, nums[i])
            backtrack(i + 1)
            current = current[:len(current)-1] // 回溯
        }
    }

    backtrack(0)
    return result
}

算法2:全排列(Permutations)

为什么? 理解搜索结果的排列组合,是设计混合检索评估指标(如 NDCG)的基础。

// LeetCode 46: Permutations
// 给定不含重复数字的数组,返回所有全排列
func permute(nums []int) [][]int {
    var result [][]int
    var current []int
    used := make([]bool, len(nums))

    var backtrack func()
    backtrack = func() {
        if len(current) == len(nums) {
            tmp := make([]int, len(current))
            copy(tmp, current)
            result = append(result, tmp)
            return
        }

        for i := 0; i < len(nums); i++ {
            if used[i] {
                continue
            }
            used[i] = true
            current = append(current, nums[i])
            backtrack()
            current = current[:len(current)-1]
            used[i] = false
        }
    }

    backtrack()
    return result
}

// 含重复数字的全排列(LeetCode 47)
func permuteUnique(nums []int) [][]int {
    sort.Ints(nums) // 先排序,方便剪枝
    var result [][]int
    var current []int
    used := make([]bool, len(nums))

    var backtrack func()
    backtrack = func() {
        if len(current) == len(nums) {
            tmp := make([]int, len(current))
            copy(tmp, current)
            result = append(result, tmp)
            return
        }

        for i := 0; i < len(nums); i++ {
            if used[i] {
                continue
            }
            // 剪枝:相同元素,前一个未使用时跳过(保证相同元素按顺序选取)
            if i > 0 && nums[i] == nums[i-1] && !used[i-1] {
                continue
            }
            used[i] = true
            current = append(current, nums[i])
            backtrack()
            current = current[:len(current)-1]
            used[i] = false
        }
    }

    backtrack()
    return result
}

算法3:组合总和(Combination Sum)

为什么? RRF 调权本质上是在满足 vectorW + keywordW = 1 约束下,搜索最优组合。组合问题是这类约束搜索的算法基础。

// LeetCode 39: Combination Sum
// 从 candidates 中找所有和为 target 的组合(可重复使用元素)
func combinationSum(candidates []int, target int) [][]int {
    sort.Ints(candidates) // 排序有助于剪枝

    var result [][]int
    var current []int

    var backtrack func(start, remaining int)
    backtrack = func(start, remaining int) {
        if remaining == 0 {
            tmp := make([]int, len(current))
            copy(tmp, current)
            result = append(result, tmp)
            return
        }

        for i := start; i < len(candidates); i++ {
            if candidates[i] > remaining {
                break // 已排序,后面的更大,直接剪枝
            }
            current = append(current, candidates[i])
            backtrack(i, remaining-candidates[i]) // i 不是 i+1,可重复使用
            current = current[:len(current)-1]
        }
    }

    backtrack(0, target)
    return result
}

// 变体:每个元素只能用一次(LeetCode 40)
func combinationSum2(candidates []int, target int) [][]int {
    sort.Ints(candidates)
    var result [][]int
    var current []int

    var backtrack func(start, remaining int)
    backtrack = func(start, remaining int) {
        if remaining == 0 {
            tmp := make([]int, len(current))
            copy(tmp, current)
            result = append(result, tmp)
            return
        }

        for i := start; i < len(candidates); i++ {
            if candidates[i] > remaining {
                break
            }
            // 跳过同层重复元素(避免重复组合)
            if i > start && candidates[i] == candidates[i-1] {
                continue
            }
            current = append(current, candidates[i])
            backtrack(i+1, remaining-candidates[i]) // i+1,不重复使用
            current = current[:len(current)-1]
        }
    }

    backtrack(0, target)
    return result
}

回溯模板总结:

backtrack(状态) { if 满足终止条件 → 记录结果 for 选项 in 所有可用选项 { 做选择 backtrack(更新后的状态) 撤销选择(回溯) } }

下一步:Day 11 预告

明天我们会:

  1. 对 top-20 候选进行 LLM Rerank(精排)
  2. 生成带引用的答案(citation [1][2] 格式)
  3. 防止 LLM 幻觉:答案必须基于检索结果

准备问题:

  • 为什么混合检索返回 top-20 之后还需要 rerank?
  • 什么是 cross-encoder,和 bi-encoder(embedding)有什么区别?
  • 如何保证 LLM 不编造检索结果里没有的内容?