Skip to content

About

不依赖 LangChain 从零手写的 RAG 系统:混合检索 + 重排 + 引用溯源 + 38 条评估集,含 4 次失败归因

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

14 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

从零实现的 RAG 检索系统

不依赖 LangChain / LlamaIndex,从零手写检索增强生成全链路。 面向石油石化领域年报文档问答,重点是失败归因与评估驱动的优化。

python chunks progress


30 秒看懂

项 结果
语料 中国石油年报 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 次失败归因

八个核心发现(详见「实验与发现」与「评估结果」)

  1. 整份文档不能直接当向量——会被静默截断
  2. 纯向量分不清「答案」和「看起来像答案的东西」,分差仅 0.02
  3. BM25 能补上,但中文必须加领域词表
  4. 样本量决定结论:同一套脚本,5 条样本 vs 32 条样本,四个配置的优劣全部翻转
  5. 融合不总是正收益:纯 BM25 在事实型问题上 Hit@5 达 0.889,融合后反而降到 0.778
  6. 口语化改写是真实短板:8 条改写问题,所有配置都过不了 0.4
  7. 三次「看起来是 A,其实是 B」——归因比调参重要
  8. 语料质量是天花板:PDF 表格提取会产出**「竖排数字粥」**

这是什么

输入企业年报 PDF,能对里面的内容提问,输出带引用来源、可溯源、不确定时拒答的答案。

问题
  ↓
┌──────────────── 检索 ────────────────┐
│  向量检索 Top-50 ┐                    │
│                  ├─ RRF 融合 → Top-20 │
│  BM25   Top-50 ┘                      │
└───────────────────────────────────────┘
  ↓
cross-encoder 重排 → Top-5
  ↓
拼装带编号的参考资料 → 调用 LLM → 答案 + [1][2] 引用

手写 vs LangChain:我怎么选

市面上 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 能补上,但中文分词是前提

手写 BM25(倒排索引 + 正 IDF)后,正确答案从第 16 名升到第 4 名。

但 jieba 把「营业收入」切成「营业/收入」、「中国石油」切成「中国/石油」, 两个最有区分度的词都废掉了。

加入 19 个石油财务领域词后:

词 出现在多少块 IDF
营业(被切碎) 45 3.326
营业收入(加词后) 62 3.008
的 1095 0.145

排名随之升到第 2。

④ IDF 是相对的,不是词的固有属性

中国石油 在当前语料里 IDF 只有 0.794——因为它出现在 45% 的块里。

因为语料只有它一家的年报,公司名到处都是。 语料换成 30 家公司后,它自然会变成稀有词。

结论:IDF 衡量的是「这个词在你的语料库里有多稀有」,而不是词的固有重要性。

⑤ RRF 融合成功:16 → 2 → 1

阶段 目标块排名
纯向量 16
BM25 2
RRF 融合 1

为什么 RRF 用排名而不用分数:向量分数是余弦相似度(0~1),BM25 分数无上界(这里到 11), 两者量纲完全不同,相加毫无意义。

RRF 公式:score(doc) = Σ 1 / (k + rank),k=60。 它只看"排第几",因此天然免疫量纲问题。

核心性质:一路的第 1 名,比不过两路都还不错的选手。

⑥ 工程优化:numpy 矩阵化,提速 325 倍

实现 检索 1265 块耗时
纯 Python 循环 156.4 ms
numpy 矩阵乘法 0.48 ms
提速 325 倍

原因:Python 循环慢的不是"算得慢",而是逐元素的对象创建和解释器开销。 vecs @ q 一次算完所有相似度,走 C 循环 + SIMD。

这是工程优化而非算法优化——还是暴力算全部,只是让同样的计算跑得更快。

⑦ 三次「看起来是 A,其实是 B」⭐

表面现象 查证后的真因
纯向量检索不到答案 不是模型不行,是排序问题(答案排第 16)
BM25 效果不理想 不是算法问题,是 jieba 把「营业收入」切碎了
重排把正确答案从第 1 打到第 7 不是重排坏了,是被排到前面那块确实高度相关——它是财务报表里的「营业收入附注」,只是不含我们指定的那个总数

