RAG 系统答错时,多数人反复调 prompt 却是白费力——根源在检索层没召回正确 chunk,再好的生成器也救不回来。本文给出可落地评测方案:只需 20 条真实 query + 人工标相关文档,两小时就能拆解出真正的瓶颈在检索还是生成。
这是一个我反复看到、自己也折腾了整整一个月的调试循环。
答案错了。调调提示词。好了一点。换换模型。还是错的。加上"只使用提供的上下文"加粗提示。还是错。于是得出结论:模型不够好。
这些改动每一个都是真正问题的下游。
如果正确的 chunk 从来没有被送进上下文窗口,再怎么改提示词都无济于事。你在调优的是一个生成器,而检索器从未被评估过。
两个组件,两种失败模式,但几乎所有人都在调试错误的那一个——因为生成器是你能看见的部分。
在调试之前先把系统拆开:查询 → [检索器] → chunks → [生成器] → 答案
问自己一个问题,整个系统就分解了:
答案在检索到的 chunks 里吗?
没有 → 检索失败。生成器根本没机会答对,改提示词毫无意义。有,但答案还是错的 → 生成失败。这时候提示词才有用。
不加上标签,这个问题你回答不了。而获取标签的工作量比你想象的要少。
二十个查询,一个下午
你不需要基准测试。你需要二十个有代表性的查询,加上手工标注的相关文档。两个小时,一次搞定。
EVAL = [
{
"q": "what's our refund window for annual plans",
"relevant": {"billing/refunds.md", "policies/annual-terms.md"},
},
{
"q": "how do we onboard a new enterprise client",
"relevant": {"process/enterprise-onboarding.md"},
},
# ... 18 more, drawn from real queries people actually asked
]
从日志里提取这些,而不是靠想象。真实查询和你编出来的完全不同——更短、更模糊、充满内部简称。
def recall_at_k(retrieved, relevant, k):
got = set(retrieved[:k])
return len(got & relevant) / len(relevant) if relevant else 1.0
def precision_at_k(retrieved, relevant, k):
got = retrieved[:k]
return sum(1 for d in got if d in relevant) / k
def mrr(retrieved, relevant):
for i, d in enumerate(retrieved, 1):
if d in relevant:
return 1 / i
return 0.0
def evaluate(retriever, eval_set, k=8):
rs, ps, ms = [], [], []
for case in eval_set:
got = retriever(case["q"], k=k)
rs.append(recall_at_k(got, case["relevant"], k))
ps.append(precision_at_k(got, case["relevant"], k))
ms.append(mrr(got, case["relevant"]))
n = len(eval_set)
return {"recall@k": sum(rs)/n, "precision@k": sum(ps)/n, "mrr": sum(ms)/n}
怎么看这三个数字——这才是真正告诉你该修哪里的部分:
| 表现 | 诊断 | 修复方案 |
|---|---|---|
| recall 低 | 正确的 chunk 根本没被找到 | 分块策略,或混合搜索——见下方 |
| recall 高,precision 低 | 找到了,但埋在一堆噪声里 | 重排,或缩减语料库 |
| precision 尚可,MRR 低 | 正确的 chunk 在,但排名太靠后 | 重排 |
| 三个指标都还行,答案还是烂 | 真正的生成问题 | 现在才去调提示词 |
最后一行是整个流程的关键。大多数人从那里开始,然后永远困在那里。
precision 差的时候,直觉是换一个更好的 embedding 模型。先试试这个,因为它是免费的,而且通常效果更明显:
模型要读 k 个 chunks——从你成千上万的文档里挑出 8 个、10 个、20 个。每一个不相关的文档都会占用固定数量的槽位。在相关材料没有等比增加的情况下,把语料库翻倍只会严格降低 precision。
我把文档从 15 篇增加到 30 篇,precision 从 4 变成了……(原文数字缺失,但逻辑是:)这比任何检索改动都有效,而且花的是一个下午阅读,而不是一周的工程投入。
固定大小分块会切在半句、半张表格、半条思路里。生成的 embedding 因此代表一个孤立出来毫无意义的碎片。
def chunk_by_heading(md: str, max_chars=1200):
"""Split on markdown headings; only sub-split if a section is huge."""
sections, cur = [], []
for line in md.splitlines():
if line.startswith("#") and cur:
sections.append("\n".join(cur)); cur = [line]
else:
cur.append(line)
if cur: sections.append("\n".join(cur))
out = []
for s in sections:
if len(s) <= max_chars:
out.append(s)
else:
paras, buf = s.split("\n\n"), ""
for p in paras:
if len(buf) + len(p) > max_chars and buf:
out.append(buf); buf = p
else:
buf = f"{buf}\n\n{p}" if buf else p
if buf: out.append(buf)
return out
在 embedding 之前,给每个 chunk 加上文档标题和标题路径前缀。"窗口是 30 天"这个片段孤立出来毫无用处;但如果是"Billing > Refunds > Annual plans — 窗口是 30 天"就可以被检索到了。这一项改动通常是单项 recall 提升最大的,而且零成本。
Dense embedding 在精确 token 上表现很差——错误码、SKU、姓氏、内部项目名都是。Keyword 搜索在 paraphrase 面前又很弱。两者都需要。
def hybrid(query, k=8, alpha=0.6):
dense = {d: s for d, s in vector_search(query, k=k*3)}
sparse = {d: s for d, s in bm25_search(query, k=k*3)}
def norm(scores):
if not scores: return {}
lo, hi = min(scores.values()), max(scores.values())
rng = (hi - lo) or 1
return {d: (s - lo) / rng for d, s in scores.items()}
dn, sn = norm(dense), norm(sparse)
merged = {
d: alpha * dn.get(d, 0) + (1 - alpha) * sn.get(d, 0)
for d in set(dn) | set(sn)
}
return sorted(merged, key=merged.get, reverse=True)[:k]
用 eval 集来调 alpha,而不是凭感觉。在充满内部术语的语料库上,我通常会调到比预期更低的位置——keyword 那一半的贡献比你以为的要大。
Cross-encoder 把查询和文档一起读取,而不是比较预计算的向量,所以准得多、慢得多——这正是它适合用在小候选集上的原因。
重排修复的是排序,不是召回。如果正确的 chunk 不在你的 30 条里,重排也救不了你。先检查 recall@30 再动手,否则你会在错误的组件上浪费一周。
这是最容易被跳过、也是最容易栽跟头的一条。
陈旧性不是相关性属性。一份过时的定价文档在语义上比大多数当前文档更接近"我们的定价是多少",因为它恰恰就是讲这个的。你的检索器会兴高采烈地把它推上来,而且推得很"准确"。
def retrieve(query, k=8):
candidates = hybrid(query, k=k*4)
fresh = [d for d in candidates if store[d].meta.get("status") == "current"]
return rerank(query, fresh)[:k]
没有任何 embedding 模型能检测出文档已过期。这必须来自元数据,而元数据必须有人来维护文档——这是这件事的治理层面,说实话比技术部分更难。
价值不在于一个分数,而在于这次改动有没有让情况变糟。
BASELINE = {"recall@k": 0.71, "precision@k": 0.44, "mrr": 0.62}
def check(new):
regressions = {
m: (BASELINE[m], new[m])
for m in BASELINE
if new[m] < BASELINE[m] - 0.03 # tolerance for noise
}
if regressions:
raise SystemExit(f"retrieval regression: {regressions}")
print("ok", new)
每次分块策略改动、每次 embedding 切换、每次语料库增加都要跑。特别是语料库增加——这是所有人都以为只会单向上涨的改动,却是最可能悄悄蚕食 precision 的那个。
一个下午的标注工作,比一个月的提示词调优告诉你更多。而且它告诉你的这件事是提示词调优永远无法知道的:答案是否曾经在这个房间里。
[YOUR NAME] — I test AI stacks honestly, including the parts that don't work: AiStackGuru.