什么是父文档检索(Parent Document Retriever)?它解决了什么问题?
结论:父文档检索把"检索粒度"和"生成粒度"拆开——用小块(child chunk)建索引,因为小块语义集中、检索准; 命中后不把小块直接喂模型,而是回溯到它所属的大块(parent chunk)再送进去,因为大块上下文完整、能回答"为什么"和"前后条件"。 它一次解决了"切小了检索准但没上下文、切大了有上下文但检索不准"的两难。
1. 两难的由来
切 1500 字的大块
向量 = 整块内容的平均 → 语义被稀释 → 检索不准 ✘
但内容完整 → 模型能答对 ✔
切 200 字的小块
向量聚焦 → 检索准 ✔
但只剩半句/半段 → 模型答不了"在什么条件下适用" ✘
父文档检索:小块负责"被找到",大块负责"被读懂"
2. 两层结构
| 层 | 典型大小 | 职责 | 存放位置 |
|---|---|---|---|
| 子块 child | 150~300 token | 建向量索引、参与相似度检索与重排 | 向量库(含 parent_id) |
| 父块 parent | 800~2000 token | 作为上下文送进提示词 | 文档库 / KV / SQL / 对象存储 |
子块可以重叠、可以按句子切;父块通常就是一个 ## 小节或一页。关键字段只有一个:child 的 parent_id。
3. 检索流程
query
│ 向量检索
▼
child top-12
│ 取元数据里的 parent_id
▼
去重(多个 child 命中同一 parent,只留一份)
│ 按 parent 的最高 child 分数排序
▼
token 预算内取 3~5 个 parent
▼
prompt ──▶ LLM
4. 代码:建索引与检索
import hashlib
PARENTS: dict[str, str] = {} # 生产环境换成 Redis / PostgreSQL / 对象存储
def index_document(sections: list[str], source: str) -> None:
"""sections 是章节级父块;每个父块再切成若干子块入向量库。"""
for sec in sections:
pid = hashlib.md5(sec.encode()).hexdigest()[:16]
PARENTS[pid] = sec
children = chunk_by_sentence(sec, target=200) # 子块只用于检索
store.upsert(
ids=[f"{pid}:{i}" for i in range(len(children))],
documents=children,
embeddings=embed(children),
metadatas=[{"parent_id": pid, "source": source, "seq": i}
for i in range(len(children))],
)
def retrieve_parents(question: str, k: int = 12, top_n: int = 4) -> list[dict]:
hits = store.query(query_embeddings=embed([question]), n_results=k,
include=["metadatas", "distances"])
best: dict[str, float] = {}
for meta, dist in zip(hits["metadatas"][0], hits["distances"][0]):
pid, score = meta["parent_id"], 1 - dist
best[pid] = max(best.get(pid, 0.0), score) # 同一父块取最高分子块
ranked = sorted(best.items(), key=lambda kv: kv[1], reverse=True)
return [{"parent_id": pid, "score": s, "text": PARENTS[pid]} for pid, s in ranked[:top_n]]
# 输出:[{'parent_id': 'a1b2c3', 'score': 0.83, 'text': '## 差旅报销\n...'}, ...]
如果不想自己拼,主流框架已内置:LangChain 的 ParentDocumentRetriever 配 child_splitter / parent_splitter / docstore,
LlamaIndex 的 SentenceWindowNodeParser 则是"单句检索 + 前后 N 句窗口"的变体。
5. 几种相近方案
| 方案 | 检索单元 | 返回单元 | 特点 |
|---|---|---|---|
| 父文档检索 | 子块 | 父块 | 最常用,父子比例好调 |
| 句子窗口检索 | 单句 | 前后各 N 句 | 窗口固定,实现最简单 |
| 分层检索 | 叶子块 | 按需上卷到章节 | 适合超长手册 |
| 自动合并(Auto-merging) | 叶子块 | 命中数达标就合并到父节点 | 命中越集中,上下文越大 |
| 摘要树(RAPTOR) | 叶子 + 各级摘要 | 对应层级 | 兼顾全局性问题 |
6. 落地时的四个坑
| 坑 | 后果 | 处理 |
|---|---|---|
| 父块过大 | 一个父块就占满预算,噪声回归 | 父块上限 2000 token,超了拆 |
| 不做去重 | 同一章节被塞 5 遍 | 按 parent_id 去重 |
| 父块存内存 | 重启后 parent_id 悬空 | 持久化到 KV/对象存储,与向量库同版本 |
| 更新不同步 | 子块已更新、父块还是旧的 | 父子同事务写入,用版本号校验 |
7. 常见追问
Q:它和重排冲突吗? 不冲突,是串联关系:先用重排给子块排序,再按子块顺序取父块,父块之间还可以再排一次。
Q:父块大小怎么定? 以"一个能独立回答问题的语义单元"为准——一个 ## 小节、一页、一个问答对。经验值 800~2000 token。
Q:引用粒度会变粗吗? 会。引用到父块级别(章节/页),如果业务要求精确到句,就在父块里再回填子块的高亮区间。
Q:为什么不干脆用大块 + 更好的 embedding? 换模型成本高且不一定解决"平均化"问题;父子结构是结构层面的解法,与模型无关,可叠加。
小结
父文档检索用一个 parent_id 换来两全:检索时看细节,生成时看全貌。凡是"答得对但答不全"或"检索准但用不上"的系统,都值得先试这一招。