很多团队做 RAG(检索增强生成)的第一版都长一个样:把文档灌进向量库,用户提问就 top-3 检索,拼进 prompt 让模型回答。上线第一天效果惊艳,第二周开始有人反馈"问东答西""明明文档里有却说不知道"。这时候往往不是模型不行,而是检索环节的工程细节没打磨。本文按一条真实的优化链路,把切块、混合检索、重排序、引用约束和离线评估逐段拆开讲,代码可直接改。
一、切块:别用固定字符数硬切
多数人第一版是 text[i:i+500] 这种固定窗口。它的问题很直接:一段"步骤 3"的说明被切到两块里,两块单独看都不完整,检索命中哪一块都可能答错。
基线做法是固定窗口加重叠(overlap),减少跨块语义断裂;更稳妥的是结构感知切分——Markdown 按标题层级切,PDF 按段落和页码切,代码文档按函数/类切。切块粒度建议按内容类型调整:FAQ 类 200–300 字一块,技术文档 500–800 字一块,合同这类依赖前后文连贯的,单块可以放到 1000 字以上。
下面的切块器先按二、三级标题切,超长块再按句子切并保留重叠窗口:
import re
def split_markdown(text, max_chars=800, overlap=120):
"""结构感知切块:先按 Markdown 标题切,超长块按句切并保留上下文重叠。"""
blocks = re.split(r"\n(?=#{2,3} )", text) # 以二/三级标题为界
chunks = []
for block in blocks:
if len(block) <= max_chars:
chunks.append(block.strip())
continue
sentences = re.split(r"(?<=[。!?;])", block)
buf = ""
for sent in sentences:
if len(buf) + len(sent) > max_chars and buf:
chunks.append(buf.strip())
buf = buf[-overlap:] + sent # 用尾部 overlap 字做衔接
else:
buf += sent
if buf.strip():
chunks.append(buf.strip())
# 丢掉过短碎片(多为目录、页眉、孤立标题)
return [c for c in chunks if len(c) >= 30]
常见切块策略的取舍:
|
切块策略 |
适用场景 |
主要风险 |
|
固定窗口 + 重叠 |
无结构纯文本、快速验证 |
语义被截断,重叠比例难调 |
|
结构感知(标题/段落) |
技术文档、Markdown 知识库 |
依赖原始排版质量 |
|
语义切分(相似度断点) |
长篇散文、会议记录 |
需额外算力,偶有过度切碎 |
|
父子块(小块召回、大块喂模型) |
长文档问答、精度要求高 |
存储与组装逻辑更复杂 |
其中"父子块"值得单独提一句:用小块做召回,命中后把所属的大块(或整节)交给模型。这样召回更准(小块向量更聚焦),回答时上下文又更完整,是目前长文档问答里性价比很高的方案。
二、检索:纯向量会漏关键词,加一路 BM25 再融合
向量检索擅长语义近似,但对精确字符串很钝。用户问"428XL 报 E1042 怎么处理",向量可能召回一堆讲"设备报错处理流程"的段落,却没命中写着"E1042"的那一节。反过来,BM25 这类关键词检索对专有名词、型号、错误码、函数名非常敏感,却完全不懂同义改写。
所以别二选一。两路各召回一批,再用 RRF(Reciprocal Rank Fusion)融合。RRF 的好处是不依赖两路分数的量纲,省掉归一化,公式也简单——只用到排名:
def rrf_fuse(rankings, k=60):
"""RRF 融合多路召回结果。rankings 为多个有序的 id 列表。"""
scores = {}
for ranking in rankings:
for rank, doc_id in enumerate(ranking, start=1):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
return sorted(scores.items(), key=lambda x: -x[1])
# 向量召回 top30 + BM25 top30 → 融合后取前 30 条交给重排
dense_ids = [d["id"] for d in vector_search(query, top_k=30)]
sparse_ids = [d["id"] for d in bm25_search(query, top_k=30)]
fused_ids = [doc_id for doc_id, _ in rrf_fuse([dense_ids, sparse_ids])][:30]
k 取 60 是常见默认值,它决定"高排名"能带来多大优势:k 越小,头部名次的权重越突出。如果你的向量路明显比关键词路可靠,调小 k 会更激进地偏向前者。
三、重排序:召回 30 条,精排留 4 条
向量检索用的是双塔模型(bi-encoder),query 和文档各自编码后算相似度,快但精度有上限——两个向量在编码阶段从没"见过"对方。重排序用交叉编码器(cross-encoder),把 query 和候选文档拼在一起送进模型,能捕捉细粒度的词语对应关系,精度明显更高,代价是慢,所以只适合在小候选集上做。
def rerank(query, candidates, reranker, top_k=4, min_score=0.3):
"""candidates: [{'id': ..., 'text': ...}];reranker.predict 返回相关性分数。"""
pairs = [(query, c["text"]) for c in candidates]
scores = reranker.predict(pairs)
ranked = sorted(zip(candidates, scores), key=lambda x: -x[1])
# 分数过低的干脆不喂给模型,宁缺毋滥
return [c for c, s in ranked[:top_k] if s >= min_score]
经验值:混合检索召回 30–50 条,重排后留 3–6 条。留多了反而稀释模型注意力,还白抬 token 成本。min_score 别拍脑袋定,在离线评估集上扫一遍再选。
四、把"依据"和"不知道"写进 prompt
检索再准,prompt 不约束也白搭。三个约束最关键:只依据资料回答、必须标引用编号、没依据就直说。
PROMPT = """你是技术文档助手,只能依据下面的资料回答问题。
规则:
1. 每处结论后用 [编号] 标注来源,如 [2]。
2. 资料中没有的信息,回答"资料中未提及",禁止自行推测或补充。
3. 若多段资料互相冲突,指出冲突并分别标注来源。
资料:
{context}
问题:{question}
"""
context = "\n\n".join(
f"[{i}] {c['text']}" for i, c in enumerate(reranked, start=1)
)
另外建议把"引用编号 → 原文链接/片段"一并返回给前端,让用户能点开核对。这既是体验问题,也是排查幻觉最快的路径:用户投诉答错时,先看命中的是哪几块,再决定改检索还是改 prompt。
五、离线评估:先凑 30 条黄金问答集
没有评估集,所有优化都只是"感觉变好了"。最低成本的做法:从真实用户问题里挑 30 条,人工标好"应该命中哪几个块",然后分层看指标。
- 检索阶段:
recall@k(正确块是否进了 top-k)、MRR(正确块排在第几位);
- 生成阶段:答案是否与资料一致、是否出现无来源的断言。
分层的好处是能定位问题归属。如果 recall@5 只有 0.6,那是检索的问题,反复改 prompt 没用;如果 recall@5 有 0.95 而答案仍然错,才轮到 prompt 和模型背锅。
六、几个省成本的工程取舍
- embedding 结果按"文档 hash + 切块参数"做缓存,切块参数没变就不重算;
- 增量更新只重算变更文件,别每次全量重建索引;
- 高频问题加一层语义缓存,命中后直接返回,整条检索链路都可以跳过;
- 重排序模型按需选型,候选集小、延迟敏感的用轻量 cross-encoder 就够;
- 把每次请求的召回 / 重排 id 列表落日志,线上出问题能回放复现。
RAG 的效果上限由检索决定,模型只负责把已有资料讲清楚。与其反复换更大的模型,不如先把切块、混合检索、重排序这三步做扎实——通常这三步做完,用户的"答非所问"就能少掉一大半。
本文聚焦RAG系统中导致'答非所问'的核心工程问题,系统阐述切块(结构感知分块、父子块策略)、混合检索(向量+BM25+RRF融合)、重排序(bi-encoder召回与cross-encoder精排)、prompt引用约束及离线评估方法。强调检索质量决定RAG上限,提供可落地的代码级优化链路与成本控制实践。

317

被折叠的 条评论
为什么被折叠?



