什么是父文档检索(Parent Document Retriever)?它解决了什么问题?

#RAG#Agent#面试八股

什么是父文档检索(Parent Document Retriever)?它解决了什么问题?

结论:父文档检索把"检索粒度"和"生成粒度"拆开——用小块(child chunk)建索引,因为小块语义集中、检索准; 命中后不把小块直接喂模型,而是回溯到它所属的大块(parent chunk)再送进去,因为大块上下文完整、能回答"为什么"和"前后条件"。 它一次解决了"切小了检索准但没上下文、切大了有上下文但检索不准"的两难。

1. 两难的由来

切 1500 字的大块
  向量 = 整块内容的平均 → 语义被稀释 → 检索不准 ✘
  但内容完整 → 模型能答对 ✔

切 200 字的小块
  向量聚焦 → 检索准 ✔
  但只剩半句/半段 → 模型答不了"在什么条件下适用" ✘

父文档检索:小块负责"被找到",大块负责"被读懂"

2. 两层结构

层典型大小职责存放位置
子块 child150~300 token建向量索引、参与相似度检索与重排向量库(含 parent_id)
父块 parent800~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 换来两全:检索时看细节,生成时看全貌。凡是"答得对但答不全"或"检索准但用不上"的系统,都值得先试这一招。

笔记加载中…