RAG 系统怎样先修语料再选算法

RAG 系统怎样先修语料再选算法
Asaakii一、搜什么都搜不到
会议纪要库上线那天,我搜什么都搜不到。
不是结果不准,是一条都没有。0 命中。这个库里躺着五份会议记录、十几万字,向量索引建好了,检索接口通了,然后无论我问什么,它都告诉我没有。
如果你还不清楚“向量索引”“embedding”“chunk”这些词,别急,第二节会从零讲起。这一节先跟着故事走,你只需要知道一件事:我建了一个号称能按“意思”来搜索的知识库,结果它一条都搜不出来。
我的第一反应是模型不行,大概是 embedding 对口语化的文本不敏感吧?我甚至开始琢磨要不要换个模型、要不要试试混合检索、要不要把 MQE 和 HyDE 加上。毕竟这些是我刚学完的东西,正手痒。
后来我做了一件当时觉得很笨、现在觉得最该先做的事:我打开了源文件。
里面是这样的:
1 | [发言人1] 00:03:12 嗯,那个,我们先说一下这个情况啊 |
说话人标签、时间戳、「嗯」「那个」「哈哈哈」,短句碎成一行一行,整份转写是一个两千字的单一 chunk。
向量模型看到的是一坨噪音。它当然什么都匹配不上:问题从头到尾都不在检索这一侧。
这件事之后我回头复盘,发现一个挺让我意外的规律:在这个项目的三个知识库案例里,最大的检索质量提升都来自修文档本身,而不是换算法或调参数。我测过的一项混合检索优化在当前语料与实现下没有带来额外收益,但这不代表它在其他数据集里也无效。
这篇就是这三次「修文档」的记录。
初学者先记住两条分工
- SQL 擅长回答确定的结构化事实,例如“2024 年 GDP 增速是多少”;
- RAG 擅长找解释性材料,例如“这项政策为什么这样规定、报告如何描述某个问题”。
后文先解释常见概念,再给出三个排障案例。案例里的分数来自当前系统的检索服务,只用于定位问题,不能直接当成不同 RAG 系统之间的通用排名。
关于曾出现在标题中的 0.815:它是“表结构库”中一个典型自然语言查询在加入索引文档后得到的语义相关性分,不是 Recall、准确率或全库平均成绩。本文没有做完整的离线评测,因此保留它作为案例证据,而不把它包装成通用性能指标。
二、先补地基:几个绕不开的词
这一节把后面反复出现的词从零讲清楚。如果你已经熟悉 embedding、向量检索、分块、rerank,可以直接跳到第三节;如果不熟,这几段是理解全文的钥匙。尤其是开头那句“向量模型看到的是一坨噪音”,读完这节你就会明白它到底在说什么。
2.1 embedding:把一句话变成一串坐标
计算机不认识文字,只认识数字。embedding(嵌入)模型做的事,就是把一段文本变成一串固定长度的数字,比如 1024 个小数,写出来像 [0.02, -0.31, 0.88, ...]。你可以把这串数字理解成这段文本在一个高维空间里的坐标。
这个空间有一个关键性质:语义相近的文本,坐标离得近。“小猫”和“猫咪”会挨得很近,“小猫”和“利率”会离得很远。这就是“语义空间”这个说法的由来,模型比的不是字面,而是“意思”。RAG 能按“意思”而不是“关键词”找东西,全靠这一步。
现在回头看开头那份会议转写:
1 | [发言人1] 00:03:12 嗯,那个,我们先说一下这个情况啊 |
当 embedding 模型给整整两千字这样的内容算一个坐标时,“发言人1”“00:03:12”“嗯”“那个”“哈哈哈”这些没有信息量的噪音,会和真正重要的那一两句业务内容,被平均进同一串坐标里。结果这段文本的坐标既不靠近任何一个正常问题,也不代表任何清晰的语义,落在空间里一个谁都够不着的角落。这就是“一坨噪音”的字面含义:不是模型不行,是你喂给它的那串坐标本身就是糊的。
2.2 向量检索:靠“离得近”找东西
有了坐标,检索就很直白了:把用户的问题也用同一个 embedding 模型算成坐标,然后在库里找坐标离它最近的那些片段。“最近”通常用余弦相似度来衡量,你只要知道它输出一个 0 到 1 之间的分数、越大越像就够了。全文那些 0.67、0.75、0.815,就是这么来的,它们是“问题坐标”和“片段坐标”的接近程度。
检索时一般还会设两个旋钮:
- top_k:只返回最接近的前 k 个片段(比如前 5 个),因为你不可能把全库都塞给模型。
- score_threshold(分数阈值):低于某个分数就算“不够像”,直接丢掉。
于是“0 命中”这件事就有了精确的含义:不是库是空的,而是库里没有任何一个片段的相似度分数,高到能越过那道阈值。开头那个会议库,正因为所有片段的坐标都是糊的,没有一个能跟问题足够接近,才会“搜什么都搜不到”。
2.3 分块(chunking):你检索的不是“文档”,是“片段”
还有一个容易被忽略、却贯穿全文的前提:embedding 和检索都不是以整份文档为单位,而是以“片段”(chunk)为单位。上传文档时,系统先把它切成一段一段,分别算坐标、分别入库;检索时命中的也是某一段,而不是整份文件。
为什么要切?因为一份文档往往讲很多事,整份算一个坐标,等于把所有话题平均成一个模糊的点(又回到了“一坨噪音”)。切成片段后,每段主题更集中,坐标更干净,才搜得准。
但怎么切很讲究,这正是第五节整节要展开的:
- 切得太大,一个片段里塞了十个话题,你问其中一个,它甩给你一大段,相关内容被稀释;
- 切得太小,一句话被拦腰截断,丢了上下文,模型看不懂。
比较理想的做法是顺着文档本来的结构切,按章节、按小标题。记住这一点:第五节那三个“修文档”的案例,本质上都是在帮系统找到正确的切割点。
2.4 两个后备选手:BM25 与 rerank
还有两个词会在第六节出现,先打个照面:
- BM25 是一种关键词检索算法。它不管语义,只看“你问的词有没有原样出现在文档里、出现了几次”,是搜索引擎用了几十年的经典方法。它和向量检索是两条不同的思路:向量懂“意思相近”,BM25 懂“字面命中”。把两者的结果合起来,就是常说的混合检索(hybrid search)。
- rerank(重排序 / 精排)是检索的“第二道关”。向量检索快但粗,先捞回 top_k 个候选;再用一个更精细、也更慢的 rerank 模型,把这 k 个候选和问题逐一放在一起重新打分、重新排序。它更准,但因为要一对一比较,只适合用在少量候选上。
这里先埋一个第六节会踩的坑:rerank 打出来的分,和 embedding 的相似度分根本不是一回事,尺度差很多,6.2 节会专门说这个。
2.5 为什么需要记忆和 RAG
LLM 有两个根本局限:
- 无状态导致遗忘:每次调用都是独立计算,模型不会自动记住上一次对话。长对话里早期信息会因上下文窗口丢失,也无法跨会话保留用户偏好。
- 内置知识静态且有限:训练数据有截止点,垂直领域深度不足,还容易产生幻觉。
记忆解决第一个,RAG 解决第二个。
2.6 分层记忆
记忆系统的设计通常借鉴认知心理学对人类记忆的分层:
- 工作记忆:当前会话的临时信息,容量有限(比如 50 条)+ TTL(存活时间,到点自动删)过期,纯内存,重启即丢,这正符合它的定位;
- 情景记忆:具体事件和经历,带时间序列,通常 SQLite(结构化查询)+ 向量库(语义检索)混合存储;
- 语义记忆:一般性知识和概念,比如“公司财年从 4 月开始”这类不依附于某次具体对话的事实;
- 程序性记忆:技能和习惯,比如“生成报告时固定先出摘要再出图表”这类固化下来的流程。
工作记忆的检索通常是混合评分,比如:
1 | base_relevance = vector_score * 0.7 + keyword_score * 0.3 |
也就是 (相似度 × 时间衰减) × (0.8 + 重要性 × 0.4),把语义、时效、重要性三者加权到一起。
2.7 高级检索策略
两个最常被提到的技巧:
多查询扩展(MQE):同一个问题有多种表述,用 LLM 把原始查询扩展成多个语义等价的查询并行检索,再合并去重,提高召回。
假设文档嵌入(HyDE):核心是「用答案找答案」。问题是疑问句,文档是陈述句,两者在语义空间里分布本就有差异。HyDE 先让 LLM 生成一段假设性的答案,再拿这段答案去检索真实文档,从而缩小 query 和 document 的语义鸿沟。
1 | def _prompt_hyde(query: str) -> Optional[str]: |
这两个策略都很聪明。记住 HyDE 解决的问题,也就是查询和文档之间的语义鸿沟,后面会回来找它。
三、落地第一课:先分清什么根本不该检索
我最早犯的错误,是想把所有东西都塞进向量库。
指标数据不该走向量检索。「2024 年 GDP 增速是多少」这种问题,答案是数据库里一个确定的数字。用向量检索去找它,你会得到一堆「语义上很像」的片段,然后指望模型从里面读出正确的数。这既慢又不准,而且错了都不知道。
所以系统里定了一条分工原则:
硬数据走 SQL,软知识走 RAG。
flowchart TB
Q["分析任务"] --> Split{"要的是什么"}
Split -->|"是多少<br/>(数字型事实)"| SQL["关系库 · SQL 精确查询<br/>结构化指标 / 企业台账 / 项目池"]
Split -->|"为什么、依据是什么<br/>(解释性材料)"| RAG["向量库 · 语义检索<br/>政策文件 / 规划纲要 / 报告 / 纪要"]
SQL --> M["分析节点:融合"]
RAG --> M
M --> R["有据可查的结论"]
分析节点做的事,是把两边的结果融合:硬数据提供「是多少」,软知识提供「为什么、依据是什么」,合起来才是一个有据可查的结论。
这条看起来朴素,但它是后面一切的前提。RAG 不是万能的信息入口,它只适合「答案是一段话」的问题,不适合「答案是一个数」的问题。想清楚这个边界,比调任何参数都重要。
四、落地第二课:元数据过滤比高级策略更实用
真正让检索变准的第一个手段,不是 MQE 也不是 HyDE,而是给文档打标签。
我给知识库建了一套四维元数据:
| 字段 | 取值示例 | 用途 |
|---|---|---|
domain |
综合、数字经济、产业发展、农业农村、社会治理、财政金融…… | 按业务领域过滤 |
data_type |
规划文件、政策文件、政府工作报告、统计公报、经济分析 | 按文档类型过滤 |
data_period |
2024、2025、十四五 | 按时间周期过滤 |
source_level |
国家级、省级、市级、县级 | 按行政层级过滤 |
核心库的标签覆盖率做到了 100%。检索时在向量召回的基础上叠加元数据筛选:
| 业务场景 | 过滤条件 |
|---|---|
| 数字经济专题检索 | domain = "数字经济" |
| 查找十四五规划文件 | data_type = "规划文件" AND data_period = "十四五" |
| 检索某年政府工作报告 | data_type = "政府工作报告" AND data_period = "2025" |
| 省级产业政策 | domain = "产业发展" AND source_level = "省级" |
为什么这个比 MQE/HyDE 管用?因为它砍掉的是「根本不该进候选集的东西」。当你要找「十四五规划怎么说数字经济」时,2019 年的一份工作简报无论语义多像,都是噪音。语义相似度分辨不了这个,但一个 data_period 字段可以。
而且它便宜:过滤是数据库层面的事,零 LLM 调用、零额外延迟。MQE 要多调几次 LLM 生成扩展查询,HyDE 每次查询都要先生成一段假设文档,在一个要跑几十个节点的业务链里,这些开销是会累积的。
五、落地第三课:真正的瓶颈在文档结构
这是最反直觉、也是收益最大的一课。三个真实案例。
5.1 政府工作报告:没有标题,分块就退化成硬切
项目主要用层级分块(hierarchical chunking):识别 markdown 的 ##、### 作为分段点,在标题之间的内容建立独立向量索引,同时保留父子关系。
问题来了:政府工作报告的原始文件里,章节标记是中文的,「一、」「二、」「(一)」,它们不是 markdown heading。于是层级分块识别不到任何分段点,直接退化成按 max_tokens 硬切,产生 12~13 个约 2000 字的巨型片段。
后果是检索时返回的片段又大又杂,相关性被严重稀释:你问一个具体问题,它甩给你 2000 字,里面只有两句相关。
解法一点也不高级:写个预处理脚本,用正则识别中文章节标记(^[一二三四五六七八九十]+、、^——我们、^五年来,我们 这类模式),插入对应的 ## / ###:
1 | 处理前:12 段,平均 1,855 字 |
另一份报告是 13 段(均 1767 字)→ 30 段(均 639 字)。
没有动任何检索参数,只是给文档加了标题。
5.2 会议纪要:把语料重建一遍
就是开头那个 0 命中的库。诊断清楚之后,重建流程一点也不高级:
flowchart LR A["原始转写<br/>说话人标签 / 时间戳<br/>填充词 / 短句碎片"] --> B["正则清洗<br/>合并短行成段落"] B --> C["按段落分块<br/>目标 800 字 / 上限 1200"] C --> D["提取高频关键词<br/>排除停用词"] D --> E["构建 markdown<br/>带 ## 标题 + 关键词标注"] E --> F["层级分块重新上传"] F --> G["补 domain 元数据"] G --> H["验证检索质量"]
1 | 重建前:0 命中 |
七个步骤里,没有一步碰到检索算法,全是在收拾语料。
从「完全不可用」到「可用」,靠的是数据清洗。
5.3 表结构库:用索引文档做语义桥接(这里和 HyDE 撞上了)
这个案例最有意思。
知识库里有 200 多份数据库表结构文档,每份描述一张表的表名、中文说明、字段列表和示例 SQL。单份文档自己的语义匹配还行(0.55~0.74),但用户是拿自然语言提问的,比如「某县的 GDP 数据在哪个表」。
这句话和一份技术性的表结构描述之间,语义距离非常远。结果就是 0 命中。
看到这里,学过 HyDE 的人应该有反应了:这不正是 HyDE 要解决的那个问题吗,查询(自然语言问句)和文档(技术性陈述)之间的语义鸿沟。HyDE 的解法是让 LLM 现场生成一段假设的技术性答案,再拿去检索。
我的解法是另一个方向:预先写好 5 个「表目录索引」文档,按业务域分组,每个索引文档包含该领域所有相关表名、中文说明、典型查询场景,以及「常见问题 → 表名」的映射。
| 索引文档 | 覆盖领域 |
|---|---|
| 表目录-经济GDP人口 | GDP、地区生产总值、人口、城镇化率 |
| 表目录-工业企业科技 | 工业、规上企业、高新技术企业、专精特新 |
| 表目录-农业财政投资 | 农业、粮食、财政收支、固定资产投资 |
| 表目录-商贸金融民生 | 消费、进出口、存贷款、教育、医疗、就业 |
| 表目录-县域综合速查 | 县域各类表速查 |
这些索引文档本身就是用自然语言写的,天然离用户的问法很近;它们又包含技术表名,于是成了一座桥。
1 | 加索引前:0 命中 |
同一个问题,HyDE 用「运行时让模型生成假文档」来搭桥,我用「预先写好真索引文档」来搭桥。后者的好处很直接:一次性成本、零运行时开销、完全确定,而且内容是我写的,不会瞎编。HyDE 每查询一次就要多一次 LLM 调用,还得赌它生成的假设文档没跑偏。
这不是说 HyDE 不好。在你没法预先组织语料的场景里(比如面对用户随手上传的文档),HyDE 依然是好办法。但当语料是你自己的、可以治理的,与其让模型每次现场猜,不如把桥修好放在那里。
六、落地第四课:被实测推翻的两个直觉
6.1 混合检索:收益为零
「语义检索 + BM25 关键词的混合检索效果更好」几乎是 RAG 的常识。我也这么以为,于是测了。
结论:在中文语料加当前向量库的环境下,混合检索与纯语义检索的结果完全一致,BM25 关键词索引没有提供任何额外增益。
所以最终采用纯语义检索 + 重排序的两阶段策略:
- 第一阶段:语义向量检索,召回 top_k 候选;
- 第二阶段:rerank 模型精排。
我没有深挖 BM25 为什么没生效(可能和中文分词、向量库的实现细节有关)。但这件事教会我的是:别把别人环境里的结论当成自己环境里的事实。测一下的成本,比带着一个错误假设优化半年低得多。
6.2 分数阈值:两个分数不是一回事
一个很容易踩的坑:启用 rerank 之后,不能再沿用原来的 score_threshold。
因为两个分数的尺度根本不同:
- embedding 相似度分数通常落在 0.5 ~ 0.9;
- rerank 相关性分数落在 0.15 ~ 0.95。
如果你按 embedding 的经验设了个 0.5 的阈值,rerank 之后一大批本来相关的结果会被直接砍掉,而且你不会收到任何报错,只会觉得「怎么召回变差了」。
最后的做法是:启用 rerank 时干脆关掉分数阈值,由 top_k 控制返回数量。
这类坑的共同点是,它们不会让系统崩溃,只会让它悄悄变差。而这恰恰是最难查的一类问题。
七、记忆这一侧:我没有做四层记忆
说完检索,说记忆。这里我要诚实一点:
教科书那套「工作记忆 / 情景记忆 / 语义记忆 / 程序性记忆」的分层,我一层都没实现。
不是因为它不好,而是因为我的系统不需要。它不是一个陪你聊天的助手,而是一个跑分析任务的流水线:一次任务进来,走完一条业务链,产出报告。任务之间基本没有「记住你上次说过什么」的需求。
真正承担「记忆」职责的是另外两个东西。
第一个是状态黑板,也就是会话内的工作记忆。一次任务的全部中间产物都挂在一个结构化状态对象上,节点之间靠它接力。这本质上就是工作记忆,任务结束即释放,不需要 TTL,因为任务本身就有生命周期。
第二个是证据与缺口,可追溯的长期记忆。状态里有一块专门记证据:证据卡、来源,以及最重要的证据缺口。
而且工具的返回值里,「边界」是一等字段:
1 | return { |
does_not_support 这个字段我很喜欢。一般的检索工具只告诉你「我找到了什么」,而它还会告诉你「基于这些材料,哪些结论是不能下的」。
这跟第三节那条分工原则是一脉相承的:RAG 检索回来的是「依据」,不是「结论」。材料能支撑到哪一步,必须说清楚。在一个产出会影响真实决策的系统里,「检索到了什么」和「这些东西能证明什么」是两件必须分开的事。
所以我的结论是:记忆的形态应该由任务形态决定,而不是照着认知科学的分类去凑。认知科学那套分层解释了「为什么需要不同的记忆」,但它不是一张必须照抄的施工图。先问「我的任务真的需要跨会话记住东西吗」,再决定要不要建。
八、几点总结
在我的案例里,RAG 的第一瓶颈是语料而不是算法。三次最明显的改善(0 命中救回 0.75、典型查询提升到 0.815、片段从 1855 字降到 720 字)全部来自修文档:加标题、清洗噪音、补索引。算法层的混合检索在当前实现里没有额外收益。结论不是“算法不重要”,而是在调 top_k 之前,先打开源文件看一眼。
检索质量的顺序是:分对类 > 打标签 > 修结构 > 换算法。先分清什么该走 SQL、什么该走 RAG;再用元数据把不该进候选集的砍掉;再把文档结构修好;最后才轮到检索策略。我是倒着做的,走了弯路。
能预先组织的,就别让模型现场猜。HyDE 和「表目录索引」解决的是同一个语义鸿沟,前者靠模型运行时生成假文档,后者靠人预先写好真索引。当语料可治理时,后者更便宜、更确定、也不会瞎编。这跟我在别处得到的结论是同一个:确定性的东西,尽量别交给模型即兴发挥。
别把别人的结论当自己的事实。混合检索更好、分数阈值设 0.5,这些「常识」在我的环境里一个都不成立。而且它们失效时不报错,只是悄悄变差。测一下很便宜。
架构要照着任务长,不要照着理论长。四层记忆很优美,但我的任务是流水线不是聊天,硬套只会增加没人用的代码。理论的价值是让你理解「为什么会有这些设计」,不是给你一张必须填满的表格。
回过头看,这一章我学到的最有用的东西,可能不是 MQE 或 HyDE 的实现,而是知道了它们在解决什么问题。正因为知道 HyDE 是在补语义鸿沟,我才能在遇到同一个问题时认出它,然后选一个更适合我这个场景的解法。
理解原理的价值,往往不是让你用上它,而是让你认得出问题。
参考资料











