Ch24: 三路检索源码——BM25、向量与并行调度
Part 8: WeKnora 源码走读 前置:Ch23 EventManager、Ch04 检索三路军 后续:Ch25 RRF 与 Rerank 源码
结论先行:BM25 守 精确词,向量守 语义,图谱守 关系(Ch26)。源码层重点看:索引怎么建、query 怎么编码、TopK 在哪截断、三路结果结构是否统一(供 RRF 消费)。
一、BM25 路
索引阶段(文档摄入后):
- 分词(中文:jieba 或等价)→ 倒排表
term → [doc_id, tf…] - 存储:可能 ES/SQLite/内建,以适配器为准
查询阶段:
// 概念流程
terms := tokenize(query)
scores := invertedIndex.Score(terms) // BM25 公式
top := selectTopK(scores, kRetrieve)
return toChunkHits(top)
大师调试:query 只含错误码时,BM25 应排第一;若向量压过 BM25,查融合权重与 RRF 是否过度偏语义路。
BM25 打分(复习):
\[score(D,Q) = \sum_{t \in Q} IDF(t) \cdot \frac{f(t,D)(k_1+1)}{f(t,D)+k_1(1-b+b|D|/avgdl)}\]二、向量路
索引:chunk → Embed(model) → 向量库 Upsert(id, vector, metadata)
查询:
qv := embedder.Embed(query)
hits := vectorStore.AnnSearch(qv, topK, filter{kb_id})
metadata 常见字段:kb_id, doc_id, chunk_index, source_url——Rerank 的 source_credibility 依赖这些。
大师调试:
- 换 embedding 模型后是否 重建索引?
- 过滤条件是否过严导致 Recall 崩?
见 Ch30 向量库适配器(待读)。
三、两路输出统一格式(RRF 的前置条件)
RRF 需要每路返回 有序 doc/chunk id 列表,而非不可比的分数量纲:
type RetrievalHit struct {
ChunkID string
Rank int // 1-based,在本路内的排名
Score float64 // 可选,RRF 可不用
Source string // "bm25" | "vector" | "graph"
}
读 fusion 包前,确认 retriever 返回结构是否一致——不一致是常见 bug 源。
四、并行与超时
三路应并行;任一路超时策略:
| 策略 | 效果 |
|---|---|
| 丢弃慢路 | 延迟↓ 召回可能↓ |
| 降级为双路 | 图谱常先被弃 |
| 等待最慢路 | 延迟=最慢路 |
WeKnora 倾向 尽量等齐再 RRF——读 config 里 graph_timeout 等项。
五、与 Dify/FastGPT 对比(源码思维)
| 项目 | 路数 | 融合 |
|---|---|---|
| WeKnora | 3 | RRF |
| Dify | 2 | 加权 |
| AnythingLLM | 1 | 无 |
改 WeKnora 的 BM25 分词 = 改 中文精确召回;竞品多数在 Python 层用现成库,WeKnora 在 Go 层自管索引——竞赛差异化空间。
六、动手:检索-only 实验
不写 LLM,只调内部 search API 或写临时 main:
- 同一 KB,query = 精确术语 → 看 BM25 路 top1
- 同一 query,改写成口语 → 看向量路 top1
- 记录两路 top5 重叠率——低重叠说明 三路融合必要
章末 · 大师自检
- 解释 BM25 与向量各自「必赢」的 query 类型
- 说清 AnnSearch 前为什么要
Embed(query) - 写出 RetrievalHit 为什么要有 Rank 字段
- 完成检索-only 双 query 对比实验
延伸阅读
- Ch04
- Ch11 Graph RAG(第三路)
- Ch25 RRF