客服RAG系统从demo到生产的6周复盘:召回率38%到89%的真实路径+别再迷信chunksize+被否决的方案比被采纳的更值钱

接手一个 demo 跑通但生产 38% 准确率的 RAG 系统,6 周把 Recall@5 从 51% 顶到 89%、Answer Hit 顶到 81% 的完整路径。Multi-Query 改写贡献最大、Reranker 反直觉地在 Answer Hit 上比 Recall 提升更多、chunk size 实测只是次要变量。否决的方案(BM25 hybrid、HyDE、换大 embedding)和拒答工程三件套都写在里面。

背景: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 被噪音误导。

当时我有两条路:

  1. 继续调 RRF 权重(α=0.3 / 0.5 / 0.7 / 0.85)
  2. 把 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 周后,我们打算做两件事:

  1. 把座席踩过的样本自动回流到评估集——目前是周三人工合并一次,瓶颈在标注;
  2. 把 Multi-Query 改写换成微调的小模型(Qwen2.5-1.5B)——gpt-4o-mini 每天约 ¥220 的成本主要花在改写上,本地跑能省一大半。

这两件事各 2 周左右,等做完了再写一篇复盘。但更重要的"下一步"其实是组织层面的:让内容运营团队真正接入 RAG 系统的反馈循环——目前知识库更新和评估集增量是两条平行线,这条线必须并起来,否则技术再怎么调都救不了内容滞后导致的拒答增长。

—— 客服 RAG 项目记录,2026/05

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

Node.js+TSmonorepoCI/CD流水线从28分钟压到4分钟的3周复盘:6大方向30项优化+Turborepo/oxlint/BuildKit实战

2026-5-26 17:34:59

技术教程

Saga 分布式事务库存幽灵占用事故复盘:2 个月 47 单灵异库存 + 状态机持久化 + 幂等 + dead-letter 监控三件套

2026-5-26 17:56:33

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索