面试必考!RAG 知识库全链路深度解析:父子分块 × Rerank × 查询重写 × 标准化改写

👋 开篇唠两句

我是折腾派程序员,一个专门把自己折腾明白再来讲给你听的后端老油条。

最近在准备面试,把 RAG 知识库从头到尾梳理了一遍,发现市面上的文章要么只讲概念不给代码,要么代码一堆但解释语焉不详。于是干脆自己写一篇,把 RAG 最核心的四块技术------父子分块、Reranking、查询重写(扩写+改写)、标准化改写------串成一条完整的工业级链路,一次说清楚。

如果你正在做 RAG 项目、准备大厂面试,或者就是单纯想搞明白"检索质量到底怎么提升",这篇文章应该能给你一些实质性的帮助。


一、背景痛点:Naive RAG 为什么撑不住?

在正式讲技术之前,先说清楚我们在解决什么问题

最简单的 RAG 流程是这样的:

css 复制代码
用户 Query → 向量化 → 向量数据库检索 → Top-K 文本块 → 送入 LLM → 生成答案

这套流程在 Demo 阶段跑得很顺,但一旦上生产,就会暴露出以下几个典型问题:

问题一:块太大 or 块太小,两难困境

  • 块太大(1000+ tokens):向量语义被稀释,检索精度差,不相关内容混进来
  • 块太小(50 tokens 以内):召回是精了,但上下文残缺,LLM 看不懂,回答质量差

这两个目标天然矛盾,传统固定大小分块无法同时满足。

问题二:向量相似度 ≠ 真正相关

向量检索本质是近似最近邻(ANN)搜索,优化的是召回率,不是精度。余弦相似度高,并不代表这段文本真正能回答用户的问题。Top-K 里混入语义相近但答非所问的噪声块,是家常便饭。

问题三:用户 Query 本身就是检索的最大障碍

现实中的用户提问往往是这样的:

  • "那个德国的税怎么算的" → 口语化,专业术语缺失
  • "法国呢?" → 多轮对话,指代词无法独立检索
  • "比较一下德法个税区别和计算方式" → 多意图混合,单次检索必然顾此失彼
  • "IIT" → 缩写,向量库里存的是全称,匹配不上

以上三个问题,对应三条技术解法:

痛点 解法
块大小两难 父子分块(Parent-Child Chunking)
向量精度不足 Reranking(Cross-Encoder 重排)
Query 质量差 查询重写(扩写 + 改写 + 标准化)

下面逐一拆解。


二、父子分块:小块检索,大块供料

2.1 核心思想

用小块做相似度检索,命中后返回其父块给 LLM。

子块粒度细,向量语义聚焦,检索精度高;父块上下文完整,LLM 理解质量高。两者通过 parent_id 关联,互不干扰。

2.2 架构图

erlang 复制代码
原始文档
   │
   ├── 父块 P1(500~2000 tokens)→ 存入 Document Store(Redis / MongoDB)
   │     ├── 子块 C1-1(100~500 tokens)→ 向量化 → 存入 Qdrant,携带 parent_id
   │     ├── 子块 C1-2
   │     └── 子块 C1-3
   │
   ├── 父块 P2
   │     ├── 子块 C2-1
   │     └── 子块 C2-2
   └── ...

关键分工:

存什么 存哪里 用途
子块向量 + parent_id 向量数据库(Qdrant) 相似度检索
父块原文 Document Store(内存/Redis) 提供完整上下文给 LLM

2.3 完整检索流程

css 复制代码
用户 Query
    ↓
Query 向量化(Embedding)
    ↓
Qdrant 向量检索 → Top-K 子块
    ↓
提取 parent_id
    ↓
Document Store 反查父块原文(去重:多个子块可能同属一父块)
    ↓
父块文本 → 组装 Prompt → LLM 生成答案

2.4 LangChain 代码实现

py 复制代码
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryStore
from langchain_community.vectorstores import Qdrant

# 父块分割器(粗粒度,提供上下文)
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=1000)

# 子块分割器(细粒度,用于向量检索)
child_splitter = RecursiveCharacterTextSplitter(chunk_size=200)

# 父块存储(Document Store)
docstore = InMemoryStore()

# 向量库(只存子块向量)
vectorstore = Qdrant(...)

# 组装 Retriever
retriever = ParentDocumentRetriever(
    vectorstore=vectorstore,
    docstore=docstore,
    child_splitter=child_splitter,
    parent_splitter=parent_splitter,
)

