RAG 应用最危险的阶段,不是检索为空、模型报错,而是检索到了、回答生成了、引用也附上了,但三者之间对不上。用户看到一段流畅的回答和三条来源链接,其中两条其实没有被回答使用,一条被断章取义;离线评测 Recall@5 很高,线上用户投诉“答非所问”;换了更好的重排序模型,指标涨了,但某类问题的准确率反而下降。这些问题不抛异常,只会让“看起来有据可查”的回答失去可信度。下面从三个位置给出可运行的代码与工程约束。
最简 RAG 流水线把文档按字符数切块、向量化、检索。问题在于固定长度分块对语义边界无感知:一个定义被切成两半、一个条件句的“除外情况”落到了下一块。检索时命中前半段,模型看到的上下文缺少限定条件,于是给出一个“语法完整但事实错误”的回答。这种错误不会被 Recall 指标捕获,因为被检索的块确实包含相关关键词。
工程上的缓解是把分块从“字符切分”改为按结构递归切分,并保留父块引用,让检索命中子块时能把父块作为上下文送入模型:
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=400, chunk_overlap=60,
separators=["\n## ", "\n### ", "\n\n", "\n", "。"],
)
chunks = []
for doc in docs:
for i, piece in enumerate(splitter.split_text(doc["text"])):
chunks.append({
"id": f"{doc['id']}#{i}",
"parent_id": doc["id"], # 保留父文档引用
"text": piece,
"metadata": doc.get("meta", {}),
})parent_id 是关键字段。检索时用子块匹配问题,送入 LLM 时用父块补充完整上下文。separators 里把 "\n## " 放在最前面,意味着优先在二级标题处切分,而不是在任意句号处切断。这不是一个“更好的默认值”,而是一个与文档结构绑定的显式声明——如果文档是法律条文,分隔符应该换成条款编号;如果是代码文档,应该换成函数边界。
加一层 cross-encoder 重排序是提升 RAG 精度的常见做法。但重排序器的训练目标通常是“给定 query,哪个 passage 更相关”,这个目标在两类场景下会系统性偏离业务需求。第一类是否定与条件:query 问“不包含某条款的合同有哪些”,重排序器会偏向与 query 词汇重叠高、但恰好包含该条款的段落。第二类是长尾意图:重排序器在训练分布内表现好,在分布外的 query 上会把格式相似但内容无关的段落排到前面。
一个可观测的做法是在重排序后记录分数分布,而不是只取 top-k。如果 top-1 与 top-5 的分数差异很小,说明重排序器对这批候选没有明确判断,此时应该回退到原始检索分数或扩大候选集,而不是盲目相信重排序结果:
def rerank_with_guard(query, candidates, reranker, min_gap=0.15, k=5):
pairs = [(query, c["text"]) for c in candidates]
scores = reranker.predict(pairs)
ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
top_score = ranked[0][1]
fifth_score = ranked[min(k - 1, len(ranked) - 1)][1]
gap = top_score - fifth_score
if gap < min_gap:
# 重排序置信度不足,回退原始检索顺序
return {"strategy": "fallback_retrieval",
"docs": [c for c, _ in ranked[:k]],
"gap": round(float(gap), 3)}
return {"strategy": "reranked",
"docs": [c for c, _ in ranked[:k]],
"gap": round(float(gap), 3)}min_gap 的工程含义是:重排序器只有在“明确区分出第一名”时才被信任。当它给出的分数挤在一起时,说明它在这批候选上缺乏判断力,此时回退到向量检索的余弦相似度排序,比接受一个勉强排序的结果更安全。这个阈值的具体数值需要用验证集校准,但“检查分数间距”这个动作本身就应该成为重排序层的固定逻辑。
让模型在回答里标注 [1][2] 并附上来源,是 RAG 提升可信度的常用手段。但模型生成的引用编号只是文本中的 token,它与实际传入的文档列表之间没有强制对应关系。模型可能引用 [3] 而上下文里只有两条来源,也可能把 [1] 标在一句实际来自 [2] 的话后面。用户点开 [1],看到的文档和回答对不上,信任瞬间崩塌。
正确的做法是在提示词中要求模型输出结构化的引用对象,并在后处理阶段逐条校验引用的来源 ID 是否存在于本次检索结果中,校验失败则降级为“无引用”回
import json, re
def validate_citations(answer: str, retrieved_ids: set) -> dict:
pattern = r"\[(\d+)\]"
cited = set(re.findall(pattern, answer))
invalid = {c for c in cited if c not in retrieved_ids}
if invalid:
cleaned = re.sub(pattern, "", answer)
return {"valid": False, "invalid_ids": sorted(invalid),
"answer": cleaned, "citations": []}
return {"valid": True, "invalid_ids": [],
"answer": answer, "citations": sorted(cited)}validate_citations 只解决了“引用编号是否存在于检索结果”这一层。更深一层是引用与句子的对应关系:一句被标为 [1] 的话,是否真的能在文档 1 中找到依据。这一层无法用正则解决,需要把回答按引用位置切分成若干 claim,逐条用 NLI 或 LLM 裁判判断“该 claim 是否被引用的段落支持”。工程上的折中是:先做 ID 存在性校验,对高频、高风险问题再做 claim 级校验。ID 校验的成本接近零,claim 校验的成本高但值得用在关键路径上。
这三个断层的共同逻辑是:RAG 的每一层都引入了一个“看起来对”的中间产物——分块看起来是完整句子、重排序看起来给出了排名、引用看起来标注了来源——而工程可靠性取决于是否为每个中间产物准备一个可以证伪它的检查点。分块要保留父块引用,重排序要检查分数间距,引用要校验 ID 存在性。这些检查都不改变 RAG 的基本架构,但它们决定了系统是在“能检索、能生成”的层面停留,还是在“回答可以被核验”的层面成立。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。