第 6 课:RAG 生成侧 —— 拼 Prompt + 引用绑定 + Verifier
本节目标:掌握检索后如何拼装 context、如何让 LLM 标注引用、如何用 Verifier 验证答案没有幻觉,以及修正循环的完整链路。
学完本课后,推荐阅读 附06:Prompt 工程深度 —— 七大核心技巧、多 Prompt 拆分设计、防注入、黄金模板。
第 4-5 课解决了"怎么找到相关 chunks"。这一课解决找到之后怎么办。
1. 全局视角:RAG 的前半场和后半场
┌── 前半场(检索侧,第4-5课)──────┐
│ query rewrite → embed → hybrid │
│ → RRF → rerank → top-K chunks │
└──────────────┬───────────────────┘
↓ chunks
┌── 后半场(生成侧,本课)──────────┐
│ 1. 过滤低分 chunk │
│ 2. 拼 context(带 [1][2] 编号) │
│ 3. 渲染 prompt 模板 │
│ 4. LLM 生成(带 [N] 引用) │
│ 5. 解析引用 → Citation 对象 │
│ 6. Verifier 验证 + 修正循环 │
└──────────────────────────────────┘
2. 过滤 + 构建 Context
过滤低分 chunk
filtered = [h for h in hits if h["score"] >= min_score] # 默认 0.3
if not hits:
return _fallback("no_hit") # 向量库零命中 → "无法回答"
if not filtered:
return _fallback("low_score") # 全低于阈值 → "无法回答"
两种兜底分开的原因:运营需要区分——no_hit 要补文档,low_score 可能是 query 表述或 chunk 切分问题。
构建 Context 文本
def _build_context(chunks, *, limit=4000):
for c in chunks:
block = f"[{c.index}] (source: {c.source}, title: {c.doc_title})\n{c.text}\n"
if total + len(block) > limit:
break # 超限截断,不是跳过整块
parts.append(block)
最终送进 LLM 的 context 长这样:
[1] (source: hr_handbook.md, title: 请假制度)
员工请假须提前3个工作日在OA系统提交申请,由直属主管审批...
[2] (source: policy_2024.md, title: 年假规定)
正式员工入职满一年享有5天带薪年假...
三个设计要点:①
[N]编号是 LLM 和解析层的共同语言;② 4000 字符截断(超出停止,字符数估算成本为零);③ context 带元数据让 LLM 能利用来源信息。
3. Prompt 模板:驯服 LLM 的 5 条规则
name: rag_qa
---
[SYSTEM]
你是一个严谨的知识库问答助手:
- 如果 Context 里有答案:回答并在相关语句末尾用 [N] 标注引用
- 如果 Context 信息不足:直接说"根据现有资料无法回答",**绝对不要编造**
- 一句话可以引用多个编号:[1][2]
- 不要在答案开头或结尾加"根据资料..."这种套话,直接给结论
- 回答用简洁中文,不超过 300 字
Context: ${context}
[USER] ${question}
| 规则 | 为什么需要 |
|---|---|
| 严格基于 Context | 防幻觉核心——不让 LLM 用自己的"知识" |
| 用 [N] 标引用 | 让引用绑定可自动化解析 |
| 不足就说不知道 | 不答优于乱答 |
| 不要套话 | 省字数,不提供信息 |
| 不超 300 字 | 控 token + 强迫精炼 |
最重要的一条:"绝对不要编造"。但这不是万能的——LLM 可能在"总结 context"时偷偷加入 context 里没有的信息。这就是为什么后面需要 Verifier。
4. 调 LLM + 解析引用
temperature=0.1(不是 0):0 会导致重复退化,0.1 极低随机性但偶尔可跳出重复。RAG 最佳实践区间 0.0-0.2。
解析引用:
def _parse_citations(answer, chunks):
cited_indices = sorted({int(m) for m in _CITATION_RE.findall(answer)})
for idx in cited_indices:
c = chunk_by_idx.get(idx)
if c is None: # 越界编号 → 跳过
continue
citations.append(Citation(index=c.index, source=c.source,
doc_title=c.doc_title, snippet=c.text[:120]))
return citations
LLM 输出: "员工请假须提前3天申请[1],年假5天[3]。"
↓ 正则 \[(\d+)\]
提取: {1, 3} → 逐个绑定 chunk → Citation 列表
三个防御:越界编号过滤(LLM 标了 [7] 但你只给了 5 条→跳过)、去重(多次 [1] 只生成一个 Citation)、snippet 截断(前 120 字供前端预览)。
finish_reason:answered(正常)、no_hit(零命中)、low_score(全低于阈值)、no_context_used(最危险——LLM 回答了但一个 [N] 都没标,可能是幻觉)。
5. Verifier:用第二个"法官 LLM"验证答案
核心概念:Claim + Evidence + Verification
class Claim(BaseModel):
text: str # 一句可独立验证的话
evidence_ids: list[str] # 绑定的证据 ID
verification: Verification # supported / partial / unsupported
class Evidence(BaseModel):
text: str
source: Literal["vector", "keyword", "hybrid"]
例子:
答案: "员工请假须提前3天申请,年假最多5天。"
c1: "请假须提前3天" → evidence [1] → supported c2: "年假最多5天" → evidence [] → unsupported ↑ 知识库里写的是"至少5天",LLM 编的"最多"
三种验证策略
| 策略 | 特点 | 成本 |
|---|---|---|
| RuleVerifier | 规则快筛(同步) | 0 LLM |
| LLMJudgeVerifier | LLM 裁判(能看语义) | 1 LLM 调用 |
| HybridVerifier | 先规则,不确定上 LLM | 30-40% 走 LLM |
RuleVerifier 决策树
claims 为空? → INSUFFICIENT
有冲突? → CONFLICT
全部 unsupported? → INSUFFICIENT
unsupported 或 partial 太多? → REVISE
默认 → PASS
打分:(supported×1 + partial×0.5 + unsupported×0) / total
LLMJudgeVerifier
把 question、answer、claims、evidences 全部打包 → LLM 做 JSON 结构化输出(temperature=0.0,验证必须确定性):
{
"verdict": "revise",
"score": 0.6,
"issues": ["未回答副作用部分", "claim 2 证据相似度偏低"],
"suggestions": ["补充副作用段落", "查找更直接的 evidence"]
}
suggestions是关键——给下一轮生成看的修正方向。
HybridVerifier(主线)
rule_res = self.rule.verify(...)
# 高置信负面 → 直接返回(省 LLM)
if rule_res.verdict in (CONFLICT, INSUFFICIENT):
return rule_res
# 规则判 PASS → 直接返回(省 LLM)
if not self.use_llm_always and rule_res.verdict == PASS:
return rule_res
# REVISE 才调 LLM 细化
llm_res = await self.llm.verify(...)
return self._merge(rule_res, llm_res) # 以 LLM 为主,CONFLICT 例外
成本:60-70% 被规则直接判定,只有 30-40% 需要 LLM。成本降一半以上。
6. Verdict 四种判定 + 修正循环
| Verdict | 系统行为 |
|---|---|
| PASS | 合格,直接返回 |
| REVISE | 有小问题 → 带 suggestions 重新生成(最多 2 次) |
| INSUFFICIENT | 证据不足 → 告诉用户"无法回答" |
| CONFLICT | 证据矛盾 → 需人工介入 |
修正循环:
round 1: generate → verify → REVISE
issues: "未回答副作用部分"
suggestions: "补充副作用段落"
↓
round 2: generate(with hints) → verify → PASS ↓
返回最终答案
max_revisions=2:超过 2 轮还 REVISE → 问题本身证据有问题,强制返回。Verifier 失败兜底为 PASS(不让验证器自己把流程搞崩)。
7. 工程教训
- 引用绑定是 RAG 可信度的基石——没有引用 = 和 ChatGPT 没区别。用户能点击 [1] 查证
- Verifier 失败兜底为 PASS——增强功能不阻塞主流程,和第5课 HyDE 兜底一脉相承
- 规则 + LLM 混合是成本最优解——70% 规则判定免费,30% LLM 精判
- no_context_used 是最重要的监控指标——突然升高说明 prompt 被改坏或 context 质量下降,必须告警
- 修正循环不超过 3 次——再多说明证据本身有问题,改也改不好
8. 自测
问题 1:chunk 本身 5000 字符超了 MAX_CONTEXT_CHARS=4000,会发生什么?暴露了 ingestion 的什么问题?
问题 2:LLM 输出 [1,2](逗号分隔)而不是 [1][2],当前正则 \[(\d+)\] 能匹配吗?怎么改?
问题 3:HybridVerifier 规则判 PASS 太松(1 个 supported 就 PASS)会导致什么后果?怎么收紧?
问题 4:为什么 Verifier 用 temperature=0.0 而不是生成用的 0.1?
问题 5(最重要):LLM 从 context [1] 和 [2] 自行总结"A 比 B 便宜 30%",引用都标了,Verifier 也判 PASS。但 context 里根本没有价格对比原文——为什么 Verifier 没拦住?怎么改进?
答案与解析
问题 1:超限 chunk
第一个 chunk 5000 字符 → total(0) + len(block)(5000+) > 4000 → break → parts 为空 → context 空 → LLM 要么"无法回答"要么幻觉。
根本原因:ingestion 阶段 chunk_size 设太大或根本没切。两层防御:ingestion 切 chunk(500-1000 字符)+ _build_context 截断单个 chunk 而非跳过。
上游不做好切分,下游怎么补都是打补丁。
问题 2:逗号分隔引用
\[(\d+)\] 匹配 [1] 但不匹配 [1,2]。兼容改法:先宽松匹配 [...] 块,再从里面提取所有数字。
更深层:正则解析 LLM 输出永远脆弱。生产用 structured output(JSON mode)让 LLM 直接输出 citations 数组,不从自然语言里"猜"。
问题 3:规则太松
1 个 supported + 4 个 unsupported → 判 PASS → 4 个幻觉放行 → 用户看到"验证通过"但答案 80% 是编的。
收紧方案:score 阈值(≥0.7)+ unsupported 零容忍 + 上线后监控 PASS/REVISE 比例调参。
问题 4:temperature=0.0
验证是判断题不是创作题。0.0 = 每次相同判断可复现。0.3 → 同一份答案第一次 PASS 第二次 REVISE → 用户同样的问题两次结果不同 → 信任崩塌。
| 场景 | temperature | 原因 |
|---|---|---|
| RAG 生成 | 0.0-0.2 | 基于证据,低随机 |
| Verifier | 0.0 | 判断必须确定性 |
| HyDE | 0.3 | 需要创造力编假设答案 |
问题 5:引用正确但内容是推理幻觉
为什么没拦住:Verifier 验证的是"evidence 是否提及了 claim 的内容",不是"evidence 是否蕴含了 claim 的结论"。"便宜30%"是一个推理结论,需要从两个证据中计算推导——LLM 自行做了这个推导,Verifier 看不出来。
4 层递进防御:
| 层 | 方案 | 成本 |
|---|---|---|
| 1 | Prompt 约束:"不要做数学计算或推理,只转述原文结论" | 免费 |
| 2 | Claim 级验证加强:标记 "inferred" 类 claim → 降级为 partial | 中等 |
| 3 | NLI 模型(如 deberta-v3):判 entailment/contradiction/neutral | 推理 50ms |
| 4 | Fact-checking pipeline:NER 提取数字 + 验证数学关系 | 最重 |
引用 ≠ 正确。LLM 可以在"看起来有引用"的外壳下做任意推理。防御按业务风险等级逐层叠加。
本节要点
- Context 拼装核心是 [N] 编号——LLM 和解析层的共同语言,串联生成、引用、验证三个环节
- Verifier 精髓是"规则快筛 + LLM 精判"混合——70% 免费判定,30% 花钱精判
- 4 种 verdict 覆盖所有答案质量状态:PASS / REVISE / INSUFFICIENT / CONFLICT
- 修正循环最多 2-3 次——超出说明证据本身有问题
- 引用 ≠ 正确——LLM 可标着 [1][2] 但答案是自行推理的。防御需从 prompt 约束到 NLI 到事实核查多层递进