# 写入文档(自动处理父子关系,生成 parent_id 并写入 payload)
retriever.add_documents(docs)

# 检索(命中子块 → 自动返回父块)
results = retriever.get_relevant_documents("德国个人所得税税率")

2.5 进阶变体

① 多级父子(3 级层次)

复制代码
文档 → 章节(祖父块)→ 段落(父块)→ 句子(子块)

适合超长文档(如法规全文、技术手册),检索命中句子,上报章节级上下文。

② 句子窗口(Sentence Window)

不严格划父子,而是检索到目标句子后,自动扩展前后 N 句作为上下文。本质上是"动态父块",LlamaIndex 有现成实现(SentenceWindowNodeParser)。


三、父子分块 + Reranking:精度与上下文双重保障

3.1 为什么还需要 Reranking?

父子分块解决了上下文丢失的问题,但向量检索本身的精度问题还在:

  • ANN 搜索优化的是召回率,不是精度
  • Top-K 里可能混入"语义相近但答非所问"的噪声子块
  • 多个子块命中不同父块时,哪个父块优先级更高?

Reranker(Cross-Encoder 重排器) 用更精细的交叉注意力模型对每个 (query, chunk) 对重新打分,过滤噪声,提升最终精度。

实证数据:

  • 在金融报告 RAG 系统中,加入 Cross-Encoder Reranking 后,答案正确率从 33.5% 提升至 49.0% ,提升 15.5 个百分点,完全错误答案比例从 35.3% 降至 22.5%
  • 综合多项研究,Cross-Encoder Reranking 通常带来 10--25% 的精度提升

3.2 两阶段检索架构

css 复制代码
Query
  │
  ▼
① 向量检索(ANN,宽召回)      ← 快,Top-30 子块,保召回率
  │
  ▼
② Cross-Encoder Rerank 精排    ← 准,从30个里选Top-5,保精度
  │
  ▼
③ parent_id 反查父块 + 去重    ← 多子块命中同父块时合并,分数叠加
  │
  ▼
④ 父块送入 LLM Context         ← 上下文完整,质量高
  │
  ▼
⑤ 生成回答

⚠️ 关键顺序:先 Rerank 子块,再回查父块。

不要先查父块再 Rerank------父块太长会让 Reranker 效果变差,且速度慢。

3.3 Bi-Encoder vs Cross-Encoder 本质区别

css 复制代码
Bi-Encoder(向量检索):
  Query → Embed → [q_vec]
  Doc   → Embed → [d_vec]
  Score = cosine([q_vec], [d_vec])   ← 向量已分别编码,无交叉信息

Cross-Encoder(Reranker):
  [Query + Doc] → Transformer → Score  ← 同时看到二者,能捕捉深层交互

Bi-Encoder 快但精度存在上限;Cross-Encoder 慢但精度更高。两者组合是标准生产方案。

3.4 完整代码实现

py 复制代码
from qdrant_client import QdrantClient
from FlagEmbedding import FlagReranker
from typing import List, Dict

# 初始化
qdrant = QdrantClient(host="localhost", port=6333)
reranker = FlagReranker('BAAI/bge-reranker-v2-m3', use_fp16=True)

# --------- 第一阶段:向量召回子块(宽召回)---------
def vector_recall(query: str, top_k: int = 30) -> List[Dict]:
    query_vector = embed(query)
    results = qdrant.search(
        collection_name="tax_policy_chunks",
        query_vector=query_vector,
        limit=top_k,
        with_payload=True
    )
    return [
        {
            "chunk_id": r.id,
            "parent_id": r.payload["parent_id"],
            "text": r.payload["text"],
            "vector_score": r.score
        }
        for r in results
    ]

# --------- 第二阶段:Cross-Encoder 精排子块 ---------
def rerank_chunks(query: str, chunks: List[Dict], top_n: int = 5) -> List[Dict]:
    pairs = [[query, chunk["text"]] for chunk in chunks]
    # normalize=True 将分数映射到 [0, 1]
    scores = reranker.compute_score(pairs, normalize=True)

    for i, chunk in enumerate(chunks):
        chunk["rerank_score"] = float(scores[i])

    ranked = sorted(chunks, key=lambda x: x["rerank_score"], reverse=True)
    return ranked[:top_n]

