从能跑到能用: 我做 RAG 时踩过的哪些坑

项目仓库:
https://github.com/Withyou1000/rag-vector-search-api
这次练手项目,本质上是一个“公司内部文档问答系统”的实现。它支持上传 PDF / Markdown / txt,先把文档解析、切块(chunk)、向量化,再配合关键词检索,把用户问题转成更适合检索的查询,最后基于命中的上下文生成答案,并返回引用来源。
如果只用一句话概括这套流程,大概就是:
用户提问 -> 查询改写 -> 多路检索 -> 可选 rerank -> 上下文压缩 -> 生成答案 -> 返回 citation -> 记录 retrieval trace
这篇文章不是一篇“RAG 术语大全”,也不是 README 式的功能说明,而更像我这一阶段的学习成长记录:我是怎么从“能搜到东西”这个起点,慢慢走到“为什么这样答、什么时候不该答、答错了怎么定位问题”的。
这次项目,一开始到底想解决什么
我最初的目标其实很朴素:
-
文档能上传
-
问题能回答
-
回答最好带来源
-
查不到的时候别胡说
但真正做起来后,我很快发现,如果只有这条最短链路:
文档切块 -> embedding -> 写入向量库 -> 检索几个 chunk -> 让模型总结
它当然能跑,但离“可用型 RAG”还差得很远。
真实问题会很快冒出来:
-
用户问题太口语,不像检索语言
-
只靠向量检索时,专有名词和关键词命中不稳定
-
页面明明已经命中了 chunk,系统却还说“查不到足够可靠的内容”
-
有时候不是没检索到,而是 LLM 根本没调用成功,只是 fallback 在拼摘要
-
答错的时候,很难判断到底是检索错了,还是生成错了
做到这里我才真正意识到:
RAG 难的不是把一条链路串起来,而是把每一段都做得可解释、可调试、可评测。
这一版项目最后做成了什么
最后落下来的系统,是一个基于下面这套组合的轻量知识库问答项目:
FastAPI + Qdrant + sentence-transformers + OpenAI 兼容 API
它已经具备这些能力:
-
上传
PDF / Markdown / txt -
删除文档、重新索引、同内容强制重建
-
owner / visibility级别的轻量权限隔离 -
vector / fulltext / bm25 / hybrid四种检索模式 -
可选
rerank -
回答带
citation -
查不到时显式
no-answer -
记录每次问答的
retrieval trace -
提供简易 Web 页面
-
带 smoke test、API test 和离线评测脚本
如果从“学习型项目”的角度看,我觉得它已经不再只是一个检索 demo,而是开始长出一点“可用型 RAG 工程雏形”的味道了。
我最大的认知变化:RAG 不是一个搜索接口,而是一套编排系统
这一轮做下来,我最大的变化不是记住了多少术语,而是开始把 RAG 当成一个需要统一编排的系统来看。
以前我的直觉更像是:
先找最相关的几个 chunk,再让模型总结。
现在我的思路更像是:
先想清楚这个问题该怎么搜,再想清楚搜到的东西够不够答,最后才是怎么答,以及答完以后怎么证明自己不是在胡说。
这个变化听起来像一句空话,但它直接决定了后面每个模块怎么设计。
从“只做向量检索”到“混合检索”,是这一轮最关键的一步
一开始我默认把 dense retrieval 当主角,觉得 embedding 足够强,很多问题都能兜住。
真正上手以后我才发现,技术文档、代码说明、专有名词、缩写、命令行参数这些内容,并不总适合只靠向量相似度解决。
于是这一轮我把检索模式扩展成了四种:
-
vector -
fulltext -
bm25 -
hybrid
其中 hybrid 被我作为默认推荐模式,因为它能把这两类能力结合起来:
-
dense retrieval:负责语义召回 -
lexical retrieval:负责关键词和术语命中
这点和我参考的《8种 RAG 检索策略》笔记高度一致:
技术类知识库,几乎天然适合混合检索。
如果只用向量检索,遇到命令、标识符、专有名词时就很容易吃亏;如果只用 BM25,又会丢掉语义理解能力。两边结合,才更像真实系统该有的底盘。
我终于把 Query Rewrite 和 Multi-query 分清了
以前我对这两个概念一直有点混,直觉上总觉得它们都在“改问题”,差别好像不大。真正自己实现以后,我才算把它们分开。
Query Rewrite:把问题改得更适合搜
比如用户问:
这个简历是谁的
系统可能会把它改写成:
简历中的候选人姓名是什么
它解决的是“口语问题不太像检索语言”的问题。
Multi-query:不要只用一种问法去搜
同一个问题,系统可以同时保留:
-
原问题
-
改写问题
-
关键词版问题
例如:
这个简历是谁的简历中的候选人姓名是什么简历 候选人 姓名
这样一起参与检索,召回会更稳。
我现在对它们的记忆方式很简单:
-
Query Rewrite:把一个问题说清楚 -
Multi-query Retrieval:从多个角度一起找
这个区别一旦想通,后面很多策略就顺了。
rerank 真正解决的,不是“有没有命中”,而是“上下文干不干净”
这轮我对 rerank 的理解也变了很多。
最开始我会觉得,只要页面下面已经显示命中了 chunk,问题应该不大。后来才发现:
命中了,不等于排对了。
尤其是 hybrid 这种多路召回模式,前几条候选里经常会混进一些“沾边但不是核心证据”的 chunk。此时如果直接把它们喂给模型,模型就可能被噪声带偏。
所以我现在更愿意把 rerank 理解成:
第一轮检索负责尽量别漏,第二轮重排负责尽量别乱。
当前项目里的 rerank 还是轻量版,主要是基于 query 和 chunk 的重叠度做 lexical boost。它已经能改善一部分排序问题,但也让我更清楚一件事:
如果后面想明显提升质量,接一个真正的 Cross-Encoder Reranker 会非常值。
no-answer 让我第一次认真思考“什么时候不该答”
以前做小 demo 的时候,我默认的思路几乎都是:
只要搜到点东西,就让模型试着回答。
但这一轮真正把系统跑起来以后,我越来越觉得这件事很危险。因为 RAG 最让人头疼的,不是“没答出来”,而是“看起来答出来了,但依据其实不够”。
所以这次我把 no-answer 当成了一个单独策略来处理,而且做成了双层判断:
-
检索层先判断 evidence 强不强
-
生成层再让模型基于上下文判断是否该拒答
中间我还踩到一个特别典型的坑:最开始我把 vector 模式的分数阈值直接套给了 hybrid,结果页面明明有命中,系统却总说“查不到足够可靠的内容”。
后来我才反应过来:
hybrid 里的 RRF 分数口径,和向量相似度根本不是一回事。
它们不能共用一个阈值。
这个问题修掉以后,我对 no-answer 的理解也落地了:
不是越保守越好,而是要理解不同检索模式背后的分数语义。
citation 和命中 chunk 看起来很像,但它们不是一回事
这轮里还有一个我自己也反复琢磨的问题:citation 和命中的 chunk,到底是不是同一回事?
从当前实现上说,它们确实高度重合,因为 citation 目前基本就是从最终送进模型的 context chunks 里提出来的。
但后来我慢慢想清楚了,它们概念上其实属于不同层次:
-
retrieved chunks:候选集 -
final context chunks:最终送进模型的上下文 -
citation:对外展示给用户的答案依据
也就是说,现在它们看起来像,是因为项目还在第一版实现里,citation 还比较粗粒度;但以后如果继续打磨,citation 完全可以继续收缩成真正支撑答案的 1 到 2 条核心证据。
这件事让我明白了一个很重要的点:
成熟 RAG 里的 citation,不是把命中的所有内容都摆出来,而是清楚地告诉用户,这个答案到底依据了什么。
retrieval trace 是这一轮最有“工程味”的部分
如果让我选一个这轮里最不像 demo、最像真实系统的模块,我会选 retrieval trace。
因为它解决的是一个非常实际的问题:
RAG 答错的时候,我怎么知道到底是错在检索,还是错在生成?
所以这次我把很多中间过程都记录了下来:
-
原始问题
-
query rewrite 的结果
-
multi-query 最终用了哪些 query
-
检索模式
-
是否启用 rerank
-
命中的 chunk
-
最终送进模型的 context chunk
-
最终答案
-
是否触发 no-answer
这套 trace 特别有价值,因为它让我第一次不是只盯着“最后答对没答对”,而是可以回头看系统中间到底做了什么。
比如前面那个“明明命中了,却说查不到”的问题,如果没有 trace,页面体验上就只剩困惑;但一旦 trace 在手,问题就能被拆开来看。
我现在越来越认同一句话:
RAG 不难做 demo,难的是知道它什么时候错了,为什么错了。
这次我还踩到了几个特别真实的小坑
1. 配了 key,不等于模型真的调通了
这轮有一个特别真实的问题是:.env 里明明已经配了下面这些配置:
OPENAI_BASE_URLOPENAI_API_KEYOPENAI_MODEL
但前端表现还是像“没调用 AI”,回答更像检索片段拼接。
最后排下来才发现,不是没配,而是兼容接口失败以后静默 fallback 了。
后面我补了更兼容的请求头,比如:
Accept: application/jsonUser-Agent: OpenAI/Python 1.0.0
同时也把最近一次 LLM 错误保留下来,避免以后再出现“看起来像调了,其实没调通”的错觉。
这件事让我对“接 OpenAI 兼容 API”多了一层警惕:
真正要验证的是请求有没有成功、响应能不能解析,而不是环境变量有没有写上。
2. 前端交互也会影响你对系统是否可靠的判断
另一个很朴素但很真实的问题是:点击“开始问答”后没有明显交互反馈,重复点击还会发起多次请求。
这种问题看起来不像算法问题,但它会直接影响用户是否相信这个系统是“稳”的。
所以我后面把这些交互都补上了:
-
按钮忙碌态
-
请求中的禁用态
-
状态提示文案
-
成功和失败的反馈信息
这个改动不大,但非常值。它提醒我:
可用性并不只来自检索质量,也来自细节交互。
3. 能把代码讲清楚,本身也是一次理解升级
这轮还有一个意外收获:我不只是想把功能做完,还希望自己能把关键模块讲清楚。
所以后面我专门把详细注释规则写进了 AGENTS.md,然后把这些关键文件都补成了更适合学习阅读的版本:
app/main.pyapp/retrieval.pyapp/llm.py
这件事反过来也帮我更好地理解了系统。因为当你真的能把下面这条链路讲清楚的时候:
search -> compress_context -> no-answer -> answer -> citation -> trace
你对它的掌握就不再只是“会用”,而是开始接近“会解释、会修改、会继续优化”。
做完这一版以后,我怎么理解“可用型 RAG”
做到这一步以后,我对“可用型 RAG”的理解,已经和一开始很不一样了。
它不只是:
-
有向量库
-
能检索
-
能问答
它至少还应该有:
-
多路检索,而不是单一路径
-
明确的
no-answer策略 -
可追溯的
citation -
能回看过程的
retrieval trace -
基本可落地的评测方法
说到底:
RAG 是一套需要编排、调试、评测、迭代的系统,而不是一个搜索函数。
结合《8种 RAG 检索策略》,这个项目接下来还能怎么优化
下面这部分,是我结合当前实现和《8种 RAG 检索策略》笔记,给自己整理出来的下一阶段方向。不是大而空的 roadmap,而是我认为“继续做下去会真的有价值”的几个点。
1. Parent-child chunk:兼顾检索精度和上下文完整性
当前项目已经做了 chunking,但还是单层 chunk。
它的典型问题是:
-
chunk 太小,回答容易碎
-
chunk 太大,检索容易变钝
更好的做法是:
-
用更小的 child chunk 做检索
-
命中后回溯到 parent chunk 作为最终上下文
这会让系统同时兼顾“搜得准”和“给得全”,对长技术文档尤其有帮助。
2. Sentence window retrieval:比简单截断更自然的上下文补全
当前项目的 Context Compression 还是轻量方案,主要是:
-
取前几个 chunk
-
截断长度
-
去重
它已经能用,但还不够聪明。
后面如果改成 sentence-window 的思路,就可以:
-
先以句子为命中单位
-
命中后自动补前后几句
这会比“固定截 900 个字符”更自然,也更适合细粒度问答。
3. 真正的 Cross-Encoder Reranker:把 rerank 从辅助模块做成提质核心
现在的 rerank 已经有效果,但它本质上还是词项重叠增强,不是真正意义上的 cross-encoder。
后面如果引入真实 reranker,比如:
cross-encoder/ms-marco-MiniLM-L6-v2
它就能真正做到:
-
问题和 chunk 成对打分
-
把“表面相关但其实答非所问”的结果压下去
-
提高最终前几条上下文的质量
这一块我觉得会比继续微调一些简单阈值更值得投入。
4. Self-query Retriever:让 metadata 真正参与检索理解
当前项目已经有 owner / visibility / source / tags 这些过滤维度,但它们更多还是接口层参数。
后面很值得往前再走一步:
-
让系统自动从问题里提取 metadata 条件
-
先缩小文档范围,再做语义检索
比如用户问:
看 alice 上传的共享文档
或者:
找 evaluation 来源里的内容
如果系统能自动识别这些过滤条件,检索体验会更接近真实知识库。
5. Router Retriever:按知识域路由,而不是所有文档一起搜
现在这个项目还是一个统一知识库。
但如果后面文档类型越来越多,比如混入:
-
研发文档
-
制度文档
-
FAQ
-
简历库
-
运维说明
那统一索引就很容易互相干扰。
这时候加一个路由层就很有价值:
-
先判断问题属于哪个知识域
-
再把它送到对应子知识库里检索
这比把所有内容都扔进一个池子里硬搜,更像真实企业知识库的演进方向。
6. Adaptive Retrieval:别让 top_k 和上下文长度一直写死
当前前端里有两个常见参数:
top_kmax_context_chunks
现在它们大多还是固定值,比如都填 5。但不同问题的复杂度差很多:
-
“这个简历是谁的”这种问题,1 到 2 个 chunk 往往就够
-
“结合项目经历和技能栈分析候选人匹配度”这种问题,可能需要更多上下文
所以后面很值得尝试 adaptive retrieval:
-
简单问题少检索、少给上下文
-
复杂问题多召回、多压缩
这样既能省 token,也能让复杂问题答得更完整。
7. 更细粒度 citation:从块级证据走向句级证据
当前 citation 已经可用,但还是块级别的粗粒度引用。
后面如果继续做,我很想把它做成这样:
-
答案中的一句话,对应哪个 chunk
-
甚至对应到 chunk 里的哪一句
这样无论是从产品可信度还是工程可解释性上,都会更进一步。
8. 更扎实的评测:把“感觉更好”变成“指标真的更好”
这一轮我已经补了离线评测脚本,会比较:
-
vector -
bm25 -
hybrid -
hybrid + rerank
并输出:
Recall@kMRR@kNDCG@kNoAnswerAccAnswerWithCitation
但我很清楚,这还只是起点。
后面如果继续做,我最想补的是:
-
更系统的标注集
-
question type 分类
-
失败样本回放
-
no-answer 专项评测
因为真正让系统持续进步的,从来不是“再多加一个听起来很高级的模块”,而是:
有没有稳定的反馈回路。
这一轮结束后,我最大的变化
如果只让我总结一句这次最大的理解变化,我会写:
我以前以为 RAG 的核心是“搜得出来”,现在我更在意它“为什么这样答、什么时候不该答、出了问题能不能定位”。
这也是我觉得这一轮最像“学习成长记录”的地方。因为我不只是多记住了几个概念,比如 BM25、rerank、citation、trace,而是真的在实现、调试和解释的过程中,把这些概念和系统行为一一对应起来了。
最后这版系统当然还远不是终点,它还有很多值得继续打磨的地方。但对我来说,这一轮已经完成了一个很重要的跨越:
我开始不只是会搭一个 RAG,而是在学着把它做成一个能解释、能评测、能继续优化的工程系统。
如果你也在做自己的 RAG 项目,我很推荐你除了关注“模型答得像不像”,也多留一点注意力给这些问题:
-
检索模式怎么组合
-
no-answer 怎么设计
-
citation 怎么展示
-
trace 怎么记录
-
评测怎么落地
这些内容一开始看起来不像“模型答对了”那么显眼,但恰恰是它们,决定了一个 RAG 最后是演示品,还是一个真正开始可用的系统。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)