上一版答得挺好。这次只把切分策略从 512 token 改成按语义段落,提示一个字没动,人工抽检的准确率掉了一半。日志里检索结果看起来更"整"了,答案却更散——这是典型的耦合故障:改的是检索,坏的是提示。
提示与检索不是两个模块,是一条链上的两个手段。链上任何一环的假设被打破,另一环的正确性同时失效。这篇不写怎么调,只写三处界面在哪、各自怎么坏、改的时候怎么联调。
1. 耦合的三个界面
| 界面 | 一边 | 另一边 | 耦合形式 |
|---|---|---|---|
| 切分粒度 ↔ 上下文预算 | 片段的长度分布 | 能塞进提示的片段条数 | 片段变长,条数必减,覆盖同一条也要减 |
| 排序 ↔ 引用顺序 | 检索的得分序 | 提示里 [1] [2] [3] 的位置 |
重排会打乱编号与结论的对应 |
| 系统提示 ↔ 拒答边界 | 指令里写的"不确定就说不知道" | 检索为空时的返回形态 | 检索层不区分空与不相关,指令就无从判断 |
三个界面的共同点:契约没有写在代码里,只写在提示的自然语言里。
一条提示里隐含的检索契约(通常没人写下来)
- 我期望拿到 4–6 条片段
- 我期望每条片段自带来源标识
- 我期望片段按相关性降序出现
- 我期望检索为空时返回空数组而不是最相似的一条
2. 提示对检索结果的三种依赖
| 依赖 | 提示里的表现 | 脆弱点 | 检索侧一改就坏的动作 |
|---|---|---|---|
| 存在性 | "根据以下资料回答" | 空结果被当成有资料 | 提高相似度阈值 |
| 顺序 | "优先采用第一条" | 排序变了,优先级也变了 | 换重排模型、改融合权重 |
| 格式 | 依赖 [1]、---、字段名 |
解析失败 → 引用错位 | 改切分器、改元数据字段 |
依赖越隐式,破坏越晚被发现:线上答案是错的,离线分却是对的,因为离线集里的检索结果恰好没变。
3. 把"没检索到"和"检索到但不相关"分开
这是提示层面必须显式处理的一件事,且必须在检索层就有区分信号。
检索返回的四种状态(提示需要分别应对)
A. 命中且相关 → 正常作答 + 引用
B. 命中但低分 → 作答但降级表述("按现有资料")
C. 空结果 → 直接说没有,不给推测
D. 召回失败 → 说没有,并提示可能的原因(索引、权限、范围)
多数系统的失败在于把 B 当成 A、把 C 当成 D。提示里写死"必须基于资料回答",等于把 C 伪装成 A——模型只能编。
| 状态 | 检索层要给的信号 | 提示层的动作 |
|---|---|---|
| A | 分数 + 来源 + 命中的字段 | 直接引用 |
| B | 分数低于阈值但非零 | 加限定语,减少断言 |
| C | 显式空标记(不是空数组) | 拒答 + 说明范围 |
| D | 错误码或过滤条件回显 | 报障,不回答 |
{
"hits": [],
"status": "empty",
"reason": "no_match_after_filter",
"filters": {"tenant": "t-42", "lang": "zh"},
"threshold": 0.62
}
给提示一个 status 字段,比让它从空数组里猜意图可靠得多。
4. 上下文预算怎么分
| 组成 | 典型占比(按模型窗口核对) | 谁该让步 | 让步的代价 |
|---|---|---|---|
| 系统指令 | 5–10% | 最后让 | 行为漂移、格式失守 |
| 对话历史 | 15–30% | 先让(压缩/截断) | 指代丢失 |
| 检索片段 | 40–60% | 中让(减条数) | 召回下降 |
| 输出预留 | 10–20% | 不动 | 截断答案 |
顺序不是拍出来的:指令 > 输出预留 > 检索片段 > 历史。历史最该让,因为它最容易压缩且损失可测;指令最不该让,因为它决定格式,格式一破整条链都读不出结果。
预算分配的判据(不是比例,是检查项)
1. 检索片段条数是否随窗口变化?固定条数在长片段上必然溢出
2. 压缩历史后,指代型问题("那它呢")的准确率掉多少?
3. 输出被截断的比例是否 > 1%?
5. 引用编号与来源溯源
编号是提示里最脆的格式:它把"第几条片段"和"第几条结论"绑在一起,任何重排都会错位。
| 方案 | 稳定性 | 成本 | 适用 |
|---|---|---|---|
位置编号 [1] [2] |
低(重排即错) | 低 | 单轮、无重排 |
内容哈希编号 [a3f1] |
高 | 需渲染映射 | 多轮、有重排 |
| 来源 ID(文档+段落) | 最高 | 需元数据治理 | 需审计的场景 |
# 引用编号用内容指纹,重排后仍然指向同一片段
import hashlib
def cite_id(chunk: dict) -> str:
raw = f"{chunk['doc_id']}:{chunk['start']}:{chunk['end']}"
return hashlib.sha1(raw.encode()).hexdigest()[:6]
代价是回答里出现 [a3f1] 这种不好读的编号。折中做法:编号用指纹,展示用序号,中间做一层映射,映射表随 answer 一起存。
6. "少给几条更好"的判据
噪声与召回有一个明确的交换点,但不在"相关性"上,在片段之间的冲突上。
| 片段数 | 召回 | 冲突概率 | 表现 |
|---|---|---|---|
| 1–2 | 低 | 极低 | 答不出,或答得过窄 |
| 3–5 | 中高 | 低 | 多数场景的最优区 |
| 8–12 | 高 | 明显 | 出现"资料说法不一"式回避 |
| 20+ | 高 | 高 | 答案被稀释,引用乱 |
判据:当 top-k 里出现互相矛盾的两条时,模型倾向于复述矛盾而不是裁决。此时减少条数比加提示词有效。
减少条数的两个安全做法
1. 按文档去重:同一文档只留最高分两段,避免单文档淹没
2. 冲突前置:把矛盾片段显式标注,让提示要求"指出分歧"
7. 模型升级的隐性破坏
模型换代时最容易被打破的不是知识,是容忍度。
| 变化 | 旧模型 | 新模型 | 后果 |
|---|---|---|---|
| 对多余字段的容忍 | 忽略 | 严格按 schema 报错 | 结构化输出全挂 |
| 对空结果的解释 | 主动说没有 | 倾向生成 | 幻觉上升 |
| 对长上下文的利用 | 中段遗忘 | 略好但位置偏好仍在 | 结论位置漂移 |
对 [1] 式标记 |
稳定沿用 | 偶尔改写编号格式 | 引用对不上 |
升级必须做的一件事:拿同一批检索结果跑新旧两版提示,比 format 合规率与引用一致性,而不是只比答案质量。
8. 联调成本:三者不能各自评测
| 变更 | 只测这项会漏掉 | 必须一起测 |
|---|---|---|
| 换切分 | 引用错位、条数溢出 | 提示的格式假设 |
| 换重排 | 优先级反转 | 提示里的顺序语义 |
| 改提示 | 召回变化被掩盖 | 检索的 top-k 分布 |
最小联调矩阵(每次变更跑一行)
变更项 × {格式合规率, 引用一致率, 空结果处理正确率, 答案正确率}
四项里任一项下降 > 2 个百分点 → 回退
9. 回归集的最小设计
不追求覆盖全部业务,只覆盖三个界面的交叉点。
| 用例类型 | 构造方式 | 覆盖哪个界面 | 条数(典型) |
|---|---|---|---|
| 空检索 | 问库外问题 | 系统提示 ↔ 拒答 | 10 |
| 单条命中 | 精确事实问 | 存在性依赖 | 15 |
| 多条冲突 | 找库内互相矛盾的文档 | 少给几条的判据 | 10 |
| 长片段 | 强制定长切分 | 预算界面 | 10 |
| 重排敏感 | 两条分数接近 | 顺序依赖 | 10 |
回归集的三条纪律
1. 每条用例记录:期望状态(A/B/C/D)+ 期望引用 ID
2. 检索结果快照随用例保存,避免检索漂移掩盖提示问题
3. 新增用例必须来自线上真实失败,不凭空构造
10. 灰度与回退:提示要指纹化
# 提示指纹:把模板、版本、检索参数一起算进去
import hashlib, json
def prompt_fp(template: str, ver: str, retrieval: dict) -> str:
blob = json.dumps({"t": template, "v": ver, "r": retrieval}, sort_keys=True)
return hashlib.sha256(blob.encode()).hexdigest()[:12]
| 灰度维度 | 切分方式 | 风险 |
|---|---|---|
| 按用户 | 稳定,便于回退 | 覆盖慢 |
| 按请求比例 | 快 | 同一用户答案不一致 |
| 按提示指纹 | 精确,可归因 | 需日志带指纹 |
回退的前提是能定位。日志里不带提示指纹与检索参数,回退就是一次赌博:你不知道回退的是提示、切分还是索引。
11. 排错
| 现象 | 大概率原因 | 查什么 |
|---|---|---|
| 换切分后答案变散 | 片段变长,条数溢出被截断 | 实际进提示的片段条数与截断位置 |
| 引用编号对不上原文 | 重排发生在编号生成之后 | 编号生成点与重排点的先后 |
| 库外问题也答得很像 | 空结果被当成低分命中 | 检索层 status 是否为 empty |
| 升级模型后格式全错 | 对多余字段/标记的容忍度变了 | 格式合规率对比 |
| 离线涨、线上跌 | 回归集的检索快照被一起改了 | 快照与用例是否同步变更 |
12. 边界
| 场景 | 耦合问题的存在性 | 理由 |
|---|---|---|
| 一次性问答、无检索 | 不成立 | 没有第二个界面 |
| 检索结果极稳定(如固定 FAQ 表) | 很低 | 顺序与格式几乎不变 |
| 模型不接受外部输入(纯生成) | 不成立 | 无检索界面 |
| 单轮、无引用需求 | 中低 | 只剩预算界面 |
| 多轮、多源、需审计 | 高 | 三个界面全活跃 |
耦合管理是有成本的:它要求提示、切分、索引同版本发布。只有当三者的变更频率都高到会互相破坏时,这套纪律才划算;三者都冻结的系统,做它只是加流程。
13. 自测
- 一个提示里隐含的检索契约,你能写出几条?
- 检索返回空数组与返回
status: empty,对提示的差别在哪? - 上下文预算里最该先让步的是哪一部分,为什么不是指令?
- 用位置编号做引用,在什么操作之后必然错位?
- 模型升级后,为什么"答案质量没降"不足以证明可以上线?
- 你的日志里能否定位到某条回答用的是哪版提示与哪套检索参数?