背景:demo 跑通≠能上线
4 月初,我接手一个 SaaS 后台的"客服知识库 RAG 问答"模块。前任已经搭好了管线——LangChain + OpenAI text-embedding-3-small + Pinecone + GPT-4o-mini,demo 演示流畅,产品经理拍板"两周后上线"。
真接到生产环境的客服对话流量后,事情立刻变味:座席手动评估的"答得对"比例只有 38%,剩下要么答非所问、要么"在知识库中没有找到相关内容"。座席的反馈非常直接:"还不如让我自己 Ctrl+F 搜文档。"
这篇文章把后续 6 周的真实路径写下来。不是教程也不是软文,核心是把走过的弯路、被否决的方案和最后真正起作用的几板斧讲清楚——尤其是那个被业界吹得最凶的 chunk size,实测里几乎不是首要变量。
第 1 周:把"瞎猜"换成"可量化"
前任团队评估方式是"产品经理看一眼觉得对",我接手后第一件事是停下所有调优,先建评估集。
1.1 评估集构造
| 来源 | 条目数 | 覆盖意图 | 标注方式 |
|---|---|---|---|
| 真实座席工单(过去 60 天) | 312 | 退款 / 物流 / 账号 / 优惠 / 发票 | 2 人独立标注 + 1 人仲裁 |
| 客服 SOP 手册原文 QA | 148 | 政策解读类 | 1 人初标 + 抽检 20% |
| 对抗样本(故意改写、同义替换、错别字) | 96 | 鲁棒性 | 规则生成 + 人工校验 |
| "知识库不应该回答"的边界样本 | 54 | 拒答能力 | 人工设计 |
| 合计 | 610 | — | — |
每条样本含:question / expected_doc_ids(命中哪几篇文档算召回成功)/ expected_answer_points(答案要点 list,只要命中其中 ≥1 就算答对)/ category(意图)/ difficulty(简单 / 中等 / 困难)。
1.2 三个关键指标
| 指标 | 定义 | 为什么重要 |
|---|---|---|
| Recall@5 | top5 检索结果里至少命中一个 expected_doc_ids | RAG 上限:检不到 LLM 也无能为力 |
| Answer Hit | 生成答案命中 expected_answer_points ≥1 | 真正影响用户体验的口径 |
| Refusal Acc | 拒答样本上 LLM 拒答比例 | 避免"编"答案,客服场景下编出来要负法律责任 |
初始基线跑出来:Recall@5 = 51%,Answer Hit = 38%,Refusal Acc = 12%。也就是说:一半样本检索阶段就丢了,剩下能检到的里大约 3/4 答对——而该拒答的样本几乎全在硬答。
1.3 评估代码长这样
import json, asyncio
from collections import defaultdict
from pathlib import Path
async def evaluate(samples: list[dict], pipeline) -> dict:
bucket = defaultdict(lambda: {"recall": 0, "answer": 0, "refusal": 0, "total": 0})
async def run_one(s):
retrieved = await pipeline.retrieve(s["question"], k=5)
doc_ids = {d["doc_id"] for d in retrieved}
recall_hit = bool(doc_ids & set(s["expected_doc_ids"]))
ans = await pipeline.generate(s["question"], retrieved)
if s.get("should_refuse"):
refusal_hit = pipeline.is_refusal(ans)
return s, recall_hit, False, refusal_hit
answer_hit = any(pt in ans for pt in s["expected_answer_points"])
return s, recall_hit, answer_hit, None
results = await asyncio.gather(*(run_one(s) for s in samples))
for s, r, a, rf in results:
cat = s["category"]
bucket[cat]["total"] += 1
if r: bucket[cat]["recall"] += 1
if a: bucket[cat]["answer"] += 1
if rf: bucket[cat]["refusal"] += 1
return {
"overall": {
"recall@5": sum(v["recall"] for v in bucket.values()) / sum(v["total"] for v in bucket.values()),
"answer_hit": sum(v["answer"] for v in bucket.values()) / sum(v["total"] for v in bucket.values()),
},
"by_category": dict(bucket),
}
关键设计:按 category 分桶看——总分掩盖结构性问题,比如"退款"类样本召回 79% 而"发票"类只有 23%,平均下来才 51%。后面好几次的优化方向都是这种分桶看出来的。
第 2 周:被否决的方案比被采纳的更值钱
建好评估集之后,我列了一个"待验证清单",一周内挨个跑实验。这里把否决的方案先写下来——因为这些方案在公开资料里几乎都被吹成"必上"。
2.1 chunk size 调整(微提升,后撤)
业界最常见的建议是"把 chunk size 从 512 调到 256/128"。我做了 6 组对照:
| chunk size | overlap | Recall@5 | Answer Hit | 检索延迟 P50 |
|---|---|---|---|---|
| 128 | 32 | 53.1% | 40.2% | 118ms |
| 256 | 64 | 54.3% | 41.7% | 112ms |
| 512(基线) | 128 | 51.0% | 38.1% | 104ms |
| 768 | 128 | 49.8% | 37.5% | 101ms |
| 1024 | 128 | 47.6% | 35.9% | 98ms |
| 语义分块(下面 2.4 节) | — | 56.8% | 43.2% | 122ms |
结论:chunk size 在我的语料里是个温度计——动一下能看出趋势,但绝对值的提升非常有限。256+overlap 比 512 多出来 3.3 个点的 recall,完全不足以救一个 51% 的系统。把它列为"已知次要变量",放回 256/64,然后再不去碰。
2.2 加 BM25 做 hybrid retrieval(否决,因为我们语料的命名空间问题)
第二个流行方案:稠密向量 + BM25 稀疏检索,RRF 融合。我用 OpenSearch 的 hybrid query 试了一周。
结果令人不安:Recall@5 从 51% 升到 58%,但 Answer Hit 反而降到了 35%。分桶看:"账号"类(里面有 SKU 编号、订单号这类强 keyword 信号的)recall 大涨,而"政策解读"类被 BM25 拖下去了——BM25 把含某个高频词但语义无关的文档拉进了 top5,LLM 被噪音误导。
当时我有两条路:
- 继续调 RRF 权重(α=0.3 / 0.5 / 0.7 / 0.85)
- 把 BM25 留作"显式编号查询"的副通道,默认管线只走稠密
试了 5 组 α 之后我选了 2。原因后面 4.2 节细说。
2.3 直接换 embedding 模型(否决,成本不匹配)
把 text-embedding-3-small 升到 text-embedding-3-large,Recall@5 上升 4.1 个点。但向量维度从 1536 涨到 3072,Pinecone 存储成本和检索 QPS 都翻倍——我们这个体量(知识库 12 万 chunk,日均 8 万次检索)算下来 一年要多花约 ¥4.7 万,产品经理直接 PASS。后续的 3.1 节用一个更便宜的方法把这 4 个点拿回来了。
2.4 语义分块(采纳,但只在长文档上)
用 LLM 做"按主题切分"的语义分块,在 SOP 手册这种长文档上 recall 涨 5.8 个点;在短 FAQ 上几乎无变化甚至略降。最终方案:
def smart_chunk(doc: Document) -> list[Chunk]:
if len(doc.text) < 1500:
return [Chunk(doc.text, doc_id=doc.id)]
if doc.type in ("faq", "qa_pair"):
return rule_based_split(doc, max_tokens=256, overlap=64)
return semantic_split(doc, target_tokens=300, llm="gpt-4o-mini")
规则:短文档不切,FAQ 用规则切,长 SOP 走语义切。不是"一刀切上语义分块",而是按文档类型路由——这一点后面被反复验证。
第 3 周:真正起作用的三板斧
否决了几个高调方案之后,真正把 Recall@5 顶到 89%、Answer Hit 顶到 81% 的是另外三件事。它们都不性感、不上 paper,但实测有效。
3.1 Query 改写 + Multi-Query(收益最大)
分析了 312 条工单 query 之后,我发现一个细节:座席的 query 几乎都是口语化短句,而知识库文档是正式书面语。比如座席问"会员升级了能用之前买的优惠券吗",知识库里对应文档写的是"会员等级变更后,持有未使用优惠券的有效性策略"。词面几乎不重合,稠密向量也只是部分语义相似。
方案:在检索前用一个轻量 LLM 调用,把口语 query 改写成 3 个候选,分别检索,结果合并去重。
REWRITE_PROMPT = """你是知识库检索助手。
将用户的口语化问题改写成 3 个候选检索 query,要求:
1. 至少 1 个是正式书面化表达;
2. 至少 1 个保留用户原始口语;
3. 至少 1 个把可能的隐含意图显式化(如果有的话);
4. 不要回答问题,只输出 JSON 列表。
用户问题: {q}
输出 JSON:"""
async def multi_query_retrieve(q: str, k: int = 5, k_each: int = 4) -> list[dict]:
rewrites = await llm_json(REWRITE_PROMPT.format(q=q), model="gpt-4o-mini")
queries = [q] + rewrites
embeds = await embed_batch(queries)
candidates: dict[str, dict] = {}
for emb in embeds:
hits = await vstore.search(emb, k=k_each)
for h in hits:
existing = candidates.get(h["doc_id"])
if not existing or h["score"] > existing["score"]:
candidates[h["doc_id"]] = h
ranked = sorted(candidates.values(), key=lambda x: -x["score"])
return ranked[:k]
这一个改动单独贡献了:Recall@5 +18.2 个点(56.8% → 75.0%),Answer Hit +14.7 个点。代价是每条 query 多一次 gpt-4o-mini 调用(平均 +180ms 延迟、+¥0.0008 成本)。在我们的语料上,这比换 embedding 模型便宜 50 倍,效果还更好。
3.2 Cross-Encoder Reranker(收益第二)
稠密向量召回是粗排,top-k 里相关性顺序经常乱。加一个 bge-reranker-v2-m3 做精排,在 top-20 上重打分,取 top-5。
from FlagEmbedding import FlagReranker
reranker = FlagReranker("BAAI/bge-reranker-v2-m3", use_fp16=True)
def rerank(query: str, candidates: list[dict], top_k: int = 5) -> list[dict]:
if not candidates:
return []
pairs = [(query, c["text"]) for c in candidates]
scores = reranker.compute_score(pairs, normalize=True)
for c, s in zip(candidates, scores):
c["rerank_score"] = float(s)
return sorted(candidates, key=lambda x: -x["rerank_score"])[:top_k]
async def retrieve_with_rerank(q: str, k_coarse: int = 20, k_final: int = 5):
coarse = await multi_query_retrieve(q, k=k_coarse, k_each=10)
return rerank(q, coarse, top_k=k_final)
实测:Recall@5 +8.5 个点(75.0% → 83.5%),Answer Hit +12 个点。延迟代价 +95ms(本地 GPU 部署)。
这里有一个反直觉的地方:reranker 提升的不是能否检到,而是检到的相关结果有没有进 top5。粗排里第 12 名的相关文档,经过 rerank 排到了第 2 名,给 LLM 的上下文质量大幅改善——Answer Hit 涨幅比 Recall 还大,就是这个原因。
3.3 Metadata 过滤 + 意图路由(收益第三)
不是所有 query 都该走全库检索。看了一下 query 分布:
| 意图分类 | 样本占比 | 合适的检索范围 |
|---|---|---|
| 退款 / 售后 | 32% | 仅"退款政策"+"售后流程"两个子库 |
| 物流查询 | 21% | 仅"物流 SOP"子库 |
| 账号问题 | 17% | 仅"账号 / 安全"子库 |
| 优惠 / 活动 | 13% | "优惠规则"+"活动公告"两个子库 |
| 发票相关 | 8% | 仅"发票 SOP"子库 |
| 其他 / 模糊 | 9% | 全库 |
先做一次轻量分类(还是 gpt-4o-mini),然后用 Pinecone 的 metadata filter 把检索范围限制到对应子库。"发票"类原本 23% 的召回直接干到 86%,因为去掉了其他类目里偶尔含"发票"这个词的噪音。
CLASSIFY_PROMPT = """将用户问题分类到下列意图之一,只输出意图名称,不要解释。
意图列表: refund, logistics, account, promotion, invoice, other
用户问题: {q}
意图:"""
INTENT_TO_NAMESPACE = {
"refund": ["kb_refund", "kb_aftersales"],
"logistics":["kb_logistics"],
"account": ["kb_account"],
"promotion":["kb_promotion", "kb_campaign"],
"invoice": ["kb_invoice"],
"other": None,
}
async def route_and_retrieve(q: str, k: int = 5) -> list[dict]:
intent = (await llm_text(CLASSIFY_PROMPT.format(q=q), model="gpt-4o-mini")).strip().lower()
namespaces = INTENT_TO_NAMESPACE.get(intent)
coarse = await multi_query_retrieve(q, k=20, namespaces=namespaces)
return rerank(q, coarse, top_k=k)
三板斧叠加后的最终曲线(评估集 610 条):
| 版本 | Recall@5 | Answer Hit | Refusal Acc | P50 延迟 |
|---|---|---|---|---|
| v0(基线) | 51.0% | 38.1% | 12.0% | 340ms |
| + 语义分块 | 56.8% | 43.2% | 14.7% | 360ms |
| + Multi-Query 改写 | 75.0% | 57.9% | 27.3% | 540ms |
| + Reranker | 83.5% | 69.9% | 40.1% | 635ms |
| + 意图路由 | 89.2% | 78.5% | 45.6% | 720ms |
| + 拒答工程(第 4 周) | 89.2% | 81.4% | 83.7% | 720ms |
第 4 周:拒答比答对更难
Refusal Acc 从 12% 飙到 45% 是意图路由附带的——路由到错误子库 + 子库里检不到,LLM 自然拒答。但 45% 还远远不够:剩下的 55% 是明明检不到却硬答的样本,客服场景下这种行为不能上线。
4.1 拒答工程的三个杠杆
| 杠杆 | 做法 | Refusal Acc 增量 |
|---|---|---|
| rerank 分数阈值 | top1 rerank score < 0.45 直接拒答,不送 LLM | +18 个点 |
| Prompt 里强约束 | 系统提示词明确"找不到就拒答"+ few-shot 示例 | +12 个点 |
| 引用要求 | 要求 LLM 输出 doc_id 引用,无法引用时必须拒答 | +8 个点 |
三个杠杆叠加,Refusal Acc 到 83.7%,代价是 Answer Hit 微跌 0.6 个点(少量本该回答的样本被阈值挡住了)。这个 tradeoff 我接受:误拒比误答好,客服场景下尤其如此。
4.2 终版 Prompt
SYSTEM_PROMPT = """你是客服知识库助手。严格遵守以下规则:
1. 仅基于<参考文档>中明确写出的内容回答,不要补充任何自己的判断或常识。
2. 如果参考文档中没有直接相关的信息,回复 [REFUSE] 加一句联系人工客服的话。不要尝试推理或拼凑答案。
3. 每个事实点后用 [doc_X] 标注来源,X 是文档编号。
4. 优惠、退款、发票相关数字必须严格引用,不可改写。
参考文档:
{context}
示例(找不到时):
Q: 你们 CEO 是谁?
A: [REFUSE] 我在知识库中没有找到相关内容,请联系人工客服。
示例(部分相关):
Q: 拼团订单怎么退款?
A: 拼团订单的退款分两种情况:成团前退全款 [doc_3],成团后按未发货可退或已发货走售后流程处理 [doc_3][doc_7]。"""
关键发现:把"[REFUSE]"这种结构化前缀写进 prompt,后端用前缀匹配判定拒答,比让 LLM 自由表达"不知道"再用语义判定准确得多。代码里就一行 answer.startswith("[REFUSE]")。
第 5 周:管线总览 + 决策树
把所有部件拼起来后,运行时管线长这样:
整个 6 周下来,我把"该不该上某个优化"的判断逻辑也沉淀成了一棵决策树,给团队后续的 RAG 项目用:
第 6 周:上线灰度 + 监控
6.1 灰度策略
| 阶段 | 流量比例 | 持续时间 | 放量条件 |
|---|---|---|---|
| 内部 dogfood | 仅团队 5 人 | 3 天 | 0 P0 bug |
| 客服内测 | 10 名座席手动选用 | 5 天 | 满意度 ≥ 70% |
| 5% 灰度 | 随机分流 | 3 天 | Answer Hit ≥ 75% 且 Refusal Acc ≥ 80% |
| 30% 灰度 | 随机分流 | 4 天 | 同上 + P99 延迟 ≤ 1.5s |
| 全量 | 100% | — | — |
6.2 关键监控项
| 指标 | 采集方式 | 告警阈值 |
|---|---|---|
| 实时拒答率 | 埋点 + 5 分钟聚合 | 突增 ≥ 50% 相对值 |
| LLM 调用失败率 | OpenAI SDK 异常计数 | 1 分钟内 ≥ 3% |
| P99 端到端延迟 | OpenTelemetry trace | ≥ 2s 持续 5 分钟 |
| 座席踩反馈率 | UI 按钮埋点 | 当天累计 ≥ 8% |
| 每日 token 消耗 | OpenAI usage API | 环比 ≥ 150% |
第 6 周第 3 天,踩反馈率突破 8%,告警触发,我们暂停了一次放量。复盘下来是某个新上线的"返团"活动 SOP 没进知识库,座席问的问题模型只能拒答——补完文档当天降回 3%。RAG 系统的运维成本里,有相当一部分是知识库本身的更新流转,这个事情最好在产品规划阶段就让"内容运营"角色参与进来。
6 周总账:钱、时间、人
| 项 | 开销 | 说明 |
|---|---|---|
| 评估集标注 | 2 人 × 4 天 | 第 1 周一次性 |
| OpenAI 调用费(开发期) | 约 ¥4,600 | 主要是反复跑评估 |
| OpenAI 调用费(上线后日均) | 约 ¥320 / 天 | Multi-Query + 分类 + 生成 |
| Pinecone 月费 | ¥1,800 / 月 | 没换大模型,沿用原配置 |
| Reranker GPU(共享 A10) | ¥0 增量 | 复用现有推理资源 |
| 工程人力 | 1.0 FTE × 6 周 | 主要是我自己 |
对比"直接换 text-embedding-3-large"那条路:省下 ¥4.7 万/年的额外向量存储,换来 +38 个点的 Answer Hit。
认知更新清单
这 6 周最大的几个认知翻转,我故意写得直白一点,避免后人(包括以后的我自己)再踩:
| 翻转前认知 | 翻转后认知 |
|---|---|
| chunk size 是 RAG 第一变量 | chunk size 在我们这种意图明确的客服语料上是次要变量,Query 改写 + Rerank + 路由的合计收益是 chunk 调优的 10 倍以上 |
| Hybrid retrieval(稠密+BM25)总是好的 | 看语料。强 keyword 语料(SKU、订单号、ID)有收益,自然语言语料反而引入噪音 |
| 换更贵的 embedding 模型可以一步到位 | 多数情况下 Multi-Query 改写就够了,而且便宜 50 倍 |
| Reranker 是锦上添花 | Reranker 对 Answer Hit 的提升常常超过对 Recall 的提升——因为它决定哪些文档进 LLM 上下文 |
| "找不到就拒答"靠 Prompt 写一句就行 | 结构化拒答(REFUSE 前缀 + 分数阈值 + 引用强制)三件套缺一不可 |
| RAG 上线后基本不用管 | 知识库内容运营 + 评估集增量是长期成本,比预想大 |
| 端到端评估是终点 | 按意图分桶看 + 看 Answer Hit 分布的尾巴,才能发现真正的结构性问题 |
一个反例:不该做的事
第 3 周我曾经被一篇 paper 启发,想试试 HyDE(Hypothetical Document Embeddings)——先让 LLM 生成一个假设性答案,再用假设答案的向量去检索。在我们的语料上跑下来:Recall 提升 1.2 个点,延迟 +280ms,成本 +¥0.0015 / query。不值得。我把这条路也写进决策树否决分支,免得团队里其他人重新踩一遍。
真正决定 RAG 上限的,从来都不是"用了什么花活",而是:(1) 评估集质量;(2) 文档本身是否结构清晰、覆盖完整;(3) Query 和文档的"语言风格 gap"。前面三件事不解决,任何 paper 里的 trick 都救不了。
第 7 个细节:LLM 生成阶段的两个隐性失败
检索拿到 89.2% 之后,我以为剩下的差距(Answer Hit 78.5% vs Recall 89.2% 之间有 10.7 个点)只是"LLM 偶尔不听话"。深入看才发现两个具体的失败模式,它们没法靠 prompt 工程一句话搞定。
7.1 多文档矛盾时的拼接答案
有些"政策版本变更"型的问题,top5 里会同时检到"老版本政策"和"新版本政策"两篇文档。LLM 倾向于把两者糅合输出,看起来通顺但实际上是错的(把已经废止的条款当作生效条款讲)。这种错误对客服场景是致命的。
修复方法不是改 prompt,而是给文档加一个 effective_date metadata,检索后在 rerank 之前先做一次"时效过滤":
def filter_by_recency(candidates: list[dict]) -> list[dict]:
grouped: dict[str, list[dict]] = {}
for c in candidates:
topic = c["meta"].get("policy_topic")
if not topic:
grouped.setdefault("__no_topic__", []).append(c)
continue
grouped.setdefault(topic, []).append(c)
out = []
for topic, items in grouped.items():
if topic == "__no_topic__":
out.extend(items)
else:
items.sort(key=lambda x: x["meta"].get("effective_date", ""), reverse=True)
out.append(items[0])
return out
简单规则:同一个 policy_topic 下只保留最新生效版本。这种过滤覆盖了大约 6% 的样本,把这些样本的 Answer Hit 从 41% 拉到了 88%。
7.2 数字幻觉
第二个失败模式更隐蔽:LLM 偶尔会把文档里的"满 199 减 30"输出成"满 200 减 30"或"满 199 减 40"。我们用 612 条含数字的样本压测,发现数字错误率 1.3%——单看是低,但发票、退款、优惠这些场景里一个数字错就是客诉。
解法:在生成阶段把所有数字事实显式 grounding。具体做法是把文档里出现的数字+单位+上下文抽出来,在 prompt 里以"事实清单"形式重述一遍,让 LLM 必须从这个清单里选,而不是自由生成:
NUMBER_PATTERN = re.compile(r'(\d+(?:\.\d+)?)\s*(元|%|天|小时|分钟|次|件)')
def extract_facts(docs: list[dict]) -> list[str]:
facts = []
for d in docs:
for m in NUMBER_PATTERN.finditer(d["text"]):
num, unit = m.group(1), m.group(2)
start = max(0, m.start() - 20)
end = min(len(d["text"]), m.end() + 20)
context = d["text"][start:end].replace("\n", " ")
facts.append(f'[doc_{d["doc_id"]}] {context}')
return facts
# 在 prompt 中:
# 以下是参考文档中出现的所有数字事实,请严格引用,不要修改任何数字:
# {chr(10).join(facts)}
这一步把数字错误率从 1.3% 降到了 0.08%——对客服场景来说基本可以接受了。代价是 prompt 平均多 280 tokens,生成成本 +¥0.0004 / query。
团队协作上的两条纪律
6 周里有两个非技术问题让我反复栽跟头,写下来给自己提醒:
纪律 1:每个 PR 都必须跑评估集
前 2 周我们曾经走过"改 prompt → 抽样看几条→觉得效果好就合了"的路。第 3 周一次回归发现某个 prompt 改动让 Refusal Acc 掉了 7 个点,我们追了两天才定位。从此约定:任何改 retrieval/prompt/分块策略的 PR,必须挂上评估集报告,Answer Hit 和 Refusal Acc 都不允许相对下降超过 1 个点,否则需要二人复核签字。
实现上很简单:GitHub Actions 跑 python eval.py --diff base..HEAD,把对比表贴回 PR 评论。一次完整跑 610 条样本约 18 分钟、¥12 成本——和省下来的故障比起来微不足道。
纪律 2:别等"完美"了再上灰度
第 5 周我们的 Answer Hit 还在 78%,产品经理坚持要再调到 85% 再灰度。我反过来说服了他:78% 的 RAG 比 0% 的 RAG 强得多——剩下 22% 走拒答 + 转人工,座席总工时反而下降。最终我们 78% 就上了 5% 灰度,真实流量反馈帮我们发现了 3 个评估集没覆盖的意图分布问题,这是再多调评估集都看不出来的。
| 反直觉发现 | 具体表现 |
|---|---|
| 真实流量里"咨询商品规格"占 11%,评估集没覆盖 | 评估集是用历史工单建的,商品咨询很少形成工单 |
| "促销活动倒数 24 小时"前的 query 量是平均的 3.6 倍 | 需要 LLM 调用并发预留 + 限流策略 |
| 夜班座席的 query 风格比白班更随意,改写效果更明显 | Multi-Query 在白班的相对收益是 +12pp,夜班是 +21pp |
三个回不去的细节
除了主线的三板斧 + 拒答工程,还有几个小细节是上线后才意识到不能省的,这里集中讲一下,免得后人在某个深夜被这些细节坑到:
细节 1:embedding 缓存必须按文本内容做指纹,而不是文档 ID
第 4 周我们做过一次"全量重新 embed"的事故。起因是有人改了一个 utility 函数里的 normalize 逻辑(去掉了多余空格),导致向量库里 11.8% 的 chunk 文本指纹和重算结果对不上,但 doc_id 完全一样。如果按 doc_id 判断"是否需要重新 embed",这部分文档会直接跳过——结果是检索质量悄悄退化了一周才被发现。从此改成 hash(normalized_text) 作为缓存键,并把指纹存进 Pinecone metadata 里,启动时做一致性校验。
细节 2:Pinecone 的 metadata filter 别套太深
我们一度想用 {"$and": [{"namespace": "kb_refund"}, {"effective_date": {"$gte": "2025-01-01"}}, {"region": {"$in": ["CN", "HK"]}}]} 这种复合过滤一次搞定。实测 P99 检索延迟从 95ms 飙到 320ms,因为 Pinecone 对复合 filter 的索引利用率会下降。最终拆成"namespace 在检索时定,其他维度在 rerank 时本地过滤",P99 降回 110ms。
细节 3:LLM 输出里的 doc_X 引用必须做存在性校验
极少数情况下 LLM 会"幻觉"一个不存在的 doc_id,比如上下文里只给了 doc_3 doc_7,它输出却写 [doc_12]。这种回答前端展示时点击引用会 404。修复:生成完成后做一次正则提取 + 集合校验,任何引用不在本次上下文集合里的回答,自动重生成一次,仍然出错则回退拒答。
这三个细节单看都是小修小补,合起来贡献了大约 2 个点的 Answer Hit 提升和大量"看不见的稳定性"。客服系统的可信度是被这种细节累积出来的,不是被一次大爆点决定的。
细节 4:温度和 max_tokens 不要乱调
有同事提议把 LLM 温度从 0 调到 0.3,理由是"答案更自然"。实测下来:Answer Hit 几乎不变,但拒答前缀的稳定性下降——大约 0.4% 的拒答样本生成成了"我们好像没有相关信息"这种自然语言而非 [REFUSE] 前缀,后端没法识别,误传给座席。最终规定生成阶段一律 temperature=0,需要变化的只在 Query 改写阶段(那里温度 0.7 反而能多样化候选)。max_tokens 也别设太大,我们用 512:再大也只会让 LLM 啰嗦,反而稀释关键信息的密度。
细节 5:把"流量回放"作为日常工具
上线后我们做了个简易工具:把昨天 24 小时真实流量里所有"被踩"或"被弃用"的 query 拉出来,在当天的最新管线上重跑一遍,生成对比表。这比定时跑评估集敏感得多——评估集是静态的,流量回放能第一时间发现"新的 query 风格"或"新意图"的退化。该工具的代码只有 60 行,但 90% 的"为什么今天比昨天差"问题都靠它定位。
下一步
系统稳定运行 4 周后,我们打算做两件事:
- 把座席踩过的样本自动回流到评估集——目前是周三人工合并一次,瓶颈在标注;
- 把 Multi-Query 改写换成微调的小模型(Qwen2.5-1.5B)——gpt-4o-mini 每天约 ¥220 的成本主要花在改写上,本地跑能省一大半。
这两件事各 2 周左右,等做完了再写一篇复盘。但更重要的"下一步"其实是组织层面的:让内容运营团队真正接入 RAG 系统的反馈循环——目前知识库更新和评估集增量是两条平行线,这条线必须并起来,否则技术再怎么调都救不了内容滞后导致的拒答增长。
—— 客服 RAG 项目记录,2026/05
—— 别看了 · 2026