「帮助中心问答助手」上线在 samememory.life/rag:用中文向 Postman 官方文档提问,回答带引用编号,答不了会拒答或反问澄清。全部跑在一台 1.8GB 内存 / 2 核的 VPS 上——ONNX 量化版 e5-small 做向量、Chroma 做库、deepseek-v4.1-flash 做生成(OpenAI 兼容端点),FastAPI 单进程同时服务多个文档项目。
语料是 Postman 帮助中心 732 篇英文文档切出的 8187 个块。架构本身不复杂,复杂的是「怎么知道每次改动是变好还是变坏」。这篇文章讲的就是这条评测迭代线:十轮评测,命中 43% → 86%,LLM-judge 2.55 → 4.56,其中三个负结果比三次升级更有讲头。
一、项目是什么
与站内 pycamp 那套课程知识库检索不同,这次的语料是英文官方文档、提问是中文口语,跨语言是主要矛盾。检索链路最终形态:
- 双路召回:英文原文 dense + 中文摘要 dense(离线批量为每个块生成 60-80 字中文摘要,查询时中文问题先命中摘要再映射回英文块),RRF(k=60)融合;
- 模板页降权:CLI 等模板化页面得分 ×0.45——硬排除会让真正问 CLI 的问题永远无法召回,降权保留可能性;
- cross-encoder 重排:SiliconFlow 免费的 bge-reranker-v2-m3,零内存占用,延迟 +0.5s;
- 多查询扩检:原问题 + LLM 生成的中文改写 + 英文翻译,3 路并行召回合并重排;
- 父子块:小块(800 字)做召回保精度,命中后回填所属小节全文(≤3200 字)给生成保完整。
二、先修尺子:评测口径的两次踩坑
坑 1:LLM 对着语料反向出题,全对是假的
最早用 LLM 读着文档出 14 道评测题,检索配置一跑:100% 命中 / 4.33 分。换了人工把关的「真实用户问法」集合同样配置重测:43% / 2.55。原因:出题人知道答案,会不自觉复用文档措辞,题面和文档词面高度重合——评测集分布和真实提问分布完全是两回事。
坑 2:多跳题的命中口径
「命令行怎么跳过确认提示?推送前先查无效文件用什么命令?」这种题要跨段落拼答案,gold 只登记一篇文档会误判 miss。50 题版黄金集改成 source_docs 数组、任一命中即算,并把多跳题占比固定在 20%。
最终口径:50 题 LLM 集(口语化约束 + 分区分层抽样 + 20% 多跳双文档)做主口径,14 题人工集做悲观参照——LLM 集系统性偏乐观,两个数字都报,不挑好看的。
三、检索架构:两路召回 + 降权 + 重排
每个组件都是消融出来的,不是设计出来的:
| 改动 | 触发的问题 | 实测 |
|---|---|---|
| 模板页硬排除 → 降权 ×0.45 | CLI 模板页污染向量空间(裸 dense 33%);但硬排除让真正问 CLI 的题永远无法召回 | pd-008 从拒答修复为满分 |
| + 中文摘要双路 | 中文问题与英文文档词面/语义双断裂,BM25 在跨语言语料上是纯噪音(消融掉分) | 中文多跳题召回率大幅回升 |
| + cross-encoder 重排 | e5 余弦分数挤在 0.91-0.92 无区分度 | 断崖式区分:正确文档 0.984,第二名 0.008 |
| + 多查询扩检 | 单次 embedding 对措辞敏感 | 3 路并行召回合并重排,命中率 84%→86% |
四、十轮评测总表
| 版本 | 命中 | judge | 变更 |
|---|---|---|---|
| v2 基线 | 43% | 2.55 | oversample + 硬排除(14 题人工集) |
| v3 | 79% | 4.27 | 降权 + 摘要双路 + cross-encoder |
| v4 | 79% | 4.45 | + agentic 快慢路径(14 题集) |
| v5 | 84% | 4.32 | 50 题集 + 多查询扩检 |
| v6 | 82% | 4.28 | ❌ 只调 rerank 阈值无效 |
| v7 | 86% | 4.50 | 门控校准(见下) |
| v8 | 82% | 4.38 | ❌ 中分段强制过判,负结果 |
| v9 | 86% | 4.38 | + 父子块(细节收益 judge 测不出) |
| v10 | 86% | 4.56 | + 60-80 字摘要 v2 |
五、三个深坑与一个负结果
坑 A:快路径门 0.78 永远放行
agentic 检索设计了快慢路径:分数够高直接答,不够就触发「LLM 判断资料充分性 → 子查询补充检索」。上线后 50 题只触发 1 次,形同虚设。排查发现 e5 的余弦分布天然跑在 0.85-0.93,dense≥0.78 的门永远放行。改成「有 rerank 分时只信 rerank 门(0.35)」后,同一配置 84%→86%、judge 4.32→4.50。阈值必须对着分数的实际分布校准,不能拍脑袋。
坑 B:judge 拦不住"自信错误"(负结果)
想把门提到 0.60、让中分段全部过一遍「资料充分性判断」。结果 82%/4.38 低于基线:6 次判断全部"资料足够"。原因:judge 只看问题和检索片段,看不到标准答案,对「同主题但错误文档」天然无感。这类错误的根治在语料补齐与父子块,不在门控。
坑 C:增量入库的 idx 错位陷阱
中文摘要用 idx 指向 chunk 的全序位置。新文档按文件名排序会插进中间,一旦全量重建,所有摘要映射整体错位。解法是增量追加:新块续号排到尾部,旧 idx 不动。离线索引与增量更新的耦合,必须在设计期想清楚。
坑 D:容器迁移丢环境变量
docker restart 不会重新读取 env_file——容器从 docker run 迁到 compose 后,模型路径变量丢了,触发一个潜伏 bug(hf_hub_download 未导入,此前一直靠预置路径变量绕过)。
六、agentic 快慢路径与意外收益
快路径:rerank ≥0.35 直接生成,绝大多数问题零额外延迟。慢路径:LLM 判断资料充分性 → 生成 ≤2 个子查询补充检索 → 合并去重 → cross-encoder 全局重排,最多 2 轮,全程 fail-open。
为什么不用 LangGraph:1.8GB 的机器装不动 langchain 依赖树;自研循环 60 行、流程透明、每个环节可单独评测。这个取舍本身比用了什么框架更值得讲。
七、数据闭环:日志、缺口报告与澄清反问
- 查询日志:每次问答落 SQLite(问题、拒答与否、置信度、top 分数),日志失败绝不影响主流程;
- 缺口报告:脚本聚类拒答/低置信查询,输出「用户在问什么但语料没有」——上线后的准确率是靠这个循环养出来的;
- 澄清式反问:拒答前先判断,问题含糊就反问澄清(带 2 个选项),完全无关才拒答。上线以来尚未自然触发——语料覆盖好到连「怎么把 Hoppscotch 的集合导入」这种竞品问题都能正确作答,属于健康状态。
八、四条方法论
- 先修尺子,再优化:口径错了,一切优化都是对噪音做梯度下降——LLM 反推出题那次 100% 的假繁荣差点成为"上线成绩";
- 负结果也是产出:LLM 反推出题、BM25 跨语言、门控过判——三个"不要做"省下的弯路比三个"要做"值钱;
- 阈值对着分布校准:rerank 0.98 vs 0.008 的断崖让 0.35 很安全,dense 0.85-0.93 的分布让 0.78 永远失效;
- 长链路一致性:离线索引与增量更新的耦合、评测口径与线上实现的一致,这类问题不在设计期想清楚,就会在深夜以最恶心 debugging 的形式出现。
九、现状
线上现状:50 题口径命中 90% / judge 4.54,拒答 3/3 正确,首字 1-3s。检索质量的边际收益已经见顶,下一步的准确率增长来自真实流量:查询日志攒够数据后跑第一份知识缺口报告,让 bad case 自己开口说话。
在线体验:samememory.life/rag · 评测明细与消融数据见项目仓库 docs/eval-*.md。