# --------- 第三阶段:回查父块,多子块加权 ---------
def fetch_parent_docs(top_chunks: List[Dict], docstore: dict) -> List[str]:
    seen_parents: Dict[str, Dict] = {}

    for chunk in top_chunks:
        pid = chunk["parent_id"]
        if pid not in seen_parents:
            seen_parents[pid] = {
                "text": docstore[pid],
                "score": chunk["rerank_score"]
            }
        else:
            # 同一父块被多个子块命中 → 分数叠加,优先级更高
            seen_parents[pid]["score"] += chunk["rerank_score"] * 0.5

    sorted_parents = sorted(
        seen_parents.values(),
        key=lambda x: x["score"],
        reverse=True
    )
    return [p["text"] for p in sorted_parents]

# --------- 完整 Pipeline ---------
def rag_pipeline(query: str, docstore: dict) -> str:
    candidates   = vector_recall(query, top_k=30)  # 宽召回
    top_chunks   = rerank_chunks(query, candidates, top_n=5)  # 精排
    parent_docs  = fetch_parent_docs(top_chunks, docstore)   # 回查父块
    context      = "\n\n---\n\n".join(parent_docs)
    return call_llm(query, context)

同父块多子块命中的意义:

ini 复制代码
子块 C1-1  rerank_score=0.91 ─┐
子块 C1-2  rerank_score=0.76 ─┤ → 父块 P1 综合得分 = 0.91 + 0.76×0.5 = 1.29
子块 C1-3  rerank_score=0.43 ─┘

子块 C2-1  rerank_score=0.88 ─── 父块 P2 得分 = 0.88

→ 父块 P1 优先进入 Context(三个子块同时相关,这段文档整体高度相关)

3.5 Reranker 模型选型(2024 年后官方建议)

场景 推荐模型
中英文混合(通用首选) BAAI/bge-reranker-v2-m3
追求更高精度 BAAI/bge-reranker-v2-minicpm-layerwisebge-reranker-v2-gemma
纯英文,极速低延迟 cross-encoder/ms-marco-MiniLM-L-6-v2
懒得自部署,云端 Cohere Rerank API(约 $1/1000 次请求)

注意: bge-reranker-large 是较老版本,官方已推荐使用 v2 系列的新变体。选型时务必在自己的真实数据集上做 benchmark,不同域差异显著。


四、查询重写:从源头提升检索质量

4.1 整体分类

markdown 复制代码
查询重写
  ├── 扩写(Expansion)       → 增加信息量,提升召回率,宁多勿漏
  │     ├── HyDE              → 假设性文档嵌入
  │     ├── 多路召回           → 同义词/多语言多版本检索
  │     └── 子问题分解         → 复杂问题拆多个子查询
  │
  └── 改写(Reformulation)   → 提升表达质量,提升精度,宁准勿滥
        ├── 意图澄清改写
        ├── 多轮对话 Query 压缩  ← 面试必考!
        └── 标准化改写

核心差异: 扩写保召回,改写保精度;先改写再扩写是推荐顺序。


4.2 扩写(Expansion)

4.2.1 HyDE:假设性文档嵌入

最重要,面试高频考点。

出处: Luyu Gao et al., 2022,论文《Precise Zero-Shot Dense Retrieval without Relevance Labels》,arXiv:2212.10496。

痛点: 用户 Query 是一句话,文档库里存的是陈述性段落,两者在向量空间里天然有 gap。

思路:

与其用"问题向量"检索,不如先让 LLM 生成一段假设性答案,用"答案向量"检索------答案和文档在语义空间里更接近。

arduino 复制代码
原始 Query: "德国个人所得税税率"
     ↓
LLM 生成假设性文档(zero-shot,生成 5 次取平均):
"德国个人所得税采用累进税率制度,税率从 14% 到 45%
 不等,年收入超过 277,826 欧元适用最高税率 45%..."
     ↓
对 5 段假设文档分别 Embedding → 取向量均值
     ↓
用均值向量检索真实文档

📌 细节说明: 原论文是生成 5 次假设文档取向量均值,工程简化为 1 次也有效,但多次平均鲁棒性更强。

py 复制代码
def hyde_retrieve(query: str, vectorstore, n: int = 5) -> List[str]:
    # 生成 n 段假设性答案
    hypothetical_docs = []
    for _ in range(n):
        hypo = llm.invoke(
            f"请用2-3句专业语言回答以下问题(即使不确定也给出答案):{query}"
        )
        hypothetical_docs.append(hypo)

    # 分别向量化后取均值
    embeddings = [embed(doc) for doc in hypothetical_docs]
    avg_embedding = [sum(e[i] for e in embeddings) / n
                     for i in range(len(embeddings[0]))]

    # 用均值向量检索
    return vectorstore.similarity_search_by_vector(avg_embedding, k=10)

优点: 显著提升专业领域文档的召回率(BEIR 基准:nDCG@10 从 44.5 提升到 61.3)

缺点: 多一次(或多次)LLM 调用,延迟增加;若领域高度专业且 LLM 未训练,假设答案可能引偏检索

4.2.2 多路召回(Multi-Query)

同一个 Query 生成多个语义等价表达,分别检索后去重合并:

py 复制代码
def multi_query_retrieve(query: str, vectorstore) -> List[str]:
    prompt = f"""
    对以下问题生成3个不同的表达方式,用于检索相关文档,每行一个:
    原问题:{query}
    """
    variants = llm.invoke(prompt).strip().split("\n")
    # ["德国个税税率", "Germany income tax rate", "德国所得税计算方式"]

    all_results, seen_ids = [], set()
    for v in variants:
        for doc in vectorstore.similarity_search(v, k=10):
            if doc.metadata["id"] not in seen_ids:
                all_results.append(doc)
                seen_ids.add(doc.metadata["id"])
    return all_results

适用场景: 中英文混合文档库(同一概念可能以多种语言存储);专业术语存在多种表达方式的领域。

LangChain 内置实现:MultiQueryRetriever

4.2.3 子问题分解(Query Decomposition)

复杂问题拆成多个原子子问题,分别检索,结果聚合后综合回答:

arduino 复制代码
原始 Query: "对比德国和法国的个人所得税率差异及计算方法"
     ↓
分解:
  ├── 子问题1: "德国个人所得税率结构是什么?"
  ├── 子问题2: "法国个人所得税率结构是什么?"
  └── 子问题3: "德法两国个税在计算方法上有何不同?"
     ↓
分别检索 → 聚合结果 → LLM 综合回答

注意事项:

  • 子问题之间若有依赖关系(B 的答案依赖 A 的结果),需串行执行,不能并行
  • 并行还是串行,需要 LLM 先判断依赖关系,LangGraph 的 Plan-and-Execute 模式可处理此类场景
  • RAG-Fusion 技术:多路改写后用 Reciprocal Rank Fusion(RRF) 融合结果,比简单去重效果更好

4.3 改写(Reformulation)

4.3.1 意图澄清改写

将口语化、模糊的 Query 改写为精准的检索表达:

ini 复制代码
REWRITE_PROMPT = """
你是一个{domain}知识库检索助手。
将用户的口语化问题改写为专业、精确的检索查询。
只输出改写后的查询,不要解释,不要序号。

用户问题:{query}
改写后:
"""

# 示例
# 输入: "那个税怎么扣"
# 输出: "个人所得税预扣预缴计算方法"

4.3.2 多轮对话 Query 压缩(面试必考)

多轮对话中,用户提问往往依赖上文,直接检索必然失败:

arduino 复制代码
第1轮 用户: "德国个人所得税税率是多少?"
第1轮 AI:  "德国采用累进税率,14%~45%..."

第2轮 用户: "那法国呢?"  ← 直接检索"那法国呢"→ 检索失败!

需要将对话历史 + 当前问题压缩成独立的完整 Query

py 复制代码
def compress_query(chat_history: List[dict], current_query: str) -> str:
    history_str = "\n".join(
        f"{msg['role']}: {msg['content']}"
        for msg in chat_history[-4:]  # 取最近4轮,避免上下文过长
    )
    prompt = f"""
    根据对话历史,将最新问题改写为一个完整、独立、可单独用于检索的问题。
    不要回答问题,只输出改写后的问题,不要序号,不要解释。

    对话历史:
    {history_str}

    当前问题:{current_query}
    改写后的独立问题:
    """
    return llm.invoke(prompt).strip()

# 效果:
# "那法国呢?" → "法国个人所得税税率是多少?"
# "那里怎么申报?" → "法国个人所得税申报流程是什么?"

LangChain 对应类:ContextualCompressionRetriever


五、标准化改写:深度实现

标准化改写是改写分支中最偏向"领域工程"的一块,核心是将非标准表达映射到知识库中存在的标准术语,弥合用户语言和文档语言之间的 gap。

5.1 方案 A:词典映射(规则驱动)

最简单,零 LLM 成本,适合高频固定术语:

py 复制代码
import re
from typing import Tuple

TAX_TERM_DICT = {
    # 中文同义词
    "个税": "个人所得税",
    "企业税": "企业所得税",
    "社保": "社会保险",
    "五险一金": "社会保险和住房公积金",
    # 缩写展开(中英互查)
    "IIT": "个人所得税(Individual Income Tax)",
    "CIT": "企业所得税(Corporate Income Tax)",
    "VAT": "增值税(Value Added Tax)",
    "WHT": "预扣税(Withholding Tax)",
    "CGT": "资本利得税(Capital Gains Tax)",
    # 英文同义词
    "income tax": "个人所得税(Income Tax)",
    "withholding tax": "预扣税(Withholding Tax)",
}

def dict_standardize(query: str) -> Tuple[str, bool]:
    result, hit = query, False
    for informal, standard in TAX_TERM_DICT.items():
        pattern = re.compile(re.escape(informal), re.IGNORECASE)
        if pattern.search(result):
            result = pattern.sub(standard, result)
            hit = True
    return result, hit

# 示例
# 输入: "德国IIT税率和VAT有什么区别"
# 输出: "德国个人所得税(Individual Income Tax)税率和增值税(Value Added Tax)有什么区别"

缺点: 覆盖不了未登录词(用户首次使用的新表达),词典维护成本随时间增加。

5.2 方案 B:LLM 改写(语义驱动)

用 LLM 理解语义后输出标准化,覆盖长尾表达:

python 复制代码
STANDARDIZE_PROMPT = """
你是一个国际税务知识库的查询优化助手。
请将用户的查询标准化,要求:

1. 将口语/缩写替换为专业税务术语
2. 中英文术语并列标注(如:个人所得税/IIT)
3. 补全语境中可以明确的国家或税种信息
4. 保持原始问题的核心意图不变
5. 只输出标准化后的查询,不要解释

示例:
输入:"法国vat多少"
输出:"法国增值税(VAT)标准税率"

输入:"那个预扣税怎么算"
输出:"预扣税(Withholding Tax)计算方法"

用户查询:{query}
标准化结果:"""

def llm_standardize(query: str) -> str:
    return llm.invoke(STANDARDIZE_PROMPT.format(query=query)).strip()

5.3 方案 C:NER + 实体标准化(精度最高)

先 NER 提取实体,再对实体分别标准化,最后重建 Query:

py 复制代码
import json

NER_PROMPT = """
从以下税务查询中提取实体,以JSON格式返回(无法提取的字段值为null):
{{
  "country": "国家名(标准英文名)",
  "tax_type": "税种(标准中文名)",
  "query_intent": "查询意图(从:税率/计算方法/申报流程/豁免条件/对比分析 中选一)",
  "time_range": "时间范围(如有,格式:YYYY)"
}}

查询:{query}
"""

COUNTRY_MAP = {
    "德国": "Germany", "法国": "France",
    "英国": "United Kingdom", "美国": "United States",
    "日本": "Japan", "新加坡": "Singapore",
}

TAX_TYPE_MAP = {
    "个税": "个人所得税(IIT)",
    "企业税": "企业所得税(CIT)",
    "增值税": "增值税(VAT)",
    "预扣税": "预扣税(WHT)",
}

def ner_standardize(query: str) -> str:
    raw = llm.invoke(NER_PROMPT.format(query=query))
    clean = raw.replace("```json", "").replace("```", "").strip()
    entities = json.loads(clean)

    # 实体标准化
    if entities.get("country"):
        entities["country"] = COUNTRY_MAP.get(
            entities["country"], entities["country"]
        )
    if entities.get("tax_type"):
        entities["tax_type"] = TAX_TYPE_MAP.get(
            entities["tax_type"], entities["tax_type"]
        )

    # 重建 Query
    rebuild_prompt = f"""
    原始查询:{query}
    标准化实体:{json.dumps(entities, ensure_ascii=False)}
    请用标准化实体重写查询,保持原意,只输出重写后的查询:
    """
    return llm.invoke(rebuild_prompt).strip()

5.4 方案 D:混合方案(生产推荐)

词典先跑(快、零成本),命中则直接返回;LLM 兜底长尾。

py 复制代码
class QueryStandardizer:

    COLLOQUIAL_SIGNALS = ["那个", "怎么", "咋", "多少钱", "啥", "搞懂"]

    def __init__(self):
        self.patterns = {
            re.compile(re.escape(k), re.IGNORECASE): v
            for k, v in TAX_TERM_DICT.items()
        }

    def _dict_pass(self, query: str) -> Tuple[str, bool]:
        result, hit = query, False
        for pattern, standard in self.patterns.items():
            if pattern.search(result):
                result = pattern.sub(standard, result)
                hit = True
        return result, hit

    def _need_llm(self, query: str) -> bool:
        if any(s in query for s in self.COLLOQUIAL_SIGNALS):
            return True
        if len(query.strip()) < 8:  # 太短,信息不足
            return True
        return False

    def standardize(self, query: str) -> str:
        after_dict, _ = self._dict_pass(query)
        if self._need_llm(query):
            return llm_standardize(after_dict)  # 在词典结果基础上再 LLM
        return after_dict


# 使用示例
standardizer = QueryStandardizer()

test_cases = [
    ("德国IIT税率",           "词典命中,直接返回"),
    ("那个法国增值税咋算的",  "口语,触发 LLM"),
    ("WHT",                   "太短,触发 LLM 补全"),
]

for query, note in test_cases:
    result = standardizer.standardize(query)
    print(f"[{note}]\n  原始: {query}\n  标准: {result}\n")

5.5 词典冷启动与持续维护策略

阶段 做法
冷启动 人工整理领域术语表,参考 ISO、国家税务局官方术语
运营阶段 收集用户 Query 日志,分析未命中的高频词,持续扩充
自动化 让 LLM 批量生成同义词对 → 人工审核后入库
版本管理 词典用 Git 管理,支持回滚;生产环境热加载,不停服更新

六、完整工业级 RAG 链路整合

把以上所有环节串起来:

sql 复制代码
用户 Query  +  对话历史
      │
      ▼ ① 多轮 Query 压缩(有历史时)
      │   "那法国呢" → "法国个人所得税税率是多少"
      │
      ▼ ② 标准化改写(词典 + LLM 混合)
      │   "法国个人所得税税率" → "法国个人所得税(IIT)税率标准"
      │
      ▼ ③ 意图澄清改写(可选,口语明显时触发)
      │
      ▼ ④ 多路扩写(生成3个语义变体 + HyDE 假设答案)
      │
      ▼ ⑤ 并发向量检索(每路 Top-20,合并去重)
      │
      ▼ ⑥ Cross-Encoder Rerank 精排子块(Top-5)
      │
      ▼ ⑦ parent_id 反查父块(多子块命中加权)
      │
      ▼ ⑧ 父块文本组装 Prompt
      │
      ▼ ⑨ LLM 生成答案
py 复制代码
def industrial_rag_pipeline(
    query: str,
    chat_history: list,
    docstore: dict
) -> str:

    # ① 多轮压缩
    if chat_history:
        query = compress_query(chat_history, query)

    # ② 标准化改写
    query = standardizer.standardize(query)

    # ③ 多路扩写
    variants = generate_variants(query)   # 3 个语义变体

    # ④ HyDE(非实时敏感场景)
    hyde_chunks = hyde_retrieve(query)

    # ⑤ 并发向量检索 + 合并去重
    all_chunks = []
    for v in variants:
        all_chunks.extend(vector_recall(v, top_k=20))
    all_chunks.extend(hyde_chunks)
    all_chunks = deduplicate(all_chunks)

    # ⑥ Cross-Encoder 精排子块
    top_chunks = rerank_chunks(query, all_chunks, top_n=5)

    # ⑦ 反查父块
    parent_docs = fetch_parent_docs(top_chunks, docstore)

    # ⑧ 组装 Prompt + ⑨ 生成
    context = "\n\n---\n\n".join(parent_docs)
    return call_llm(query, context)

七、面试高频考点速记

Q1:父子分块的核心思想是什么?

子块做向量检索保精度,父块提供上下文保质量,parent_id 是连接两者的桥梁。精准检索与丰富上下文不再矛盾。

Q2:Reranking 为什么要在子块上做,而不是父块?

父块 token 多,Cross-Encoder 处理慢,且语义聚焦度差,评分不准。先在小子块上精排,找出最相关的内容信号,再用 parent_id 拿完整上下文,效率和精度都更高。

Q3:HyDE 和普通向量检索的本质区别?

普通检索是"问题向量 → 文档向量",存在语义 gap;HyDE 是"假设答案向量 → 文档向量",两者分布更接近。代价是增加 LLM 调用延迟,且若 LLM 对该领域了解有限,假设答案可能引偏检索。

Q4:多轮对话为什么需要 Query 压缩?不压缩直接检索会怎样?

多轮对话中的指代词("那个"、"法国呢")和省略依赖上文,单独拿去检索会匹配不到任何有效内容。压缩的本质是把隐式的对话上下文显式化,让每次检索都是完整语义的独立 Query。

Q5:扩写和改写的核心区别,什么时候用哪个?

扩写增加信息量,目标是提升召回率,宁可多召回也不漏;改写提升表达质量,目标是精准匹配,减少噪声。实际系统里先改写再扩写:改写先保证 Query 表达正确,扩写再在正确基础上扩大覆盖面。

Q6:子问题分解有什么风险?如何规避?

子问题之间若有依赖关系(B 的答案需要 A 的结果),简单并行检索会出错。规避方法:让 LLM 先判断子问题是否有依赖,独立子问题并行执行,依赖子问题串行执行,LangGraph 的 Plan-and-Execute 架构可以处理此类场景。

Q7:标准化改写的词典和 LLM 怎么分工?

词典处理高频、固定的术语映射,响应快且零成本;LLM 处理长尾口语化表达和跨语言场景。生产推荐混合方案:词典先跑,未命中或存在口语化信号时 LLM 兜底。

Q8:标准化改写会不会改变用户原意?如何规避?

是潜在风险。规避方案:改写后的 Query 只用于检索 ,最终送入 LLM 的 Prompt 中仍然包含用户原始问题,让 LLM 基于原始意图生成答案,避免改写引入偏差。


八、总结

技术 解决什么痛点 一句话本质
父子分块 块大小两难困境 小块检索保精度,大块喂料保质量
Cross-Encoder Rerank 向量检索精度不足 两阶段:宽召回 + 精排,10~25% 精度提升
HyDE 问题向量与文档向量语义 gap 用假设答案向量代替问题向量检索
多路召回 单一表达覆盖不足 多版本 Query 并发检索,RRF 融合
子问题分解 复杂多意图问题 原子化拆解,分别检索,综合回答
多轮 Query 压缩 多轮对话指代词 隐式上下文显式化,每次检索独立完整
标准化改写 用户术语与文档术语不匹配 词典+LLM 混合,映射到知识库标准表达

这套链路并不是所有模块都必须上齐,而是按需组合:

  • 快速迭代期: 父子分块 + Rerank,性价比最高
  • 质量提升期: 加入多轮压缩 + 标准化改写
  • 精益求精期: 引入 HyDE + 多路召回 + 子问题分解

每加一个模块,先建 offline eval set 衡量提升效果,别做没有数据支撑的工程优化。


我是折腾派程序员,如果这篇文章对你有帮助,点个赞是对我最大的支持!

后续会继续输出 RAG 评估体系(RAGAS 框架)、GraphRAG、以及 Agentic RAG 等内容,感兴趣的可以关注我。有问题欢迎评论区交流,折腾不止,进步不停 🚀

相关推荐
词元Max1 小时前
3.1 Agent开发需要懂多少数学?
人工智能·python
ZHW_AI课题组1 小时前
使用 Rectified Flow 和 Diffusion Transformer实现 MNIST 手写数字图像生成
人工智能·python·机器学习
悟空码字1 小时前
当 AI 遇到真正的编程痛点,Codex 攻克 5 类核心难题总结
aigc·openai·ai编程
z202305081 小时前
RDMA之DCQCN (14)
linux·服务器·网络·人工智能·ai
小小神仙1 小时前
ECC:怎么让 Claude Code 变成你的全栈搭档
程序员·aigc·ai编程
SimpleLearingAI1 小时前
PyTorch & Numpy 实现线性回归详解
人工智能·算法·多模态大模型
董董灿是个攻城狮1 小时前
AI 会吃了天涯吗?
人工智能
天风之翼1 小时前
AI 模型部署从入门到生产 —— ONNX 转换、TensorRT 加速、推理服务搭建
人工智能
A15362551 小时前
从 AI 零引用到高转化:GEO 落地价值解析
人工智能