VOL. 01 · 一份代码教学杂志 2026 / 春

simple_qmd
一份混合检索
系统的拆解

用一千五百行 Python
把分块、BM25、向量、RRF、
LLM 重排序逐一拆给你看。

Audience
会 Python 的本科生
Reading time
约 60 分钟
Code repo
examples/python/simple_qmd/
Companion
TUTORIAL.md(本页源材料)
01 Query 一句自然语言
02 BM25 + 向量 两个候选列表
03 RRF 合并到 top-30
04 LLM 重排 大模型打分
05 Result 附 context 链

目录 · CONTENTS

0 Overview

大图检索系统在做什么

一个文件夹,几百份笔记,一句口语化的查询。把 grep 换成什么,才能既精准又懂同义?

想象你有一个文件夹,里面有几百份 markdown 笔记。你想用一句自然语言("上个月那篇讲分布式锁的")找到对应文件。这就是检索(retrieval)问题。

如果文件少(少于 100 份),grep 够用。但当语料变大、查询变得更口语化、想要"同义"匹配("分布式锁" ↔ "DLM" ↔ "Redlock")时,grep 就不够了。

业界的标准方案是混合检索 + 重排序(hybrid retrieve & rerank):关键词通道做精确词命中,向量通道做语义相似,二者用 RRF 合并,最后让一个小型 LLM 给 top-30 重打分。

每一步都对应 simple_qmd 的一个模块:

步骤模块
文档分块chunk.py
关键词检索bm25.py
向量检索embed.py + search.py
RRF 混合search.py
LLM 重排序rerank.py
层级上下文context_tree.py
存储store.py
命令行cli.py

下面按这个顺序读代码。


1 Chunking

分块

把整篇文档当索引单元,会用 96% 的噪音淹掉 4% 的信号。先切再说。

为什么不直接整篇文档作为索引单位?

把整篇文档作为一个检索单元,会有两个问题:

  1. 稀释相关性:一篇 5000 字的笔记里,可能只有 200 字真正涉及「分布式锁」。整篇喂给检索算法,相关那 4% 的信号会被剩下 96% 的噪音淹没。
  2. 丢失定位:你想给学生看的是「第 3 节那个例子」,而不是整篇文档让他自己翻。

所以工业级 RAG 系统几乎都做 chunking:把文档切成 200–1000 字的小块,每块独立索引。检索后还能精准回到具体段落。

simple_qmd 的策略:标题切 + 滑动窗口兜底

打开 simple_qmd/chunk.py

def split(text: str, max_chars: int = 500, overlap: int = 50) -> list[str]:
    chunks = []
    for section in _sections(text):       # 1. 先按 markdown 标题切
        chunks.extend(_sliding(section, max_chars, overlap))  # 2. 长的段再滑窗
    return chunks

两步:

  • _sections:用正则 ^#{1,6}\s 找所有标题行,把文档切成「标题块」。这一步利用了 markdown 的语义结构——作者已经把相关内容组织在同一个标题下,没理由不利用。
  • _sliding:如果某段超过 500 字,再用「字符级滑动窗口」切,相邻 chunk 之间留 50 字的 overlap,避免把一个完整句子从中间切断。

试一下

>>> from simple_qmd.chunk import split
>>> doc = """# 引言
... 这是引言段落。
... # 主体
... """ + "x" * 1200 + """
... # 结论
... 结尾。"""
>>> chunks = split(doc, max_chars=500, overlap=50)
>>> len(chunks)
5
>>> [len(c) for c in chunks]
[12, 500, 500, 250, 6]  # 主体段触发了滑窗
思考题
  1. 为什么 overlap 不是 0?给一个具体例子说明 0 overlap 会丢掉什么信息。
  2. 如果你把 max_chars 调到 100,会有什么问题?调到 5000 呢?
  3. 这个分块策略对代码文件好用吗?为什么真实的 qmd 在处理 .py / .ts 时要用 AST 分块?

2 BM25

BM25:信息检索的水平线

TF 太朴素,IDF 太朴素。把两者乘起来再修两个常数 k1 和 b,就把整个搜索时代撑了起来。

从 TF-IDF 到 BM25

简化版:

  • TF(term frequency):词在文档里出现几次。多 = 相关。
  • IDF(inverse document frequency):词在多少文档里出现过。少 = 这个词有区分度("the" 在所有文档里都有,没用)。
  • TF-IDF:把两者乘起来。

但 TF-IDF 有两个朴素的问题:

  1. TF 饱和:词出现 100 次比出现 10 次"相关"10 倍吗?显然不是。需要一个饱和函数
  2. 文档长度偏置:一篇 10000 字的笔记,比一篇 500 字的,平均每个词都更易出现。需要长度归一化

BM25 把这两件事都解决了:

— Okapi BM25, k₁ = 1.5, b = 0.75 —
tf(t,d)
token t 在 chunk d 中出现次数
|d|, avgdl
chunk 长度,与平均长度
k₁
饱和速度,越小越快饱和
b
长度惩罚强度(0=不惩罚,1=完全归一)
IDF(t)
log((N − df + ½)/(df + ½) + 1)

打开 simple_qmd/bm25.py,公式直接以 ~50 行 Python 表达出来;常数K1 = 1.5B = 0.75MIN_TOKEN_LEN = 2

手算一遍

三个文档(chunk):

chunk 1 : "alpha alpha beta gamma"        |d|=4, tf(alpha)=2, tf(beta)=1
chunk 2 : "beta delta epsilon zeta"       |d|=4, tf(beta)=1
chunk 3 : "lorem ipsum dolor sit amet et" |d|=6, 不含查询词

avgdl = (4+4+6)/3 ≈ 4.67N=3df(alpha)=1df(beta)=2。查询 "alpha beta",对 chunk 1:

IDF(alpha) = ln((3−1+½)/(1+½) + 1) = ln(2.667) ≈ 0.981
IDF(beta)  = ln((3−2+½)/(2+½) + 1) = ln(1.6)   ≈ 0.470

norm = 1 − 0.75 + 0.75 × 4/4.67 ≈ 0.892

contrib(alpha) = 0.981 × (2 × 2.5) / (2 + 1.5 × 0.892) ≈ 1.469
contrib(beta)  = 0.470 × (1 × 2.5) / (1 + 1.5 × 0.892) ≈ 0.503

score(chunk 1) ≈ 1.972

倒排索引

要真的搜索,得先有"哪个词出现在哪些 chunk"的反向映射。这就是倒排索引

token  → [(chunk_id, tf), (chunk_id, tf), ...]

alpha  → [(1, 2)]
beta   → [(1, 1), (2, 1)]

store.py 里这是 bm25_postings 表,加上 bm25_stats 表存全局的 Navgdldfrebuild_bm25_index 负责扫描所有 chunk,重新统计这些数。

思考题
  1. 如果把 k1 设成 0,会发生什么?BM25 退化成什么?
  2. 如果 b=0,长文档和短文档会被同等对待。这在什么场景下是合理的?
  3. 一个全新加入的文档只在它一个里含 "blockchain" 这个词。它的 BM25 分数会很高。为什么这通常是好事?什么情况下不是?

3 Vectors

向量检索

BM25 解决不了同义词。把文本扔进 384 维空间,让"分布式锁"和"DLM"在那里靠近。

BM25 解决不了的问题

BM25 是精确词匹配。对这种查询:

  • 查询:「分布式系统怎么处理时钟漂移」
  • 文档:「Spanner 用 TrueTime API 解决跨数据中心的时间同步」

BM25 看不到联系。两边没有任何共享 token。但人类一眼能看出它们在讲同一件事。

要解决这个,我们要把文本映射到一个语义向量空间:相似含义的文本被映射到向量空间里相近的点。这种映射叫 embedding(嵌入)。

embedding 简介

