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 最后是演示品,还是一个真正开始可用的系统。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