本地知识库的混合检索架构:BM25、向量、RRF 融合与双链图扩展Hybrid Retrieval for a Local Markdown Knowledge Base: BM25, Vectors, RRF, and Wikilink Expansion
给本地 Markdown 知识库做检索增强(RAG)时,最容易写出的第一版原型通常是:把所有笔记按固定字数切块(Chunking),算一遍 Embedding 塞进向量数据库,查询时取 Top-K 喂给大模型。
但真正用起来很快就会发现三个严重问题:
- 专有名词与精确符号失灵:当你想找某个具体配置项、函数名、报错码或公式编号时,稠密向量(Dense Retrieval)很容易召回一堆“语义差不多但关键符号不对”的段落,反而不如最朴素的全文检索(BM25)精准。
- 割裂了双链笔记的图结构:在 Obsidian 这类知识库里,笔记之间的
[[Wikilink]]是作者深思熟虑建立的强语义关联。只做孤立切片检索,等于把一张认知图谱拍扁成了散装碎纸片。 - 没有“不知道”的边界:当库里根本没有相关笔记时,Top-K 依然会硬凑出几条低分结果,诱发大模型一本正经地胡说八道。
为了解决这三个问题,我在构建自己的本地知识库检索层时,把查询管线拆成了一条 “三路召回 → RRF 倒数秩融合 → 双链一跳图扩展 → 证据门禁(Evidence Gate) → 实时行号引用” 的确定性流水线。
为什么用 RRF 而不是把分数加权求和?
在同时引入 Metadata 精确匹配、SQLite FTS5 (BM25) 和 Dense 向量检索 三条通道后,第一个设计分岔口是如何合并三路结果。
很多教程会直接写 0.3 * bm25_score + 0.7 * cosine_similarity。但在工程实践中,这种线性加权非常脆弱:BM25 的分数没有固定上界,且随文档长度和词频剧烈波动;而不同 Embedding 模型的余弦相似度分布区间又极度集中在某个狭窄频带(如 0.65 ~ 0.85)。一旦换模型或语料规模变化,权重系数就会全部失效。
更稳健的做法是采用 RRF(Reciprocal Rank Fusion,倒数秩融合)——完全忽略各通道不可通约的原始分数,只看候选块在各自通道内的名次(Rank):
rrf_score = sum( 1 / (k + rank_i) ) (默认平滑常数 k = 60)
这样做有三个直接好处:
- 尺度无关:不管 BM25 打出 18.5 分还是 3.2 分,只看它在全文检索里排第几。
- 天然容错与优雅降级:如果本地向量模型未启动或降级为纯 CPU 模式,失败的通道直接不参与求和,单通道依然能用同一套公式输出稳定排序。
- 可解释的 Trace:原始分数仍然保留在诊断日志(Trace)中供人工审阅,但绝不跨通道混算。
当 rrf_score 并列时,系统依次按文件路径、章节序号、块序号做字典序决胜(Tie-breaking),保证相同的查询与索引快照永远返回 100% 一致的结果顺序。
把 [[Wikilink]] 变成一跳图扩展(Graph Expansion)
Markdown 笔记的一大优势在于显式的 [[双链]]。如果一篇关于“闭环控制”的笔记在排名前列被召回,而正文中显式链接了 [[拉普拉斯变换#终值定理]],那么即使“终值定理”那一段没有直接命中查询词,它往往也是回答问题不可或缺的上下文。
在入库阶段,解析器会提取正文中的非图片双链(排除 ![[image.png]] 这类静态附件),构建一张轻量有向图。在查询阶段,当 RRF 完成初步融合后:
- 选取种子(Seeds):从 RRF 排名前列的结果中取前
N个(如默认 5 个)不同来源的文件作为种子。 - 受控的一跳扇出(1-Hop Fan-out):只沿着种子笔记的出链(Outgoing Wikilinks)向外扩展一跳,绝不递归做二跳或全图漫游,并对目录页(MOC)这种高出度节点施加严格的扇出上限,防止把半个知识库都拉进来。
- 锚点精确定位:如果双链带有章节锚点(如
[[Note#Heading]]),直接定位到目标章节的首个有效块;如果是整篇笔记链接,则优先提取目标笔记的summary或导言段。 - 不篡改直接召回排序:通过图扩展补充进来的内容排在直接命中结果之后,并附带完整的溯源字段(
graph_provenance:记录是从哪个种子块、哪条双链跳入的),既扩充了上下文,又不会喧宾夺主。
多样化裁剪与 Evidence Gate(零证据门禁)
为了防止某一篇超长笔记霸占整个上下文窗口,图扩展结束后会执行一层多样性裁剪:同一篇笔记最多保留 2 个 Chunk,并按章节指纹去重。
紧接着是最关键的一步——Evidence Gate(证据门禁)。检索系统不应该假设“每次查询都必须返回 Top-5”。当出现以下任一情况时,管线会直接拦截结果并返回显式的 RAG_NO_EVIDENCE 状态:
- 所有启用通道均无命中;
- 融合后的最高综合置信信号低于在 Golden 测试集上校准出的冻结阈值;
- 命中的证据块在当前磁盘源文件上已无法解析出有效引用。
上层 Agent 收到 RAG_NO_EVIDENCE 后,就会坦诚告知用户“当前知识库中没有足够证据回答该问题”,而不是拿着不相关的边角料强行编造答案。
为什么行号必须在查询时实时解析?
很多本地 RAG 工具会把切块时的行号(比如 L42-L68)直接写死在数据库里。但本地 Markdown 知识库是活的——你可能随时用编辑器在文件开头加了两行字,还没来得及重跑全量索引。
因此,索引库里存的永远只是“快照行号”;在查询返回前的最后一公里,Live Citation Resolver 会重新核对磁盘上 Markdown 原文件的哈希值,通过标题层级(Heading Hierarchy)和段落指纹实时计算出当前最新的准确行号,并诚实标记该引用是 fresh(与索引一致)还是 source_changed(文件已微调但段落仍可唯一定位)。
归根结底,索引只是 Markdown 的只读派生加速层,磁盘上的纯文本文件永远是唯一的事实源(Single Source of Truth)。
When building Retrieval-Augmented Generation (RAG) over a local Markdown knowledge base, the most common first prototype is straightforward: slice every note into fixed-size chunks, compute embeddings, store them in a vector database, and feed the Top-K results to an LLM.
In real daily use, however, three flaws surface almost immediately:
- Exact symbols and proper nouns fail: When searching for a specific config key, function name, error code, or equation ID, dense vector retrieval often surfaces paragraphs that sound “semantically similar” but miss the exact token—performing worse than plain old BM25 full-text search.
- Wikilink graph structure is lost: In an Obsidian-style vault,
[[Wikilinks]]represent deliberate, high-signal semantic edges curated by the author. Retrieving isolated chunks flattens a cognitive graph into disconnected scraps of paper. - No boundary for “I don’t know”: Even when the vault contains zero relevant notes, Top-K retrieval still hands back the least-bad chunks, tempting the LLM into confident hallucination.
To solve these three problems, I designed my local retrieval layer as a deterministic five-stage pipeline: Three-Way Recall → Reciprocal Rank Fusion (RRF) → 1-Hop Wikilink Graph Expansion → Evidence Gate → Live Line-Number Citation Resolution.
Why Reciprocal Rank Fusion (RRF) Instead of Weighted Score Sums?
Once you combine Exact Metadata Matching, SQLite FTS5 (BM25), and Dense Vector Retrieval, the first architectural question is how to merge their outputs.
Many tutorials suggest a linear combination like 0.3 * bm25_score + 0.7 * cosine_similarity. In practice, raw score summation is brittle: BM25 scores are unbounded and vary wildly with document length and term rarity, while cosine similarity distributions from embedding models cluster tightly within narrow bands (e.g., 0.65 ~ 0.85). Swapping the embedding model or growing the corpus invalidates hard-coded weights overnight.
A far more resilient approach is Reciprocal Rank Fusion (RRF), which ignores incommensurable raw scores and fuses candidates purely by their rank position within each channel:
rrf_score = sum( 1 / (k + rank_i) ) (default smoothing constant k = 60)
This brings three immediate engineering benefits:
- Scale invariance: Whether BM25 outputs
18.5or3.2doesn’t matter—only whether the chunk ranked 1st or 5th in full-text search. - Graceful degradation: If the local embedding service is offline, the vector channel simply drops out of the sum while the remaining channels still produce a valid, deterministic ranking.
- Auditable diagnostics: Raw channel scores are preserved in diagnostic traces for human inspection, without ever polluting cross-channel ranking math.
Ties in rrf_score are broken deterministically by file path, section index, and chunk index, guaranteeing 100% reproducible ordering for any given query and index snapshot.
Turning [[Wikilinks]] into Controlled 1-Hop Graph Expansion
One of the biggest advantages of Markdown notes is explicit [[Wikilinks]]. If a top-ranked chunk on “Closed-Loop Control” explicitly links to [[Laplace Transform#Final Value Theorem]], that linked section is often essential context even if it didn’t directly match the user’s query terms.
During indexing, the parser extracts non-asset wikilinks (excluding image embeds like ![[diagram.png]]) into a lightweight directed graph. At query time, right after RRF fusion:
- Seed selection: Take the top
N(e.g., 5) distinct source notes from the RRF ranking as seed nodes. - Bounded 1-hop fan-out: Expand strictly one hop along outgoing wikilinks—never recursing into multi-hop graph walks—and enforce a strict fan-out cap to prevent Map-of-Content (MOC) index notes from pulling half the vault into context.
- Anchor-aware targeting: If a wikilink includes a heading anchor (
[[Note#Heading]]), resolve directly to the first chunk under that heading; otherwise, pull the target note’s frontmattersummaryor lead section. - Non-intrusive ordering: Graph-expanded chunks are appended after direct hits and tagged with full
graph_provenancemetadata (recording which seed chunk and wikilink brought them in), enriching context without overriding direct relevance.
Diversity Pruning and the Evidence Gate
To prevent a single long note from monopolizing the context window, a diversity pass caps output at at most 2 chunks per note and deduplicates by section fingerprint.
Next comes the most important safeguard: the Evidence Gate. A retrieval pipeline should never assume that every query deserves Top-5 results. The gate intercepts the response and returns an explicit RAG_NO_EVIDENCE status whenever:
- All active retrieval channels return zero hits;
- The highest fused confidence signal falls below a frozen threshold calibrated against a Golden evaluation set; or
- The matched chunk can no longer be resolved against the live file on disk.
When the upstream Agent receives RAG_NO_EVIDENCE, it honestly reports that the knowledge base lacks sufficient evidence rather than fabricating an answer from low-score noise.
Why Line Numbers Must Be Resolved Live at Query Time
Many local RAG tools bake line numbers (such as L42-L68) into the database at indexing time. But a personal Markdown vault is a living workspace—you might insert two lines at the top of a file minutes before asking a question, before a full re-index has run.
For that reason, stored line numbers are treated strictly as snapshot hints. Before returning results, a Live Citation Resolver checks the current SHA256 hash of the Markdown file on disk, re-locates the exact section via its heading hierarchy and paragraph fingerprint, computes the live line range, and marks the citation as either fresh or source_changed.
Ultimately, the index is just a disposable, read-only acceleration layer—the plain-text Markdown files on disk remain the single source of truth.