任何「变差了」都要先归因再动手。 用单个样本判断优化好坏是不可靠的——这正是需要评估集的原因。

⑧ 语料的真实质量问题:PDF 表格提取

page.get_text("text") 不理解表格结构,把财务表格压成竖排数字粥:

505
-
1,285,752
-
-
1,350,257
合计
151,150
336,861

表头与数据行被冲散、表格被切块拦腰砍断。 这是「数字碎片污染检索」的真正源头——不是模型笨,是语料本身就是坏的。

待办:改用 PyMuPDF 的表格提取 API,或用「数字占比」过滤这类块。 但需要先有评估集,才能验证改动是否真的有效。

⑨ 拒答机制:最容易被忽略的一环

提示词写死三条规则:只用给定资料、必须标引用、没有就明说「无法回答」。

实测 4 个问题:

问题 结果
营业收入是多少 ✅ 答对 + 引用可溯源
净利润是多少 ✅ 答对 + 引用可溯源(经逐字核对)
西南石油大学的校训 ✅ 正确拒答
量子纠缠的贝尔不等式 ✅ 正确拒答

关键认知:拒答率高不是模型问题,是检索问题—— 大模型只能看到检索交给它的 5 个块,漏了就只剩拒答或编造两个选项。

⑩ 引用是双刃剑

引用机制让答案可核查,但也给幻觉背书—— 一个格式规范、带出处、精确到小数点后两位的假答案,比裸的胡编更容易骗过人。

所以引用必须可验证:答案里的数字,要能在被引用的块里逐字找到。 这个检查应该自动化(阶段 7)。


评估结果(阶段 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

按问题类型细分(Hit@5 / MRR)⭐

配置 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

三条结论

  1. 纯 BM25 在事实型问题上碾压一切(Hit@5 0.889)。 把只有 0.278 的向量检索融合进来,是负收益——降到 0.778。

  2. 口语化改写全体崩盘(最高 0.375,向量最低 0.125)。 注意:向量在改写问题上比在事实问题上还差(0.278 → 0.125), 这与"向量擅长同义泛化"的教科书直觉相反。

  3. 没有任何配置全面胜出: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

生产化:成本 / 缓存 / 重试(阶段 9)

一个能跑的 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。 没有这个数字,任何"优化"都是凭感觉。

⚠️ 价格不硬编码在逻辑里 —— 放在常量 + 支持环境变量覆盖, 因为大模型定价变动频繁,而且不同渠道(官方 API / 云厂商代理 / 包年)能差好几倍。

⚠️ /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 变成能用的内部工具"。

1. 权限:文档不是所有人能看

现在任何人问任何问题,检索都是全库的。公司内部不行 —— 财务制度、人事档案、未公开的产品规划,不能给所有人看。

做法:

  • 入库时给每个 chunk 打 ACL 标签(部门 / 密级 / 项目组)
  • 检索时先按用户身份过滤候选集,再算相似度
  • ⚠️ 不能只在生成层过滤 —— 那样答案里可能已经泄漏了片段

⭐ 这是最容易出错的地方:过滤必须发生在检索之前,而不是之后。

2. 增量更新:文档每天都在变

现在是"离线构建索引 → 重启服务"。公司里这样不行(每天重启一次?)。

做法:

  • 指纹去重:文档 → hash,没变就跳过(省掉重复 embedding)
  • 增量索引:新增/修改的 chunk 追加,删除的做软删除标记
  • 定时重建:每周全量重建一次,清掉累积的碎片
  • 版本化:索引带版本号,出问题能回滚(和上面的"配置指纹"是同一思路)

3. 反馈回流:这是提升效果最有效的手段

用户点"这个答案有用/没用",是最便宜的标注 —— 比找人标评估集便宜一百倍。

做法:

  • 每次问答记录 (问题, 检索到的 chunk, 生成的答案, 用户反馈)
  • 离线分析:把"没用"的 case 抽出来,看是检索错了还是生成错了
  • 回流成评估集:真实 case 比编出来的评估题有价值得多

⭐ 这正好接上我项目的强项:我这套评估体系(Hit@K / MRR / 按类型细分) 可以直接用在线上数据上 —— 不用重新造轮子。

4. 成本估算(真算一遍)

按实测数字:单次问答 ¥0.003(输入 1396 token / 输出 29 token)。

场景 每天调用 每天成本 每月成本
200 人,人均每天问 5 次 1,000 ¥3 ¥90
200 人,人均每天问 20 次 4,000 ¥12 ¥360
加上重排和 embedding 的算力 — 另计 视部署方式

⭐ 结论:这个量级的成本完全可以接受。 加上缓存(实测命中率 50%)还能再砍一半。 ⇒ 真正的成本瓶颈不是 API,是 embedding/重排模型的 GPU 资源。

5. 可观测性:三个必须有的指标

指标 看什么 阈值告警
P95 延迟 用户体验 > 10s 告警
缓存命中率 成本是否受控 < 20% 说明问题太分散,或缓存策略有问题
降级次数 模型服务健康度 > 1% 说明上游不稳

这些在 /metrics 里已经埋好点了 —— 接一个 Prometheus 就能用。

6. 还需要补的

  • 鉴权:现在服务无鉴权,内部也要接 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 忽略)

