💡 昨天的向量搜索很强,但有致命盲点:用户搜索"Q3 sales report",向量搜索可能找不到,因为这是专有名词和数字,embedding 模型看不出语义。答案是混合检索。
引导问题:
答案揭示:
引导问题:
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的文档数
直觉理解:
错误做法: 直接平均分数
vector_score = 0.87, keyword_score = 0.65
合并 = (0.87 + 0.65) / 2 = 0.76
问题: 两种方法的分数量纲不同!
正确做法: 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?
场景化调权:
// 默认: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
}为什么需要 filter?
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",
},
},
},
},
},
},
}// 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
}// 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
}// 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)
}// 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
}// 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]) + "..."
}// 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(不归一化,稍快)// 推荐方法:在 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)
}运行前,问自己:
# 将 Day 8 的 chunks 写入 bleve
go run cmd/indexer/main.go --keyword
# 验证索引
# bleve 会在 data/bleve.index/ 目录下创建文件
ls data/bleve.index/# 对比向量搜索和关键词搜索的结果差异
SEARCH_QUERY="error code 0x8007001F" go run cmd/search/main.go
SEARCH_QUERY="如何提交申请" go run cmd/search/main.go创建一个对比测试,分别记录以下配置的召回率:
// 根据 query 特征自动选择权重
func autoWeight(query string) (vectorW, keywordW float64) {
// TODO: 实现以下逻辑
// - 包含数字、#、- 等符号 → keyword 偏重
// - 以"如何"、"为什么"、"什么是"开头 → vector 偏重
// - 其他 → 默认 0.6/0.4
}Q: bleve 和 Elasticsearch 相比,适合什么规模?
A: 对比如下:
Q: RRF 候选集大小怎么设置?
A: 经验规则:
候选集 = topK × 3
最终结果 = topK
例:要返回 5 个结果
向量搜索:取 top-15
关键词搜索:取 top-15
RRF 融合:在 15+15 个候选中选 top-5
候选集太小:好结果被截断 候选集太大:RRF 计算量增加(但通常可忽略)
Q: 如果用户 query 很短(1-2个词),两种搜索哪个更好?
A: 短 query 通常关键词搜索更好:
Q: Qdrant filter 是 pre-filter 还是 post-filter?
A: Qdrant 默认是 pre-filter(先过滤,再在过滤后的子集内搜索)。
exact: true 退化为暴力搜索)// 当过滤后子集很小时,用精确搜索
req.Filter = filter
// Qdrant 会自动检测并切换到暴力搜索模式为什么? 混合检索的候选集合并和去重,本质上是集合操作。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
}为什么? 理解搜索结果的排列组合,是设计混合检索评估指标(如 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
}为什么? 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(更新后的状态)
撤销选择(回溯)
}
}
明天我们会:
准备问题: