大图:检索系统在做什么
一个文件夹,几百份笔记,一句口语化的查询。把 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 |
下面按这个顺序读代码。
分块
把整篇文档当索引单元,会用 96% 的噪音淹掉 4% 的信号。先切再说。
为什么不直接整篇文档作为索引单位?
把整篇文档作为一个检索单元,会有两个问题:
- 稀释相关性:一篇 5000 字的笔记里,可能只有 200 字真正涉及「分布式锁」。整篇喂给检索算法,相关那 4% 的信号会被剩下 96% 的噪音淹没。
- 丢失定位:你想给学生看的是「第 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] # 主体段触发了滑窗
- 为什么
overlap不是 0?给一个具体例子说明 0 overlap 会丢掉什么信息。 - 如果你把
max_chars调到 100,会有什么问题?调到 5000 呢? - 这个分块策略对代码文件好用吗?为什么真实的 qmd 在处理 .py / .ts 时要用 AST 分块?
BM25:信息检索的水平线
TF 太朴素,IDF 太朴素。把两者乘起来再修两个常数 k1 和 b,就把整个搜索时代撑了起来。
从 TF-IDF 到 BM25
简化版:
- TF(term frequency):词在文档里出现几次。多 = 相关。
- IDF(inverse document frequency):词在多少文档里出现过。少 = 这个词有区分度("the" 在所有文档里都有,没用)。
- TF-IDF:把两者乘起来。
但 TF-IDF 有两个朴素的问题:
- TF 饱和:词出现 100 次比出现 10 次"相关"10 倍吗?显然不是。需要一个饱和函数。
- 文档长度偏置:一篇 10000 字的笔记,比一篇 500 字的,平均每个词都更易出现。需要长度归一化。
BM25 把这两件事都解决了:
- 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.5,B = 0.75,MIN_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.67,N=3,df(alpha)=1,df(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 表存全局的 N、avgdl、df。rebuild_bm25_index 负责扫描所有 chunk,重新统计这些数。
- 如果把
k1设成 0,会发生什么?BM25 退化成什么? - 如果
b=0,长文档和短文档会被同等对待。这在什么场景下是合理的? - 一个全新加入的文档只在它一个里含 "blockchain" 这个词。它的 BM25 分数会很高。为什么这通常是好事?什么情况下不是?
向量检索
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) # 大到小
...
- 为什么计算 cos 之前要 L2 归一化?如果不归一化会怎样?
- 你的语料只有 1000 个 chunk,和 1 亿个 chunk,向量检索的工程实现会有什么区别?
- mock embedder 完全没有"语义"能力。为什么在 simple_qmd 的测试里它仍然能"找到对的文档"?(提示:看看测试构造的语料,和"假 embedding"在 token 集合一样时会有什么性质。)
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:
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 还高
关键洞察:分数大小完全不重要,只看相对排名。两个通道都看好的,一定排上去。
- 如果
k = 0,RRF 退化成什么?为什么k = 60比k = 0更稳健? - 写一个反例:BM25 给某文档极高的分数,向量没看到它,RRF 仍然可能让它落选。这是 RRF 的 bug 还是 feature?
- 如果有 3 个通道(关键词、向量、还有一个领域定制的标签匹配),RRF 怎么扩展?
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
- 如果 prompt 说 "Output a number 0 to 100",你需要改
_parse_score吗?怎么改? - Pointwise 重排比 Listwise 慢 30 倍。在什么场景下你愿意付这个代价?
- 如果 LLM 偶尔输出 "I don't know",当前代码会把分数当成 0。这有 bug 吗?
Context 树
「我们决定用 PostgreSQL」——LLM 看到这句,根本不知道"我们"是谁。给它一棵祖先 context 树。
问题
假设你检索到一个 chunk:「最后我们决定用 PostgreSQL 而不是 MySQL。」LLM 看到这句话,根本不知道:"我们" 是谁?什么项目?什么时间?为什么这个决定?
解决:层级 context
qmd 允许给虚拟路径挂描述:
检索命中 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 区别于普通向量数据库的核心。
- 如果一个 chunk 命中后被附加了 4 层 context,LLM 的 prompt 会变长。在什么情况下你会想要只返回最具体那一层?
- 设计一个反向问题:你如何把 git commit message + git blame 信息变成 context 自动挂到对应 chunk 上?
存储:选 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);
chunks表为什么不加FOREIGN KEY (docid) REFERENCES documents(docid)?(提示:因为 documents 里 docid 不唯一了。FK 会拒绝插入。)- 如果你把语料从 1 万个 chunk 扩到 1 亿个,schema 哪些地方需要改?
bm25_stats表里df字段存的是 JSON。这是个偷懒还是合理设计?换成每个 token 一行的bm25_df(token, df)表会怎么样?
端到端走一遍
从空目录到「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,对方能立刻理解上下文。
综合练习
按难度排序。建议至少做出 1、2、5。
- 修复一个失败测试(10 分钟):把
simple_qmd/bm25.py里的K1改成 0.5,跑一遍测试,看哪些挂了,理解为什么。改回去。 - 换个分块策略(30 分钟):在
simple_qmd/chunk.py里加一个split_by_sentences函数,按./?/!句号切。把它接入 CLI 的index命令(加一个--chunk-strategy选项)。比较两种策略下「query 'distributed lock' 召回 lock.md」的分数差异。 - 加一个新的检索通道(1 小时):实现一个
tag_search——文档头部 yaml front-matter 里tags: [a, b, c],把 tag 匹配作为第三个候选源加入_rrf_merge。 - 改 BM25 公式(1 小时):阅读 BM25F,让标题(H1/H2)权重大于正文。修改
rebuild_bm25_index让它给标题 token 加 boost,不改公式本身。 - 加 ANN 索引(2 小时):把
vector_search替换成用hnswlib。基准测试:1 万 chunk 时全表 numpy 和 ANN 的延迟。 - 批 listwise 重排(2 小时):改写
simple_qmd/rerank.py,把 30 个候选放进一个 prompt,让 LLM 一次输出排序。比较 pointwise vs listwise 的:(a) 总延迟、(b) 排序质量。 - 增量 BM25 索引(半天):现在每次
index都全量重建。改成只更新被修改文档的 postings。难点:删除文档时也要从 postings 里减掉。写一个 fuzz 测试:反复随机增删,最后断言「全量重建结果」和「增量结果」一致。 - 暴露 MCP 接口(半天):用 Python MCP SDK 把
bm25_search/vector_search/hybrid_query/get包装成 MCP tools。Claude Desktop 配置后能直接调用。 - 替换 embedding 模型(开放):MiniLM 换成
bge-small-en-v1.5或text-embedding-3-small(OpenAI API)。设计对照实验:构造 20 个查询和 50 个文档,人工标注 ground truth,比较 nDCG@10。 - 写一份你自己的 README(开放):用你自己的语言重写一遍模块说明,让你的同学不用读这份教程也能看懂代码。
进一步阅读
真要钻进去,这几篇是绕不过的。
- 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 实现,看看生产版本对每个组件做了哪些优化
常见问题
读到这里,可能想吐槽几句。
为什么这份代码这么"啰嗦"?很多地方明明可以一行写完。
教学版优先可读性而非简洁。比如 bm25.score 故意把每一项写成显式变量名(norm、token_idf),就是为了让公式从代码里"读出来"。生产代码会更紧凑,但也更难一眼看懂。
测试为什么有 mock embedder?我能跑真模型测试吗?
可以。在测试上加 @pytest.mark.integration 标记,然后 pytest -m integration 就会跳过 autouse 的 mock 设置,跑真模型。
和 LangChain / LlamaIndex 比怎么样?
那些是框架,simple_qmd 是简化重实现。框架的目的是组合现成组件,代码量少;这份代码的目的是让你看懂每个组件内部在做什么。两者不是替代关系。读完这份再去读 LangChain,你会发现它的每个 abstract class 后面都站着一个本教程介绍过的概念。