RAG 不只是问答接口:一次知识库项目里的切分、检索与评测实践

一开始我以为 RAG 就是“上传文档,然后让大模型回答问题”。

真正做 X-RAG Agent 项目后我才发现,影响答案质量的往往不只是模型本身,而是文档怎么切、检索怎么做、证据够不够,以及有没有评测能证明效果真的变好了。

这篇文章不讲复杂理论,只记录我在项目里踩到的几个具体问题。它更像一份面向 RAG 初学者的项目复盘:从文档上传、chunk 切分、向量检索,到证据门禁和评测。

1. RAG 到底在解决什么问题

普通大模型有一个问题:它不一定知道我项目里的私有文档。

比如我问:

X-RAG Agent 支持哪些 RAG 评测指标?

这个答案在我的项目文档里,但大模型本身不一定知道。

RAG 的做法是:

先从知识库里找资料
再把找到的资料交给大模型
让大模型基于资料回答

所以 RAG 的完整流程不是直接问模型,而是:

用户提问
-> 检索知识库
-> 找到相关文本片段
-> 组织成 Prompt
-> 生成回答
-> 返回引用来源

这里有一个很重要的原则:如果知识库里没有依据,就不要硬答。

2. 文档为什么要切成 chunk

知识库文档通常比较长,不能每次都把整篇文档塞给模型。

所以需要先把文档切成很多小片段,这些小片段就叫 chunk。

例如一篇 Markdown 文档可以被切成:

chunk_0001:项目介绍
chunk_0002:系统架构
chunk_0003:接口说明
chunk_0004:评测指标

用户提问时,系统只需要找出最相关的几个 chunk。

这样做有几个好处:

  1. 检索更快。
  2. Prompt 不会太长。
  3. 回答可以追溯到具体来源。
  4. 不相关内容不会全部塞给模型。

我一开始用的是固定长度切分:

每 500 个字符切一段
相邻两段重叠 80 个字符

这个方法简单,但也有问题。

比如一个词:

keyword_hit_rate

可能被切成:

keywor

剩下的部分跑到下一个 chunk 里。

这会导致检索找到了正确文档,但回答里缺少完整关键词。

3. 相邻 chunk 补全解决了什么

固定长度切分会把内容切断。

比如一个表格刚好被切在两个 chunk 中间,检索只命中了前半段,答案就可能不完整。

所以我加了相邻 chunk 补全:

检索命中 chunk N
回答时额外带上 chunk N+1

这样如果答案被切断,后半部分也能进入上下文。

但这个方法不能无限使用。

如果补太多 chunk,会带来几个问题:

Prompt 变长
无关内容变多
模型更容易被干扰
调用成本增加

所以现在只补后一个 chunk,作为一个简单折中。

4. 为什么 Markdown 需要智能切分

Markdown 文档不是普通文本,它有结构。

常见结构包括:

标题
段落
列表
表格
代码块

如果完全按 500 个字符硬切,可能会切断这些结构。

所以我把 Markdown 切分改成了结构感知切分。

大致步骤是:

先识别标题、段落、表格、代码块
把它们拆成 block
再把 block 合并成 chunk
如果单个 block 太长,再兜底切分

这样做的好处是:chunk 更像一个完整的信息单元。

例如标题不会被随便塞到上一个 chunk 的末尾,代码块也尽量不被切开。

5. 表格太长时怎么办

Markdown 表格是我今天重点处理的问题,因为项目文档里有很多接口表、指标表和能力对比表。

短表格比较好处理,可以整体放进一个 chunk。

但如果表格很长,超过了 chunk_size,就必须拆开。

问题是:表格不能像普通段落一样随便按字符切。

例如:

| 指标 | 说明 |
| --- | --- |
| source_hit_rate | 检索结果是否命中预期文档 |
| keyword_hit_rate | 回答是否包含关键字 |

如果直接按字符硬切,可能会出现两类问题。

第一类是切断某一行:

| keyword_hit_rate | 回答是否包含

这一行已经不完整了,模型很难理解。

第二类是丢失表头。

比如某个 chunk 只剩:

| keyword_hit_rate | 回答是否包含关键字 |

