搜错误码,向量检索掉了链子
知识库问答上线初期,纯向量检索的问题很快露出来了。用户搜产品编号、错误码、人名、专业术语的时候,向量模型经常"理解"偏,返回一堆语义相近、关键词却对不上的文档;反过来,用户用口语化描述问题时,关键词检索又因为字面不匹配漏掉相关文档。
最典型的一个例子:用户搜"ERR_CONN_RESET",向量检索返回了一堆讲"网络连接问题"的通用文档,真正包含这个错误码的排查手册反而排在后面。精确匹配这件事,向量检索天然干不过关键词检索。
所以我们需要混合检索:把 BM25 关键词检索和向量检索的结果融合起来,取长补短。
三步:双路召回、融合、精排
- 双路召回:同时执行 BM25 关键词检索和向量相似度检索,各自返回 TopN;
- 分数归一化与融合:两路分数分布不同,BM25 无上界,向量余弦在 0 到 1 之间,得归一化后用 RRF(Reciprocal Rank Fusion)或加权求和来排;
- 重排序:融合后的候选集(通常 20 到 30 条)用 Cross-Encoder 重排序模型精排,取 Top5 作为最终上下文。
选型没什么可犹豫的:BM25 用 Elasticsearch,我们本来就有 ES 集群;向量用 Qdrant;Rerank 用 bge-reranker-v2-m3 本地部署。

双路召回,谁挂了用谁
两路检索并行跑,互不拖累,任一路失败不阻断,降级用另一路的结果:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
| type RetrievalResult struct {
DocID string
Content string
Score float64
Source string // "bm25" or "vector"
Metadata map[string]interface{}
}
func (s *HybridRetriever) Retrieve(ctx context.Context, query string, topK int) ([]RetrievalResult, error) {
// 并行执行两路检索
var (
bm25Results []RetrievalResult
vectorResults []RetrievalResult
bm25Err error
vecErr error
)
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
bm25Results, bm25Err = s.esClient.Search(ctx, query, topK*2)
}()
go func() {
defer wg.Done()
vectorResults, vecErr = s.qdrantClient.SearchByEmbedding(ctx, query, topK*2)
}()
wg.Wait()
// 任一路失败不阻断,降级用另一路
if bm25Err != nil {
s.log.Warn("bm25 failed, using vector only", zap.Error(bm25Err))
return vectorResults, vecErr
}
if vecErr != nil {
s.log.Warn("vector search failed, using bm25 only", zap.Error(vecErr))
return bm25Results, nil
}
// RRF 融合
fused := rrfFusion(bm25Results, vectorResults, 60)
if len(fused) > topK*2 {
fused = fused[:topK*2]
}
// Rerank 精排
reranked, err := s.reranker.Rerank(ctx, query, fused)
if err != nil {
s.log.Warn("rerank failed, using fused order", zap.Error(err))
if len(fused) > topK {
fused = fused[:topK]
}
return fused, nil
}
if len(reranked) > topK {
reranked = reranked[:topK]
}
return reranked, nil
}
|
RRF:只看排名,不看分数
融合是整个方案里最关键的一步。加权融合要调权重,RRF 干脆绕开原始分数,只看每一路的排名:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| // rrfFusion 基于倒数排名融合,k 为平滑常数(通常 60)
func rrfFusion(bm25, vector []RetrievalResult, k int) []RetrievalResult {
scores := make(map[string]float64)
resultMap := make(map[string]RetrievalResult)
addRank := func(results []RetrievalResult) {
for rank, r := range results {
scores[r.DocID] += 1.0 / float64(k+rank+1)
if _, exists := resultMap[r.DocID]; !exists {
resultMap[r.DocID] = r
}
}
}
addRank(bm25)
addRank(vector)
var fused []RetrievalResult
for docID, score := range scores {
r := resultMap[docID]
r.Score = score
fused = append(fused, r)
}
sort.Slice(fused, func(i, j int) bool {
return fused[i].Score > fused[j].Score
})
return fused
}
|
每路结果按排名取倒数再相加,k=60 做平滑。两路都靠前的文档,加出来的分数自然最高,不需要任何调参。
Rerank 收尾
融合排序还是粗排,最后一道交给 Cross-Encoder 精排:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
| type RerankerClient struct {
baseURL string
client *http.Client
}
type RerankRequest struct {
Query string `json:"query"`
Docs []string `json:"documents"`
TopN int `json:"top_n"`
}
type RerankResponse struct {
Results []struct {
Index int `json:"index"`
RelevanceScore float64 `json:"relevance_score"`
} `json:"results"`
}
func (c *RerankerClient) Rerank(ctx context.Context, query string, docs []RetrievalResult) ([]RetrievalResult, error) {
var texts []string
for _, d := range docs {
texts = append(texts, d.Content)
}
body, _ := json.Marshal(RerankRequest{Query: query, Docs: texts, TopN: len(texts)})
req, _ := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/rerank", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result RerankResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
reranked := make([]RetrievalResult, len(result.Results))
for i, item := range result.Results {
reranked[i] = docs[item.Index]
reranked[i].Score = item.RelevanceScore
}
return reranked, nil
}
|
ES 那边的 BM25 查询,title 加权,IK 分词:
1
2
3
4
5
6
7
8
9
10
11
| {
"query": {
"multi_match": {
"query": "ERR_CONN_RESET",
"fields": ["title^2", "content"],
"type": "best_fields",
"analyzer": "ik_max_word"
}
},
"size": 20
}
|
调优的五个细节
中文分词是第一个要处理的。ES 默认的标准分词器对中文按字切分,搜"支付订单"可能匹配到"订单支付",语义却丢了。换成 IK 分词器,索引用 ik_max_word、查询用 ik_smart,召回率明显提升。专业术语还得配自定义词典。
融合方案上我们对比过 RRF 和加权求和。加权融合要调权重,而且不同查询的最优权重不一样:精确查询该把 BM25 权重调高,语义查询该偏向向量这边。RRF 不依赖原始分数,不用调参,实际效果稳定,最后选了它。
Rerank 是延迟大头。Cross-Encoder 比向量检索慢一个数量级,单条约 10 到 30 毫秒,30 条批量要 200 到 500 毫秒。我们对候选集做了截断,只取融合后 Top30 进精排;实时对话这类延迟敏感的场景,Rerank 设 800 毫秒超时,超时就用融合排序兜底。
Embedding 模型也换过一版。之前用的英文预训练模型对中文召回一般,换成 bge-large-zh-v1.5 后,中文语义检索质量大幅提升。维度从 768 涨到 1024,Qdrant 的内存和索引时间有所增加,还在可接受范围内。
切分粒度是最后一个要较真的。chunk 太大,检索粒度粗、上下文噪声多;太小又丢完整语义。最后用的是 500 字加 50 字重叠的切分策略,表格和代码块单独保留,不切。
上线之后
首条命中率,也就是用户认为第一条就是答案的比例,从纯向量检索的约 60% 提到了 80% 以上,错误码、编号、人名这类查询改善最明显。
回头看,混合检索不是把两路结果拼在一起就完事,功夫在融合和精排这两个环节:BM25 管精确匹配,向量管语义理解,RRF 让两路结果公平合并,Rerank 再做最后一道精细排序。
封面图:luis perez / Flickr · CC BY 2.0