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

一、搜什么都搜不到

会议纪要库上线那天,我搜什么都搜不到。

不是结果不准,是一条都没有。0 命中。这个库里躺着五份会议记录、十几万字,向量索引建好了,检索接口通了,然后无论我问什么,它都告诉我没有。

如果你还不清楚“向量索引”“embedding”“chunk”这些词,别急,第二节会从零讲起。这一节先跟着故事走,你只需要知道一件事:我建了一个号称能按“意思”来搜索的知识库,结果它一条都搜不出来。

我的第一反应是模型不行,大概是 embedding 对口语化的文本不敏感吧?我甚至开始琢磨要不要换个模型、要不要试试混合检索、要不要把 MQE 和 HyDE 加上。毕竟这些是我刚学完的东西,正手痒。

后来我做了一件当时觉得很笨、现在觉得最该先做的事:我打开了源文件。

里面是这样的:

1
2
3
4
[发言人1] 00:03:12  嗯,那个,我们先说一下这个情况啊
[发言人2] 00:03:15 哈哈哈
[发言人1] 00:03:16 就是
[发言人1] 00:03:19 这个数据吧,它是这样的

说话人标签、时间戳、「嗯」「那个」「哈哈哈」,短句碎成一行一行,整份转写是一个两千字的单一 chunk。

向量模型看到的是一坨噪音。它当然什么都匹配不上:问题从头到尾都不在检索这一侧。

这件事之后我回头复盘,发现一个挺让我意外的规律:在这个项目的三个知识库案例里,最大的检索质量提升都来自修文档本身,而不是换算法或调参数。我测过的一项混合检索优化在当前语料与实现下没有带来额外收益,但这不代表它在其他数据集里也无效。

这篇就是这三次「修文档」的记录。

初学者先记住两条分工

  1. SQL 擅长回答确定的结构化事实,例如“2024 年 GDP 增速是多少”;
  2. RAG 擅长找解释性材料,例如“这项政策为什么这样规定、报告如何描述某个问题”。

后文先解释常见概念,再给出三个排障案例。案例里的分数来自当前系统的检索服务,只用于定位问题,不能直接当成不同 RAG 系统之间的通用排名。

关于曾出现在标题中的 0.815:它是“表结构库”中一个典型自然语言查询在加入索引文档后得到的语义相关性分,不是 Recall、准确率或全库平均成绩。本文没有做完整的离线评测,因此保留它作为案例证据,而不把它包装成通用性能指标。

二、先补地基:几个绕不开的词

这一节把后面反复出现的词从零讲清楚。如果你已经熟悉 embedding、向量检索、分块、rerank,可以直接跳到第三节;如果不熟,这几段是理解全文的钥匙。尤其是开头那句“向量模型看到的是一坨噪音”,读完这节你就会明白它到底在说什么。

2.1 embedding:把一句话变成一串坐标

计算机不认识文字,只认识数字。embedding(嵌入)模型做的事,就是把一段文本变成一串固定长度的数字,比如 1024 个小数,写出来像 [0.02, -0.31, 0.88, ...]。你可以把这串数字理解成这段文本在一个高维空间里的坐标。

这个空间有一个关键性质:语义相近的文本,坐标离得近。“小猫”和“猫咪”会挨得很近,“小猫”和“利率”会离得很远。这就是“语义空间”这个说法的由来,模型比的不是字面,而是“意思”。RAG 能按“意思”而不是“关键词”找东西,全靠这一步。

现在回头看开头那份会议转写:

1
2
[发言人1] 00:03:12  嗯,那个,我们先说一下这个情况啊
[发言人2] 00:03:15 哈哈哈

当 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 有两个根本局限:

  1. 无状态导致遗忘:每次调用都是独立计算,模型不会自动记住上一次对话。长对话里早期信息会因上下文窗口丢失,也无法跨会话保留用户偏好。
  2. 内置知识静态且有限:训练数据有截止点,垂直领域深度不足,还容易产生幻觉。

记忆解决第一个,RAG 解决第二个。

2.6 分层记忆

记忆系统的设计通常借鉴认知心理学对人类记忆的分层:

  • 工作记忆:当前会话的临时信息,容量有限(比如 50 条)+ TTL(存活时间,到点自动删)过期,纯内存,重启即丢,这正符合它的定位;
  • 情景记忆:具体事件和经历,带时间序列,通常 SQLite(结构化查询)+ 向量库(语义检索)混合存储;
  • 语义记忆:一般性知识和概念,比如“公司财年从 4 月开始”这类不依附于某次具体对话的事实;
  • 程序性记忆:技能和习惯,比如“生成报告时固定先出摘要再出图表”这类固化下来的流程。

工作记忆的检索通常是混合评分,比如:

1
2
3
4
5
base_relevance = vector_score * 0.7 + keyword_score * 0.3
time_decay = self._calculate_time_decay(memory.timestamp)
importance_weight = 0.8 + (memory.importance * 0.4)

final_score = base_relevance * time_decay * importance_weight

也就是 (相似度 × 时间衰减) × (0.8 + 重要性 × 0.4),把语义、时效、重要性三者加权到一起。

2.7 高级检索策略

两个最常被提到的技巧:

多查询扩展(MQE):同一个问题有多种表述,用 LLM 把原始查询扩展成多个语义等价的查询并行检索,再合并去重,提高召回。

假设文档嵌入(HyDE):核心是「用答案找答案」。问题是疑问句,文档是陈述句,两者在语义空间里分布本就有差异。HyDE 先让 LLM 生成一段假设性的答案,再拿这段答案去检索真实文档,从而缩小 query 和 document 的语义鸿沟。

1
2
3
4
5
6
def _prompt_hyde(query: str) -> Optional[str]:
prompt = [
{"role": "system", "content": "根据用户问题,先写一段可能的答案性段落,用于向量检索的查询文档。"},
{"role": "user", "content": f"问题:{query}\n请直接写一段中等长度、客观、包含关键术语的段落。"}
]
return llm.invoke(prompt)

这两个策略都很聪明。记住 HyDE 解决的问题,也就是查询和文档之间的语义鸿沟,后面会回来找它。

三、落地第一课:先分清什么根本不该检索

我最早犯的错误,是想把所有东西都塞进向量库。

指标数据不该走向量检索。「2024 年 GDP 增速是多少」这种问题,答案是数据库里一个确定的数字。用向量检索去找它,你会得到一堆「语义上很像」的片段,然后指望模型从里面读出正确的数。这既慢又不准,而且错了都不知道。

所以系统里定了一条分工原则:

硬数据走 SQL,软知识走 RAG。

分析节点做的事,是把两边的结果融合:硬数据提供「是多少」,软知识提供「为什么、依据是什么」,合起来才是一个有据可查的结论。

这条看起来朴素,但它是后面一切的前提。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
2
处理前:12 段,平均 1,855 字
处理后:23 段,平均 720 字

另一份报告是 13 段(均 1767 字)→ 30 段(均 639 字)。

没有动任何检索参数,只是给文档加了标题。

5.2 会议纪要:把语料重建一遍

就是开头那个 0 命中的库。诊断清楚之后,重建流程一点也不高级:

1
2
重建前:0 命中
重建后:语义得分 0.67 ~ 0.75

七个步骤里,没有一步碰到检索算法,全是在收拾语料。

从「完全不可用」到「可用」,靠的是数据清洗。

5.3 表结构库:用索引文档做语义桥接(这里和 HyDE 撞上了)

这个案例最有意思。

知识库里有 200 多份数据库表结构文档,每份描述一张表的表名、中文说明、字段列表和示例 SQL。单份文档自己的语义匹配还行(0.55~0.74),但用户是拿自然语言提问的,比如「某县的 GDP 数据在哪个表」。

这句话和一份技术性的表结构描述之间,语义距离非常远。结果就是 0 命中。

看到这里,学过 HyDE 的人应该有反应了:这不正是 HyDE 要解决的那个问题吗,查询(自然语言问句)和文档(技术性陈述)之间的语义鸿沟。HyDE 的解法是让 LLM 现场生成一段假设的技术性答案,再拿去检索。

我的解法是另一个方向:预先写好 5 个「表目录索引」文档,按业务域分组,每个索引文档包含该领域所有相关表名、中文说明、典型查询场景,以及「常见问题 → 表名」的映射。

索引文档 覆盖领域
表目录-经济GDP人口 GDP、地区生产总值、人口、城镇化率
表目录-工业企业科技 工业、规上企业、高新技术企业、专精特新
表目录-农业财政投资 农业、粮食、财政收支、固定资产投资
表目录-商贸金融民生 消费、进出口、存贷款、教育、医疗、就业
表目录-县域综合速查 县域各类表速查

这些索引文档本身就是用自然语言写的,天然离用户的问法很近;它们又包含技术表名,于是成了一座桥。

1
2
加索引前:0 命中
加索引后:0.815

同一个问题,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
2
3
4
5
6
7
8
return {
"ok": result.get("status") == "ok",
"summary": result.get("summary", ""),
"data_gaps": result.get("data_gaps", ""),
"supports": result.get("supports", ""),
"does_not_support": result.get("does_not_support", ""), # 这份数据不支持什么结论
"boundary": result.get("does_not_support", "不能替代部门数据核验。"),
}

does_not_support 这个字段我很喜欢。一般的检索工具只告诉你「我找到了什么」,而它还会告诉你「基于这些材料,哪些结论是不能下的」。

这跟第三节那条分工原则是一脉相承的:RAG 检索回来的是「依据」,不是「结论」。材料能支撑到哪一步,必须说清楚。在一个产出会影响真实决策的系统里,「检索到了什么」和「这些东西能证明什么」是两件必须分开的事。

所以我的结论是:记忆的形态应该由任务形态决定,而不是照着认知科学的分类去凑。认知科学那套分层解释了「为什么需要不同的记忆」,但它不是一张必须照抄的施工图。先问「我的任务真的需要跨会话记住东西吗」,再决定要不要建。

八、几点总结

  1. 在我的案例里,RAG 的第一瓶颈是语料而不是算法。三次最明显的改善(0 命中救回 0.75、典型查询提升到 0.815、片段从 1855 字降到 720 字)全部来自修文档:加标题、清洗噪音、补索引。算法层的混合检索在当前实现里没有额外收益。结论不是“算法不重要”,而是在调 top_k 之前,先打开源文件看一眼。

  2. 检索质量的顺序是:分对类 > 打标签 > 修结构 > 换算法。先分清什么该走 SQL、什么该走 RAG;再用元数据把不该进候选集的砍掉;再把文档结构修好;最后才轮到检索策略。我是倒着做的,走了弯路。

  3. 能预先组织的,就别让模型现场猜。HyDE 和「表目录索引」解决的是同一个语义鸿沟,前者靠模型运行时生成假文档,后者靠人预先写好真索引。当语料可治理时,后者更便宜、更确定、也不会瞎编。这跟我在别处得到的结论是同一个:确定性的东西,尽量别交给模型即兴发挥。

  4. 别把别人的结论当自己的事实。混合检索更好、分数阈值设 0.5,这些「常识」在我的环境里一个都不成立。而且它们失效时不报错,只是悄悄变差。测一下很便宜。

  5. 架构要照着任务长,不要照着理论长。四层记忆很优美,但我的任务是流水线不是聊天,硬套只会增加没人用的代码。理论的价值是让你理解「为什么会有这些设计」,不是给你一张必须填满的表格。


回过头看,这一章我学到的最有用的东西,可能不是 MQE 或 HyDE 的实现,而是知道了它们在解决什么问题。正因为知道 HyDE 是在补语义鸿沟,我才能在遇到同一个问题时认出它,然后选一个更适合我这个场景的解法。

理解原理的价值,往往不是让你用上它,而是让你认得出问题。


参考资料