👋 开篇唠两句
我是折腾派程序员,一个专门把自己折腾明白再来讲给你听的后端老油条。
最近在准备面试,把 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-layerwise 或 bge-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 等内容,感兴趣的可以关注我。有问题欢迎评论区交流,折腾不止,进步不停 🚀