class Embedder:
    @classmethod
    def encode(cls, texts: list[str]) -> np.ndarray:
        if is_mock():
            return np.stack([_mock_vector(t) for t in texts]).astype(np.float32)
        if cls._model is None:
            from sentence_transformers import SentenceTransformer
            cls._model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
        vecs = cls._model.encode(texts, normalize_embeddings=True, show_progress_bar=False)
        return np.asarray(vecs, dtype=np.float32)

真实模式下,每段文本被 MiniLM-L6-v2 这个 BERT 衍生模型映射到 384 维向量。这个模型用大量「句子对相似度」数据训练:相似句子在向量空间里被推到一起,不同的被推开。结果就是:余弦相似度 ≈ 语义相似度。

simple_qmd 还有一个 mock 模式SIMPLE_QMD_MOCK_EMBED=1),用 sha256 哈希 + 平均池化做了一个 64 维的"假 embedding"。它没有真正的语义能力,但确定性 + 不需要下载 2GB 模型——这正是教学/单元测试想要的。

余弦相似度

两个 L2 归一化后的向量,余弦相似度等于它们的点积:

search.py:vector_search 里:

def vector_search(store, query, *, n=10, collection=None):
    chunk_ids, vecs = store.load_vectors()
    q = embed.encode([query])[0]
    vnorm = vecs / (np.linalg.norm(vecs, axis=1, keepdims=True) + 1e-12)
    qnorm = q / (np.linalg.norm(q) + 1e-12)
    sims = vnorm @ qnorm           # 全部相似度,一次矩阵乘法
    order = np.argsort(-sims)      # 大到小
    ...
思考题
  1. 为什么计算 cos 之前要 L2 归一化?如果不归一化会怎样?
  2. 你的语料只有 1000 个 chunk,和 1 亿个 chunk,向量检索的工程实现会有什么区别?
  3. mock embedder 完全没有"语义"能力。为什么在 simple_qmd 的测试里它仍然能"找到对的文档"?(提示:看看测试构造的语料,和"假 embedding"在 token 集合一样时会有什么性质。)

4 RRF

RRF把两种检索合二为一

分数量纲不一,就别让它们直接相加。改看排名,魔法常数 60。

为什么要混合?

通道擅长不擅长
BM25罕见专有名词、错别字、代码同义词、口语化
向量同义词、跨语言、口语化罕见词、品牌名、人名

把两边各自取 top-30,融合成一个最终 top-30,往往两边都能受益。

朴素融合的坑

直觉是 final = α · bm25_score + (1−α) · vector_score。但这有问题:

  • BM25 分数可能是 0~5 的实数,向量相似度是 −1~1。量纲不一样
  • 不同查询下分数分布也变。今天 bm25_max=2,明天 bm25_max=20

需要做归一化、min-max、z-score…… 这条路通向调参地狱。

RRF:完全无视分数,只看排名

Reciprocal Rank Fusion

— Cormack et al., SIGIR 2009 —
RRF_K = 60

def _rrf_merge(lists: list[list[int]]) -> list[tuple[int, float]]:
    score = {}
    for lst in lists:
        for rank, cid in enumerate(lst):
            score[cid] = score.get(cid, 0.0) + 1.0 / (RRF_K + rank + 1)
    return sorted(score.items(), key=lambda x: x[1], reverse=True)

直观理解:

  • 一个文档在某通道排第 1,贡献 1/61 ≈ 0.0164
  • 排第 30,贡献 1/90 ≈ 0.0111
  • 在两个通道都进了前 5:1/65 + 1/65 ≈ 0.0308 比单通道排第 1 还高

关键洞察:分数大小完全不重要,只看相对排名。两个通道都看好的,一定排上去。

思考题
  1. 如果 k = 0,RRF 退化成什么?为什么 k = 60k = 0 更稳健?
  2. 写一个反例:BM25 给某文档极高的分数,向量没看到它,RRF 仍然可能让它落选。这是 RRF 的 bug 还是 feature?
  3. 如果有 3 个通道(关键词、向量、还有一个领域定制的标签匹配),RRF 怎么扩展?

5 Rerank

LLM 重排序

混合检索后的 top-30,前几名通常都不错——但精确顺序还得让大模型看一眼。

为什么 top-30 还要再筛?

混合检索后的 top-30 里,前几名通常都不错,但精确排序不一定对。LLM 的语言理解能力比 BM25 + embedding 加起来还强;让它读一遍 query + 候选段落,给个 0–10 分,比纯算分准。

代价:每个候选都要调一次 LLM = 慢 + 贵。所以只对 top-30 重排,不对全部 chunk 重排。这是经典的「retrieve-then-rerank」设计。

simple_qmd 的实现

PROMPT = """You are a relevance scorer. Given a query and a passage,
return a single integer 0-10 representing how relevant the passage is.
Output ONLY the number.

Query: {query}
Passage: {passage}
"""

def rerank(query, items, *, model=DEFAULT_MODEL):
    scored = []
    for item in items:
        try:
            resp = _ollama_chat(model=model, messages=[...], options={"temperature": 0.0})
            scored.append({**item, "rerank_score": _parse_score(resp["message"]["content"])})
        except Exception:
            ...  # fallback
    scored.sort(key=lambda x: x["rerank_score"], reverse=True)
    return scored

教学版每个候选单独打分(pointwise rerank),简单到能一眼看懂。工业级会用:

  • Pairwise:「文档 A 比 B 更相关吗?」
  • Listwise:「这 30 个文档按相关性从高到低排序」
  • 或者直接用专用的 cross-encoder(不是 LLM 而是 BERT 类小模型,专门训练成「输入 query+passage,输出一个分」),更快更便宜。

优雅降级

还做了一件重要事:如果 Ollama 没起,整个搜索不应该失败。

def rerank(query, items):
    failed = False
    for item in items:
        try:
            ...
            scored.append({..., "rerank_score": int_score})
        except Exception:
            if not failure_warned:
                print("[simple_qmd] rerank unavailable; falling back to RRF order", file=sys.stderr)
            failed = True
            scored.append({..., "rerank_score": None})
    if failed:
        return scored  # 保留输入顺序(即 RRF 顺序)
    scored.sort(...)
    return scored
思考题
  1. 如果 prompt 说 "Output a number 0 to 100",你需要改 _parse_score 吗?怎么改?
  2. Pointwise 重排比 Listwise 慢 30 倍。在什么场景下你愿意付这个代价?
  3. 如果 LLM 偶尔输出 "I don't know",当前代码会把分数当成 0。这有 bug 吗?

6 Context tree

Context 树

「我们决定用 PostgreSQL」——LLM 看到这句,根本不知道"我们"是谁。给它一棵祖先 context 树。

问题

假设你检索到一个 chunk:「最后我们决定用 PostgreSQL 而不是 MySQL。」LLM 看到这句话,根本不知道:"我们" 是谁?什么项目?什么时间?为什么这个决定?

解决:层级 context

qmd 允许给虚拟路径挂描述:

qmd:// 「Jay 的所有笔记」 qmd://project-foo 「2024 年的支付网关」 .../2024-Q3 「Q3 季度技术评审」 db-decision.md 命中的文档 ↑ 检索命中后,所有祖先 context 一并返回

检索命中 db-decision.md 时,把它所有祖先的 context 都带回来

[
  {"path": "qmd://",                  "description": "Jay 的所有笔记"},
  {"path": "qmd://project-foo",        "description": "2024 年的支付网关"},
  {"path": "qmd://project-foo/2024-Q3", "description": "Q3 技术评审"},
  {"chunk": "最后我们决定用 PostgreSQL 而不是 MySQL。"}
]

LLM 现在能正确理解上下文。

实现

def ancestor_paths(virtual_path: str) -> list[str]:
    if not virtual_path.startswith(SCHEME):
        return [GLOBAL]
    rest = virtual_path[len(SCHEME):]
    parts = [p for p in rest.split("/") if p]
    out = [GLOBAL]
    for i in range(1, len(parts)):
        out.append(SCHEME + "/".join(parts[:i]))
    return out

def collect_ancestors(virtual_path, contexts):
    return [{"path": p, "description": contexts[p]}
            for p in ancestor_paths(virtual_path) if p in contexts]

很短。其实就是字符串前缀枚举。但这个设计是 qmd 区别于普通向量数据库的核心

思考题
  1. 如果一个 chunk 命中后被附加了 4 层 context,LLM 的 prompt 会变长。在什么情况下你会想要返回最具体那一层?
  2. 设计一个反向问题:你如何把 git commit message + git blame 信息变成 context 自动挂到对应 chunk 上?

7 Storage

存储选 SQLite 不选 Pinecone

本地、零运维、ACID、可读性强。十几万 chunk 内 SQLite 远比 Pinecone 朴素得舒服。

为什么是 SQLite

教学版(也包括真实 qmd)选 SQLite + numpy,不选向量数据库专用品。原因:

  • 本地化:不需要起服务,开箱即用。
  • 事务性:collections / documents / chunks 跨表关联,SQLite 的 ACID 是免费的。
  • 足够快:单机几十万 chunk 内毫无压力。
  • 可读性强sqlite3 ~/.cache/simple_qmd/index.sqlite 一行就能 SELECT 看里面是什么。

向量另存 vectors.npz(numpy 自带的 zip 格式)。没有用 sqlite-vec 这种向量扩展,因为:

  • 教学版量级小,全表 numpy 矩阵乘法(O(N·d))够用且代码 5 行。
  • 加了扩展就额外多一层抽象,掩盖"向量检索本质就是矩阵乘"这件事。

Schema 关键设计

CREATE TABLE documents (
    collection TEXT NOT NULL,
    rel_path   TEXT NOT NULL,
    docid      TEXT NOT NULL,        -- = sha256(content)[:6]
    mtime      REAL NOT NULL,
    content    TEXT NOT NULL,
    PRIMARY KEY (collection, rel_path),     -- 一个路径一行
    FOREIGN KEY (collection) REFERENCES collections(name) ON DELETE CASCADE
);
CREATE INDEX idx_documents_docid ON documents(docid);
思考题
  1. chunks 表为什么不加 FOREIGN KEY (docid) REFERENCES documents(docid)?(提示:因为 documents 里 docid 不唯一了。FK 会拒绝插入。)
  2. 如果你把语料从 1 万个 chunk 扩到 1 亿个,schema 哪些地方需要改?
  3. bm25_stats 表里 df 字段存的是 JSON。这是个偷懒还是合理设计?换成每个 token 一行的 bm25_df(token, df) 表会怎么样?

8 Walkthrough

端到端走一遍

从空目录到「query 'redlock' 命中 lock.md 并附 context 链」,全部命令在这里。

# 安装
cd examples/python/simple_qmd
python -m venv .venv && source .venv/bin/activate
pip install -e '.[dev]'

# 准备一些笔记
mkdir -p /tmp/notes
cat > /tmp/notes/lock.md <<EOF
# 分布式锁选型
最终我们用 Redlock + 租约。Zookeeper 也考虑过但运维成本太高。
EOF
cat > /tmp/notes/clock.md <<EOF
# 时钟漂移问题
跨数据中心时使用 TrueTime / Spanner 的 commit-wait 思路。
EOF

# 注册 collection 并加 context
SIMPLE_QMD_MOCK_EMBED=1 simple-qmd add /tmp/notes --name notes
SIMPLE_QMD_MOCK_EMBED=1 simple-qmd context add / "Jay 的工程笔记"
SIMPLE_QMD_MOCK_EMBED=1 simple-qmd context add qmd://notes "工作中遇到的设计问题与解决方案"

# 索引
SIMPLE_QMD_MOCK_EMBED=1 simple-qmd index

# 三种检索方式
simple-qmd search "redlock"     # BM25
simple-qmd vsearch "redlock"    # 向量
simple-qmd query "redlock"      # 混合 + LLM 重排(Ollama 没起就降级)

# 拿全文
simple-qmd get lock.md --full

观察输出:每条结果都带了 context 链。这就是 qmd 的杀手锏——你可以把 simple-qmd query "..." --json 的输出直接喂给 LLM,对方能立刻理解上下文。


9 Exercises

综合练习

按难度排序。建议至少做出 1、2、5。

  1. 修复一个失败测试(10 分钟):把 simple_qmd/bm25.py 里的 K1 改成 0.5,跑一遍测试,看哪些挂了,理解为什么。改回去。
  2. 换个分块策略(30 分钟):在 simple_qmd/chunk.py 里加一个 split_by_sentences 函数,按 ./?/! 句号切。把它接入 CLI 的 index 命令(加一个 --chunk-strategy 选项)。比较两种策略下「query 'distributed lock' 召回 lock.md」的分数差异。
  3. 加一个新的检索通道(1 小时):实现一个 tag_search——文档头部 yaml front-matter 里 tags: [a, b, c],把 tag 匹配作为第三个候选源加入 _rrf_merge
  4. 改 BM25 公式(1 小时):阅读 BM25F,让标题(H1/H2)权重大于正文。修改 rebuild_bm25_index 让它给标题 token 加 boost,不改公式本身。
  5. 加 ANN 索引(2 小时):把 vector_search 替换成用 hnswlib。基准测试:1 万 chunk 时全表 numpy 和 ANN 的延迟。
  6. 批 listwise 重排(2 小时):改写 simple_qmd/rerank.py,把 30 个候选放进一个 prompt,让 LLM 一次输出排序。比较 pointwise vs listwise 的:(a) 总延迟、(b) 排序质量。
  7. 增量 BM25 索引(半天):现在每次 index 都全量重建。改成只更新被修改文档的 postings。难点:删除文档时也要从 postings 里减掉。写一个 fuzz 测试:反复随机增删,最后断言「全量重建结果」和「增量结果」一致。
  8. 暴露 MCP 接口(半天):用 Python MCP SDK 把 bm25_search/vector_search/hybrid_query/get 包装成 MCP tools。Claude Desktop 配置后能直接调用。
  9. 替换 embedding 模型(开放):MiniLM 换成 bge-small-en-v1.5text-embedding-3-small (OpenAI API)。设计对照实验:构造 20 个查询和 50 个文档,人工标注 ground truth,比较 nDCG@10。
  10. 写一份你自己的 README(开放):用你自己的语言重写一遍模块说明,让你的同学不用读这份教程也能看懂代码。

10 Further reading

进一步阅读

真要钻进去,这几篇是绕不过的。

  • BM25:Robertson & Zaragoza, The Probabilistic Relevance Framework: BM25 and Beyond(综述论文,理解公式来源)
  • RRF:Cormack, Clarke, Buettcher, Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods(SIGIR 2009,原始论文)
  • Cross-encoder vs Bi-encoder:sentence-transformers 文档里的 Retrieve & Re-Rank 那一节
  • HNSW(向量 ANN):Malkov & Yashunin, Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs
  • 真实工程qmd 主项目 的 TypeScript 实现,看看生产版本对每个组件做了哪些优化

FAQ

常见问题

读到这里,可能想吐槽几句。

为什么这份代码这么"啰嗦"?很多地方明明可以一行写完。

教学版优先可读性而非简洁。比如 bm25.score 故意把每一项写成显式变量名(normtoken_idf),就是为了让公式从代码里"读出来"。生产代码会更紧凑,但也更难一眼看懂。

测试为什么有 mock embedder?我能跑真模型测试吗?

可以。在测试上加 @pytest.mark.integration 标记,然后 pytest -m integration 就会跳过 autouse 的 mock 设置,跑真模型。

和 LangChain / LlamaIndex 比怎么样?

那些是框架,simple_qmd 是简化重实现。框架的目的是组合现成组件,代码量少;这份代码的目的是让你看懂每个组件内部在做什么。两者不是替代关系。读完这份再去读 LangChain,你会发现它的每个 abstract class 后面都站着一个本教程介绍过的概念。