模型可能不知道第一列是“指标”,第二列是“说明”。

所以长表格更适合按“行”来切,而不是按字符硬切。

我现在的做法是:

保留表头
保留分隔行
数据行按 chunk_size 分组
每个表格 chunk 都带表头

也就是说,一张长表格可以被拆成这样:

chunk 1:
| 指标 | 说明 |
| --- | --- |
| source_hit_rate | 检索结果是否命中预期文档 |
| keyword_hit_rate | 回答是否包含关键字 |

chunk 2:
| 指标 | 说明 |
| --- | --- |
| avg_latency_ms | 平均响应时间 |
| no_answer_accuracy | 无依据问题是否正确拒答 |

这样做有两个好处:

  1. 每一行数据都是完整的。
  2. 每个 chunk 都有表头,模型能知道每一列是什么意思。

这是一个很小的功能,但对结构化文档问答很重要。

6. 关键词检索和向量检索有什么区别

项目最开始使用的是关键词检索。

关键词检索很好理解:问题里的词是否出现在 chunk 里。

例如用户问:

RAG 评测建议统计哪些指标?

如果某个 chunk 里出现了:

source_hit_rate
keyword_hit_rate
avg_latency_ms
no_answer_accuracy

那它就应该排在前面。

关键词检索的问题是:如果用户表达方式和文档不一样,可能搜不到。

后来我接入了向量检索。

向量检索会先把问题和 chunk 都转换成 Embedding。

Embedding 可以简单理解成:把一段文字变成一组数字,这组数字表示这段文字的语义。

然后系统比较:

用户问题的向量
和
每个 chunk 的向量

哪个更接近,就说明哪个 chunk 更相关。

项目里用 Chroma 保存这些向量。

接入向量检索后,来源命中率明显提升。也就是说,系统更容易找到正确文档。

7. 为什么向量检索也会出错

向量检索不是万能的。

它会返回“最相似”的内容,但最相似不一定代表能回答。

项目里有一个无依据问题:

X-RAG Agent 当前生产环境部署在哪台云服务器 IP 上?

知识库里其实没有这个 IP。

但向量检索可能返回这些内容:

X-RAG Agent 是一个独立 AI 应用服务
X-RAG Agent 可以独立运行
项目二需要有自己的 README

这些内容和“X-RAG Agent”主题相关,但没有回答“云服务器 IP”。

如果系统直接把这些 chunk 当成依据,就会误答。

所以我学到一个很重要的点:

检索结果相关,不等于证据充分。

8. 什么是证据门禁

为了解决上面的问题,我在项目里加了证据门禁。

证据门禁就是在回答前先判断:

当前检索结果是否足够支持这个问题?

我这里做的是一个学习版的证据门禁,主要通过问题中的关键字段和检索结果中的字段是否匹配,来判断证据是否足够。它不是最完美的方案,但能先解决“主题相关却没有答案”的误答问题。

比如问题里包含这些精确信息:

IP
云服务器
生产环境
薪资
内部
公司

那检索结果里也应该真的出现这些词。

如果问题问的是生产环境 IP,但检索结果里完全没有 IP 或生产环境,就应该拒答。

这一步的作用是减少“看起来相关,但其实没有依据”的回答。

项目里加了证据门禁后,无依据拒答准确率提升到了 1.0。

9. sources 为什么不能让模型自己写

RAG 回答通常需要带引用来源。

比如:

来源:ai-internship-requirements.md,第 18 个 chunk

这里有一个坑:来源不能让模型自己生成。

原因很简单,模型可能编造来源。

所以项目里的做法是:

模型只负责回答内容
后端负责返回真实 sources

后端会根据检索结果返回:

chunk_id
doc_id
source
chunk_index
score

这样前端展示的来源才可信。

10. 为什么要做 RAG 评测

如果只手动问几个问题,很难判断系统到底有没有变好。

所以我做了一个简单评测集。

每条评测数据包含:

{
  "question": "RAG 评测建议统计哪些指标?",
  "should_answer": true,
  "expected_keywords": ["source_hit_rate", "keyword_hit_rate"],
  "expected_sources": ["ai-internship-requirements.md"]
}

评测脚本会做几件事:

读取问题
调用 /rag/ask
检查是否应该回答
检查答案是否包含关键词
检查来源是否命中文档
统计整体指标
生成 Markdown 报告

我目前统计的指标有:

case_pass_rate:整体通过率
source_hit_rate:来源命中率
keyword_hit_rate:关键词命中率
no_answer_accuracy:无依据拒答准确率
avg_latency_ms:平均耗时

这些指标可以帮助我判断每次优化有没有效果。

比如从关键词检索升级到向量检索后,来源命中率明显提升。
加证据门禁后,无依据拒答准确率提升。
优化 chunk 切分后,关键词命中率提升。

这比“感觉效果更好了”要可靠很多。

11. 重新解析后为什么要同步更新向量索引

这里需要先说清楚 SQLite 和 Chroma 的分工。

在我的项目里,SQLite 保存的是文档和 chunk 的业务数据。比如文档列表、解析状态、chunk 内容、chunk 顺序、文档删除记录等,都适合放在 SQLite 里管理。

Chroma 保存的是用于向量检索的索引。它会保存 chunk 对应的向量,也会带上一些 metadata,比如 doc_idchunk_indexsource。用户提问时,系统主要靠 Chroma 找到语义上最相关的 chunk。

所以它们虽然都和 chunk 有关,但职责不同:

SQLite:保存资料本身,方便管理知识库
Chroma:保存搜索索引,方便根据问题检索资料

可以把 SQLite 理解成“资料账本”,把 Chroma 理解成“搜索目录”。

问题出现在重新解析文档的时候。

如果我修改了 chunk 切分策略,SQLite 里会生成一批新 chunk。但如果 Chroma 里还保留着旧 chunk 的向量,RAG 检索时就可能搜到旧内容。

这时就会出现一种很隐蔽的问题:

后台管理看到的是新 chunk
向量检索命中的却是旧 chunk

所以重新解析文档后,不能只更新 SQLite,还要同步更新 Chroma。

完整流程应该是:

重新解析文档
删除 Chroma 里的旧向量
把新 chunks 重新做 Embedding
写入新的向量和 metadata

这一步通常就叫“重新构建 Chroma 索引”。它不是重新训练模型,而是把最新的 chunk 重新转换成向量,并更新到向量数据库里。

这样才能保证:

SQLite 管理的是最新资料
Chroma 检索的也是最新资料

12. 我现在对 RAG 的理解

做完今天这些功能后,我对 RAG 的理解变成了:

RAG 不是一个接口,而是一条链路。

这条链路包括:

文档上传
文档解析
chunk 切分
Embedding
向量索引
向量检索
证据判断
Prompt 构造
回答生成
来源引用
无依据拒答
效果评测

其中任何一环出问题,最后答案都可能出问题。

比如:

chunk 切坏了,检索可能找不完整
向量检索太宽松,可能误答无依据问题
sources 交给模型生成,可能出现假引用
没有评测,就不知道优化有没有效果

所以一个比较靠谱的 RAG 项目,不只是“能问答”,还要能解释:

资料从哪里来
为什么检索到这些 chunk
为什么可以回答
为什么有些问题要拒答
优化后指标有没有变化

13. 小结

这次项目让我对 RAG 有了一个更具体的认识。

RAG 的难点不只是“怎么调用模型”,而是怎么把资料处理好、怎么检索到合适内容、怎么判断有没有足够依据、怎么证明系统真的变好了。

如果只看最终回答,很容易忽略中间的问题。

比如:

检索结果可能主题相关,但没有真正答案
chunk 可能把表格或关键词切断
模型可能生成看起来合理但没有依据的回答
来源如果不由后端控制,就可能出现假引用

所以做 RAG 时,我现在会更关注这几个问题:

资料是怎么切的?
检索结果为什么是这些?
回答依据是否足够?
没有依据时能不能拒答?
优化前后有没有评测数据对比?

当我开始关注这些问题时,RAG 在我眼里就不再只是一个“知识库问答接口”了。它更像是一条需要持续调试的工程链路:资料处理、检索、证据判断、生成回答和效果评测,每一步都会影响最终答案。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