不依赖 LangChain / LlamaIndex,从零手写检索增强生成全链路。 面向石油石化领域年报文档问答,重点是失败归因与评估驱动的优化。
| 项 | 结果 |
|---|---|
| 语料 | 中国石油年报 PDF,解析出 1,265 个 chunk |
| 完整链路 | PDF 解析 → 分块 → 向量检索 + BM25 → 加权融合 → cross-encoder 重排 → LLM 生成 |
| 可运行服务 | FastAPI + 单文件演示页,/search 与 /ask 分离。GPU 下检索 0.85 秒(CPU 12.4 秒,14.6 倍) |
| 评估体系 | 38 条三类评估集(事实 / 多跳 / 口语化改写 + 不可答),锚点对齐、可跨分块复用 |
| 检索提升 | 目标块命中排名 16 → 2 → 1(向量 → BM25 → 加权融合) |
| 工程优化 | 纯 Python 循环 156ms → numpy 矩阵化 0.48ms(325 倍) |
| 防幻觉 | 语料外问题正确拒答,引用编号可逐字溯源到原文块 |
| 实验量 | 8 个阶段 · 7 种检索配置 · 含 3 次失败归因 |
八个核心发现(详见「实验与发现」与「评估结果」)
- 整份文档不能直接当向量——会被静默截断
- 纯向量分不清「答案」和「看起来像答案的东西」,分差仅 0.02
- BM25 能补上,但中文必须加领域词表
- 样本量决定结论:同一套脚本,5 条样本 vs 32 条样本,四个配置的优劣全部翻转
- 融合不总是正收益:纯 BM25 在事实型问题上 Hit@5 达 0.889,融合后反而降到 0.778
- 口语化改写是真实短板:8 条改写问题,所有配置都过不了 0.4
- 三次「看起来是 A,其实是 B」——归因比调参重要
- 语料质量是天花板:PDF 表格提取会产出**「竖排数字粥」**
输入企业年报 PDF,能对里面的内容提问,输出带引用来源、可溯源、不确定时拒答的答案。
问题
↓
┌──────────────── 检索 ────────────────┐
│ 向量检索 Top-50 ┐ │
│ ├─ RRF 融合 → Top-20 │
│ BM25 Top-50 ┘ │
└───────────────────────────────────────┘
↓
cross-encoder 重排 → Top-5
↓
拼装带编号的参考资料 → 调用 LLM → 答案 + [1][2] 引用
市面上 99% 的 RAG 项目是 LangChain + Chroma + 一句话调用,看不出任何工程细节。
这个项目主体是手写的,每个环节自己实现:
- BM25 是手写的(倒排索引 + 正 IDF 变体),不是调库
- 分块、融合、引用解析全部自己实现
- 每一步都配对照实验,而不是"调完感觉变好了"
目标不是做出一个能跑的 demo,而是搞清每个设计为什么存在。
招聘 JD 普遍写着「会使用 LangChain / Dify / Coze 等至少一种大模型应用框架」。
所以我把同一件事用 LangChain 又做了一遍,并做了逐项对照(src/stage10_langchain_rag.py)。
⭐ 最有意思的一步:我把「手写检索」实现成了 LangChain 的 BaseRetriever。
水平一:调框架的组件(FAISS.from_documents) —— 会用,但受限于它能做什么
水平二:读框架源码,知道它内部做了什么
水平三:让自己的实现符合框架的抽象 ——《 我做的
做到第三步之后,手写检索就能和框架的其他组件自由组合:放进 LCEL 链、
被 Agent 当工具调用、自动获得回调与追踪。想换成 LangChain 自带的
BM25Retriever 或 FAISS,只需要换一个对象,链上其他部分一行不用改。
| 环节 | 我手写的 | LangChain | 判断 |
|---|---|---|---|
| 提示词 | f-string 拼 | ChatPromptTemplate |
框架略好:变量名拼错会立刻报错(f-string 要到运行时才发现) |
| 模型调用 | requests.post 裸调 |
ChatOpenAI |
框架明显好:max_retries / timeout / batch / streaming 都是构造参数,手写要几十行 |
| 输出解析 | re.findall 抽 [n] |
输出解析器 + 结构化输出 | 框架更稳(正则会被"第[3]条"这类干扰) |
| 链式编排 | 自己写函数串联 | LCEL 管道 | |
框架明显好:换模型改一行,链不用动 |
| 批量调用 | 自己写循环 | .batch() |
框架白送:3 个问题并发 3.1 秒 |
| 检索 | 手写 BM25 + 向量 + 加权 RRF + 重排 | Retriever | 不该交给框架(见下) |
⭐⭐ 唯一我坚持不用框架的地方:检索权重。
我保留了自己调的 加权融合(向量 0.3 / BM25 0.7) —— 因为这个权重是阶段 7用 32 题评估集调出来的, 框架给的是默认值,不是最优值。
换框架解决不了检索效果问题,只有评估能。
一句话总结:用框架的工程能力,自己控制检索质量。
这一节是项目的重点——不是「写了多少代码」,而是「验证了什么、修了什么」。
BGE-M3 最大输入 8192 token,25 万字的年报会被静默截断。
实测:整份年报的向量,与「只取前 8000 字」的向量相似度高达 0.999—— 说明 8000 字之后的内容压根没进模型。
→ 必须分块,否则模型只是在拿"年报的封面"回答问题。
问题「中国石油2023年的营业收入是多少?」:
| 排名 | 块内容 | 相似度 |
|---|---|---|
| 1 | 一张无关的子公司持股表 | 0.7404 |
| 16 | 正确答案所在块 | 0.7189 |
分差只有 0.02(相对 3%)。
原因是两块都是「中文财务文本 + 大量数字」,纯向量模型看的是"整体语义像不像", 看不出「这一段在回答问题,那一段只是在列表格」。
手写 BM25(倒排索引 + 正 IDF)后,正确答案从第 16 名升到第 4 名。
但 jieba 把「营业收入」切成「营业/收入」、「中国石油」切成「中国/石油」, 两个最有区分度的词都废掉了。
加入 19 个石油财务领域词后:
| 词 | 出现在多少块 | IDF |
|---|---|---|
营业(被切碎) |
45 | 3.326 |
营业收入(加词后) |
62 | 3.008 |
的 |
1095 | 0.145 |
排名随之升到第 2。
中国石油 在当前语料里 IDF 只有 0.794——因为它出现在 45% 的块里。
因为语料只有它一家的年报,公司名到处都是。 语料换成 30 家公司后,它自然会变成稀有词。
结论:IDF 衡量的是「这个词在你的语料库里有多稀有」,而不是词的固有重要性。
| 阶段 | 目标块排名 |
|---|---|
| 纯向量 | 16 |
| BM25 | 2 |
| RRF 融合 | 1 |
为什么 RRF 用排名而不用分数:向量分数是余弦相似度(0~1),BM25 分数无上界(这里到 11), 两者量纲完全不同,相加毫无意义。
RRF 公式:score(doc) = Σ 1 / (k + rank),k=60。
它只看"排第几",因此天然免疫量纲问题。
核心性质:一路的第 1 名,比不过两路都还不错的选手。
| 实现 | 检索 1265 块耗时 |
|---|---|
| 纯 Python 循环 | 156.4 ms |
| numpy 矩阵乘法 | 0.48 ms |
| 提速 | 325 倍 |
原因:Python 循环慢的不是"算得慢",而是逐元素的对象创建和解释器开销。
vecs @ q 一次算完所有相似度,走 C 循环 + SIMD。
这是工程优化而非算法优化——还是暴力算全部,只是让同样的计算跑得更快。
| 表面现象 | 查证后的真因 |
|---|---|
| 纯向量检索不到答案 | 不是模型不行,是排序问题(答案排第 16) |
| BM25 效果不理想 | 不是算法问题,是 jieba 把「营业收入」切碎了 |
| 重排把正确答案从第 1 打到第 7 | 不是重排坏了,是被排到前面那块确实高度相关——它是财务报表里的「营业收入附注」,只是不含我们指定的那个总数 |
任何「变差了」都要先归因再动手。 用单个样本判断优化好坏是不可靠的——这正是需要评估集的原因。
page.get_text("text") 不理解表格结构,把财务表格压成竖排数字粥:
505
-
1,285,752
-
-
1,350,257
合计
151,150
336,861
表头与数据行被冲散、表格被切块拦腰砍断。 这是「数字碎片污染检索」的真正源头——不是模型笨,是语料本身就是坏的。
待办:改用 PyMuPDF 的表格提取 API,或用「数字占比」过滤这类块。 但需要先有评估集,才能验证改动是否真的有效。
提示词写死三条规则:只用给定资料、必须标引用、没有就明说「无法回答」。
实测 4 个问题:
| 问题 | 结果 |
|---|---|
| 营业收入是多少 | ✅ 答对 + 引用可溯源 |
| 净利润是多少 | ✅ 答对 + 引用可溯源(经逐字核对) |
| 西南石油大学的校训 | ✅ 正确拒答 |
| 量子纠缠的贝尔不等式 | ✅ 正确拒答 |
关键认知:拒答率高不是模型问题,是检索问题—— 大模型只能看到检索交给它的 5 个块,漏了就只剩拒答或编造两个选项。
引用机制让答案可核查,但也给幻觉背书—— 一个格式规范、带出处、精确到小数点后两位的假答案,比裸的胡编更容易骗过人。
所以引用必须可验证:答案里的数字,要能在被引用的块里逐字找到。 这个检查应该自动化(阶段 7)。
完整数据见 docs/评估结果.md。38 条评估集,32 条参与检索评估。 所有数字可复现(重跑结果完全一致,无随机性)。
| 配置 | Hit@1 | Hit@3 | Hit@5 | Hit@10 | MRR |
|---|---|---|---|---|---|
| 纯向量 | 0.0312 | 0.1250 | 0.2500 | 0.3438 | 0.1268 |
| 纯 BM25 | 0.2812 | 0.5938 | 0.7188 | 0.7500 | 0.4534 |
| RRF 等权(0.5/0.5) | 0.2812 | 0.4375 | 0.4688 | 0.5938 | 0.4041 |
| 加权(0.4/0.6) | 0.3125 | 0.4375 | 0.5000 | 0.6250 | 0.4222 |
| 加权(0.3/0.7) | 0.3438 | 0.4375 | 0.4688 | 0.7188 | 0.4374 |
| 加权(0.2/0.8) | 0.3125 | 0.4375 | 0.5312 | 0.7500 | 0.4374 |
| 加权(0.3/0.7)+重排 | 0.4375 | 0.5938 | 0.6562 | 0.7812 | 0.5445 |
| 配置 | factual(n=18) | multi_hop(n=6) | paraphrase(n=8) |
|---|---|---|---|
| 纯向量 | 0.278 / 0.153 | 0.333 / 0.125 | 0.125 / 0.069 |
| 纯 BM25 | 0.889 / 0.577 | 0.667 / 0.377 | 0.375 / 0.232 |
| RRF 等权(0.5/0.5) | 0.611 / 0.514 | 0.333 / 0.329 | 0.250 / 0.214 |
| 加权(0.3/0.7) | 0.611 / 0.556 | 0.333 / 0.294 | 0.250 / 0.278 |
| 加权(0.2/0.8) | 0.667 / 0.537 | 0.333 / 0.337 | 0.375 / 0.288 |
| 加权(0.3/0.7)+重排 | 0.778 / 0.695 | 0.667 / 0.376 | 0.375 / 0.332 |
-
纯 BM25 在事实型问题上碾压一切(Hit@5 0.889)。 把只有 0.278 的向量检索融合进来,是负收益——降到 0.778。
-
口语化改写全体崩盘(最高 0.375,向量最低 0.125)。 注意:向量在改写问题上比在事实问题上还差(0.278 → 0.125), 这与"向量擅长同义泛化"的教科书直觉相反。
-
没有任何配置全面胜出:Hit@5 最优是纯 BM25,MRR / Hit@10 最优是加权+重排。 "最优配置"这个问题问错了——正确的问题是 "在什么 top_k 下、以什么指标为目标时,哪个配置最优":
喂给 LLM 的块数 该选谁 依据 top-5 纯 BM25 Hit@5 0.7188 > 0.6562 top-10 加权(0.3/0.7)+重排 Hit@10 0.7812 > 0.7500
一个能跑的 demo 和一个能上线的服务,差的主要就是这三件事。 招聘 JD 里的「成本管控 / 日志 / 异常处理 / 监控告警」,核心就是这一层。
代码:
src/stage9_production.py(可插拔中间件,检索逻辑一行没改) 接口:GET /metrics、POST /cache/clear
不知道每次问答花多少钱,就没法做预算、也没法做优化。
meter.record(prompt_tokens, completion_tokens, latency_ms)
# → 累计调用次数 / token / 成本 / 平均延迟实测(服务真实调用):
第 1 次 /ask: cost=¥0.003024 prompt=1396 tokens completion=29 762ms
/metrics: calls=1 cache_hits=1 cache_hit_rate=0.5 cost_yuan=0.003024
⭐ 有了这个才能算:"1000 个员工每天问 10 次" = 每天 ¥30 = 每月 ¥900。 没有这个数字,任何"优化"都是凭感觉。
/metrics 里的 saved_yuan_estimate 是估算值:
用"已发生的平均单次成本 × 命中次数"推的。
被缓存的问题本来会花多少,无法反事实测量 —— 对外汇报时要注明 estimate。
⭐⭐ 关键设计:key 必须包含【检索配置的指纹】
key = sha256(fingerprint + "\x00" + question)
# fingerprint = "topk=5|bge-m3|rrf:vec0.3+bm25_0.7|rerank:v2-m3"这是最容易踩的坑:如果只拿问题文本当 key,
那你把 top_k 从 5 改成 8、换了 embedding 模型、改了融合权重,
缓存里的旧答案还会被返回 —— 而且完全看不出来,因为结果看起来很正常
(就是"旧配置下的正确答案")。
⇒ 把配置指纹拼进 key 之后,配置一变指纹就变,缓存自动失效。 这是把「可能的静默错误」变成「不会发生」。
实测:
第 2 次问同样的问题 → from_cache=true, cost_yuan=0.0 ✅
改了配置指纹后再问 → 未命中(返回 None),不会返回旧答案 ✅
LRU:maxsize=2 时插入 A/B/C → A 被淘汰(它最久没用) ✅
另两个细节:LRU(热点问题会反复问,不能用 FIFO)、TTL(模型和文档都会更新)。
⭐ 最重要的一条:区分「可重试」和「不可重试」
| 可重试 | 不可重试 |
|---|---|
| 429 限流、500/502/503/504、连接/读超时 | 400 请求体错误、401 鉴权失败、403 无权限 |
401 重试一百次也一样,只会浪费时间。
⭐ 第二条:指数退避 + 随机抖动
- 退避:等 1s → 2s → 4s(对方在限流时,立刻重试只会加剧)
- 抖动:±30% 随机(否则所有客户端同时重试,thundering herd 把刚恢复的服务再打挂)
⭐ 第三条:最终必须降级 重试用完还是失败,不能把异常抛给用户 —— 要返回明确的话并记录。 静默失败和静默成功一样危险。
实测(4 个场景):
场景1 前 2 次 503 后成功 → attempts=3, degraded=False, 重试 2 次 ✅
场景2 一直 503 → degraded=True, 返回"服务暂时不可用" ✅
场景3 401(不可重试) → 实际只请求 1 次,没有浪费重试 ✅
场景4 同一问题问 2 次 → 实际只打到 API 1 次,第 2 次花 ¥0 ✅
个人项目和公司内网工具最大的差别不是算法,是这些没人写进 demo 的东西。 这一节是"如果给我 200 人的公司,我会怎么把这个 demo 变成能用的内部工具"。
现在任何人问任何问题,检索都是全库的。公司内部不行 —— 财务制度、人事档案、未公开的产品规划,不能给所有人看。
做法:
- 入库时给每个 chunk 打 ACL 标签(部门 / 密级 / 项目组)
- 检索时先按用户身份过滤候选集,再算相似度
⚠️ 不能只在生成层过滤 —— 那样答案里可能已经泄漏了片段
⭐ 这是最容易出错的地方:过滤必须发生在检索之前,而不是之后。
现在是"离线构建索引 → 重启服务"。公司里这样不行(每天重启一次?)。
做法:
- 指纹去重:文档 → hash,没变就跳过(省掉重复 embedding)
- 增量索引:新增/修改的 chunk 追加,删除的做软删除标记
- 定时重建:每周全量重建一次,清掉累积的碎片
- 版本化:索引带版本号,出问题能回滚(和上面的"配置指纹"是同一思路)
用户点"这个答案有用/没用",是最便宜的标注 —— 比找人标评估集便宜一百倍。
做法:
- 每次问答记录
(问题, 检索到的 chunk, 生成的答案, 用户反馈) - 离线分析:把"没用"的 case 抽出来,看是检索错了还是生成错了
- 回流成评估集:真实 case 比编出来的评估题有价值得多
⭐ 这正好接上我项目的强项:我这套评估体系(Hit@K / MRR / 按类型细分) 可以直接用在线上数据上 —— 不用重新造轮子。
按实测数字:单次问答 ¥0.003(输入 1396 token / 输出 29 token)。
| 场景 | 每天调用 | 每天成本 | 每月成本 |
|---|---|---|---|
| 200 人,人均每天问 5 次 | 1,000 | ¥3 | ¥90 |
| 200 人,人均每天问 20 次 | 4,000 | ¥12 | ¥360 |
| 加上重排和 embedding 的算力 | — | 另计 | 视部署方式 |
⭐ 结论:这个量级的成本完全可以接受。 加上缓存(实测命中率 50%)还能再砍一半。 ⇒ 真正的成本瓶颈不是 API,是 embedding/重排模型的 GPU 资源。
| 指标 | 看什么 | 阈值告警 |
|---|---|---|
| P95 延迟 | 用户体验 | > 10s 告警 |
| 缓存命中率 | 成本是否受控 | < 20% 说明问题太分散,或缓存策略有问题 |
| 降级次数 | 模型服务健康度 | > 1% 说明上游不稳 |
这些在 /metrics 里已经埋好点了 —— 接一个 Prometheus 就能用。
- 鉴权:现在服务无鉴权,内部也要接 SSO/工号
- 限流:防止有人写脚本刷
- 审计日志:谁在什么时候问了什么(合规要求)
- 敏感词过滤:输入和输出都要过
| 环节 | 用的东西 |
|---|---|
| 向量化 / 重排 | sentence-transformers(BGE-M3 + bge-reranker-v2-m3) |
| 关键词检索 | 手写 BM25 + jieba 分词 |
| PDF 解析 | PyMuPDF |
| 数组计算 | numpy |
| 生成模型 | DeepSeek API(OpenAI 兼容接口) |
| HTTP 服务 | FastAPI + uvicorn |
| 大模型应用框架 | LangChain(见 stage10_langchain_rag.py,对照版) |
| 工程化中间件 | 自写:成本计量 / LRU+TTL 缓存 / 重试降级(stage9_production.py) |
- 阶段 1:文本向量化 + 余弦相似度检索
- 阶段 2:PDF 解析 + 分块(验证了截断问题)
- 阶段 3:索引持久化 + numpy 矩阵化(提速 325 倍)
- 阶段 4:手写 BM25 + 领域词表(排名 16 → 2)
- 阶段 5:混合检索(RRF 融合)+ cross-encoder 重排(排名 → 1)
- 阶段 6:接入 DeepSeek 生成 + 引用溯源 + 拒答
- 阶段 7:评估体系(38 条三类评估集 + Hit@K/MRR + 按类型细分)
- 阶段 8:FastAPI 服务 + 单文件演示页(GPU 检索 0.85 秒)
- 阶段 9:生产化三件事 —— 成本可观测 / LRU+TTL 缓存(配置指纹失效)/ 重试降级
- 阶段 10:LangChain 对照版 —— 手写检索实现为
BaseRetriever+ LCEL 链 +.batch()
src/
check_env.py 环境自检
stage1.py 最小 RAG:向量化 + 余弦相似度检索
stage2_extract.py PDF → 文本(PyMuPDF)
stage2_chunk.py 文本 → 分块
stage2_observe.py 验证「整份文档当向量会失效」
stage3_build_index.py 建索引:编码 + 存盘
stage3_search.py 检索 + numpy vs Python 循环 速度对比
stage3_diagnose.py 失败归因:正确答案排第几
stage4_bm25.py 手写 BM25 + 领域词表
stage5_fusion.py 两路检索 + RRF 融合 + 重排
stage5_check_reranker.py 重排模型 sanity check
stage5_inspect.py 查看重排结果的完整原文
stage6_generate.py 接入 LLM:生成 + 引用 + 拒答
stage6_verify.py 核对引用是否真的可溯源
stage7_find_anchors.py 从语料里挑出适合出评估题的锚点候选
stage7_eval.py 评估:7 种检索配置 × Hit@K/MRR,按问题类型细分
stage8_api.py FastAPI 服务 + 演示页(/search、/ask、/metrics、/cache/clear)
stage9_production.py 生产化三件事:成本计量 / LRU+TTL 缓存 / 重试降级
stage10_langchain_rag.py LangChain 对照版(手写检索 → BaseRetriever + LCEL 链)
tools/
fetch_parallel.py 多线程分块下载器(解决单连接限速,实测提速 9 倍)
eval/
questions.jsonl 评估集(38 条,锚点对齐,可跨分块复用)
docs/
评估结果.md 完整的评估数据、发现与局限
面试准备.md 项目介绍话术、高频问答、关键数字速查
index/ 索引产物(.gitignore 忽略,可重建)
data/ 原始语料(.gitignore 忽略)
outputs/ 评估结果 JSON
.env API 密钥(.gitignore 忽略)
python -m venv .venv
.venv\Scripts\Activate.ps1 # Windows
pip install -r requirements.txt
python src/check_env.py # 自检PyPI 上的 Windows torch 是 CPU 版。实测 CPU 下
/search要 12.4 秒, 换成 CUDA 版后降到 0.85 秒。pip install torch --index-url https://download.pytorch.org/whl/cu126
⚠️ 官方源国内很慢(实测单连接 0.46 MB/s,2.4 GB 要 87 分钟)。 可以用tools/fetch_parallel.py开 8 线程下同一个轮子,实测 4.27 MB/s、10 分钟:python tools/fetch_parallel.py <轮子URL> tmp/torch.whl --threads 8 pip install --force-reinstall --no-deps tmp/torch.whl瓶颈是单连接限速,不是总带宽 —— 这个结论值得记住。
把年报 PDF 放进 data/raw/(可从巨潮资讯网下载),然后:
python src/stage2_extract.py # PDF → 文本
python src/stage2_chunk.py # 文本 → 分块
python src/stage3_build_index.py # 建索引(跑一次)python src/stage3_search.py # 向量检索 + 速度对比
python src/stage4_bm25.py # BM25 检索
python src/stage5_fusion.py # 混合检索 + 重排在项目根目录建 .env:
DEEPSEEK_API_KEY=sk-xxxxxxxx
然后:
python src/stage6_generate.py # 生成带引用的答案
⚠️ .env已在.gitignore中,密钥不会被提交。
uvicorn stage8_api:app --app-dir src --host 127.0.0.1 --port 8000
⚠️ 模型加载需要 30-60 秒。这期间访问会 503 —— 不是崩溃。 页面右上角有状态灯,显示「服务就绪」再提问。
⚠️ 别去/docs—— 那是 FastAPI 自动生成的 Swagger 调试台, 上面一堆Try it out/Execute/Schema是给开发者手动测接口的,不是给用户看的。 给人看的演示页是/。
| 接口 | 说明 | 耗时(GPU) |
|---|---|---|
GET / |
演示页面 | — |
GET /health |
健康检查(模型是否加载好) | — |
POST /search |
只检索,不调用大模型(免费) | ~0.85 秒 |
POST /ask |
检索 + 生成 + 引用溯源(含成本/缓存/重试) | ~1.8 秒 |
GET /metrics |
成本、缓存命中率、重试与降级计数 | — |
POST /cache/clear |
手动清缓存 | — |
/ask 的返回体带一段 meta,每次问答花了多少钱一目了然:
{
"answer": "中国石油2023年营业收入为人民币30,110.12亿元[2][3]。",
"meta": {
"from_cache": false, // 第 2 次问同样的问题会变成 true
"degraded": false, // 大模型不可用时会变成 true
"cost_yuan": 0.003024,
"prompt_tokens": 1396,
"completion_tokens": 29,
"latency_ms": 762,
"attempts": 1 // 重试过几次
}
}python src/stage9_production.py会验证:token 计量、缓存 LRU/TTL/配置指纹失效、重试/降级/不可重试区分。
pip install langchain langchain-openai langchain-community langchain-text-splitters
python src/stage10_langchain_rag.py --compare| 决策 | 理由 |
|---|---|
手写 BM25 而不用 rank_bm25 |
一共 40 行,自己写一遍才真懂 k1/b 在控制什么 |
| RRF 用排名而非分数 | 两路分数量纲不可比,直接相加会让 BM25 完全主导 |
| 只用领域词表,不换分词器 | 通用分词器不懂垂直领域,加 19 个词就能解决大部分问题 |
| 重排只处理 Top-20 | 全量 1265 条不可行(CPU 单条 1.8 秒 → 38 分钟) |
| 提示词强制拒答 | 宁可拒答也不编造——这是 RAG 可信度的底线 |
| 生成温度设 0.1 | 问答要稳定复现,不需要创造性 |
模型在 lifespan 里只加载一次 |
写进接口函数会导致每个请求都重新加载模型,单次 40-60 秒,服务等于不可用 |
/search 与 /ask 分离 |
检索免费、生成花钱。分开后前端可以先展示证据,用户确认再触发生成 |
JSON 显式声明 charset=utf-8 |
否则 PowerShell 的 Invoke-RestMethod 会按 Latin-1 解码,中文变 忬éå¢ |
| 演示页的完整文本用 CSS 折叠而非 JS 截断 | 截断后浏览器 Ctrl+F 搜不到,也无法展开查看 |
| 证据文本合并换行 | PDF 提取出的文本一行一个表格单元格,不合并就是「竖排数字粥」,人类读不了 |
| 缓存 key 里绑「检索配置指纹」 | 改了 top_k/模型/权重而忘记清缓存 → 指纹变了 → 自动失效。比"记得手动清"可靠 |
| 重试要区分可重试/不可重试 | 401 重试一百次也一样,只会浪费时间 |
| 重试要加抖动 | 否则所有客户端同时重试(thundering herd),把刚恢复的服务再打挂 |
| 成本计量做成中间件 | 检索逻辑一行不用改 —— 这才叫"可插拔" |
手写检索实现 BaseRetriever |
于是它能和框架的其他组件自由组合(进 LCEL 链、被 Agent 当工具),换框架组件也只改一个对象 |
| 但检索权重不用框架默认值 | 那是我用 32 题评估集调出来的 —— 用框架的工程能力,自己控制检索质量 |
- 语料仅 1 家公司的年报,所以公司名等实体 IDF 偏低,区分度不足
- PDF 表格提取质量差(竖排数字粥),这是当前检索噪声的主要来源,尚未做过滤实验
- 评估集只有 32 条参与检索评估——每答对一题 Hit@5 变动约 3%。 趋势可信,但小数点后两位不要较真
- 口语化改写类只有 0.375,且部分改写仍与原词有重叠, 真实的语义泛化能力可能比这还低
- gold 定义偏窄:锚点只认「含那个数字的块」,主题相关但不含该数字的块不算命中
- 未做分块大小的对照实验(当前固定 512 字)
- 服务与评估结论还没对齐:
/search用的是加权融合 + 重排, 但评估显示 top-5 场景下纯 BM25 更好(0.7188 vs 0.6562)。见「下一步」 - 演示服务无鉴权、无限流、无并发控制,只适合本机演示
- 缓存只在进程内存里:重启就没了。生产环境应该用 Redis(多实例共享 + 持久化)
/metrics是拉取式的,没有接 Prometheus / 告警。目前只能人工看- 成本节约是估算值:
saved_yuan_estimate用平均成本推的, 被缓存的问题本来会花多少无法反事实测量 - LangChain 版只覆盖了应用层:检索仍是我手写的。
LangChain 自带的
BM25Retriever(需要rank_bm25)和FAISS没有接进来对比 - 没有做流式输出:
ChatOpenAI(streaming=True)没启用,首字延迟体验没优化
- 把服务配置对齐到评估结论:给
/search加mode参数 (bm25/fusion/vector),让演示页能现场对比 —— 这是「评估驱动优化」的完整闭环,也是最有说服力的演示 - 验证「表格数字粥」的影响:加一条「数字占比 > 40% 的块直接丢弃」的过滤, 重建索引,用同一套评估集重跑,看 Hit@5 有无提升
- 针对
paraphrase类做专门优化(同义词扩展 / 换更强的 embedding 模型), 并用同一套指标验证是否真的有效 - 评估集扩到 100+ 条,降低单题权重
- 多公司语料(当前只有 1 家,公司名实体的 IDF 失真)
- 缓存换成 Redis —— 支持多实例共享和持久化
- 接 Prometheus —— 把
/metrics的指标接出去,加 P95 延迟 / 降级率告警 - 开流式输出 —— 首字延迟从 1.8 秒降到 0.3 秒级别
- 阶段 7 的评估 —— 38 条评估集 × 7 种配置, 每条优化结论都有数据支撑,而不是"调完感觉变好了"
- 阶段 9 的生产化 —— 成本 / 缓存 / 重试, 以及为什么缓存 key 必须绑配置指纹(一个真实的静默错误陷阱)
- 手写 vs LangChain ——
把检索实现成框架的
BaseRetriever,用框架的工程能力,但自己控制检索质量
| 文档 | 内容 |
|---|---|
| 评估结果 | 完整的评估数据、机制解释与局限 |
| 面试准备 | 项目介绍话术、高频问答、关键数字速查 |
- 代码:MIT
- 语料:上市公司年报,来自巨潮资讯网公开披露信息,仅用于技术验证,不随仓库分发