快速开始

1. 环境

python -m venv .venv
.venv\Scripts\Activate.ps1          # Windows
pip install -r requirements.txt
python src/check_env.py             # 自检

想要 GPU 加速(可选,但强烈建议)

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

瓶颈是单连接限速,不是总带宽 —— 这个结论值得记住。

2. 准备语料

把年报 PDF 放进 data/raw/(可从巨潮资讯网下载),然后:

python src/stage2_extract.py        # PDF → 文本
python src/stage2_chunk.py          # 文本 → 分块
python src/stage3_build_index.py    # 建索引(跑一次)

3. 检索

python src/stage3_search.py         # 向量检索 + 速度对比
python src/stage4_bm25.py           # BM25 检索
python src/stage5_fusion.py         # 混合检索 + 重排

4. 生成(需要 API Key)

在项目根目录建 .env:

DEEPSEEK_API_KEY=sk-xxxxxxxx

然后:

python src/stage6_generate.py       # 生成带引用的答案

⚠️ .env 已在 .gitignore 中,密钥不会被提交。

5. 启动服务(推荐用这个演示)

uvicorn stage8_api:app --app-dir src --host 127.0.0.1 --port 8000

然后打开 http://127.0.0.1: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             // 重试过几次
  }
}

6. 生产化中间件自检(不花钱、不用 API Key)

python src/stage9_production.py

会验证:token 计量、缓存 LRU/TTL/配置指纹失效、重试/降级/不可重试区分。

7. LangChain 对照版(需要额外装 langchain)

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 秒级别

⭐ 如果只能看三件事

  1. 阶段 7 的评估 —— 38 条评估集 × 7 种配置, 每条优化结论都有数据支撑,而不是"调完感觉变好了"
  2. 阶段 9 的生产化 —— 成本 / 缓存 / 重试, 以及为什么缓存 key 必须绑配置指纹(一个真实的静默错误陷阱)
  3. 手写 vs LangChain —— 把检索实现成框架的 BaseRetriever,用框架的工程能力,但自己控制检索质量

项目文档

文档 内容
评估结果 完整的评估数据、机制解释与局限
面试准备 项目介绍话术、高频问答、关键数字速查

许可与数据来源

  • 代码:MIT
  • 语料:上市公司年报,来自巨潮资讯网公开披露信息,仅用于技术验证,不随仓库分发

About

不依赖 LangChain 从零手写的 RAG 系统:混合检索 + 重排 + 引用溯源 + 38 条评估集,含 4 次失败归因

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages