上个月帮朋友调一个本地知识库RAG,现象特别经典:问答总是“好像知道一点,但就是答歪了”。朋友说他试遍了所有embedding模型,从bge到m3e再到新出的gtr,甚至把向量数据库从Chroma换成了Milvus,召回率纹丝不动。这场景我太熟了——大多数团队花90%精力在向量这一层折腾,可真正的坏味道,几乎都藏在入库环节。
这篇文章想聊的就是这件事。我把自己过去大半年在RAG项目里踩过的坑、试过的方案,按文件类型拆开来讲。核心就一个观点:RAG检索不准,九成的锅不在向量,而在文件入库方案选错了。不同文件,就该有不同的切分逻辑和索引策略。先声明,这不是否认向量检索的价值,而是想让后来的人少走我那几周弯路。
1. 我亲手踩过的坑:把检索问题当成向量问题来处理
先讲一段往事。当时我在做一个企业制度文档问答系统,手里有几十个Policy文档,每个一两百页。最初我按“字符切块”来做:固定512字符、重叠128字符,然后丢进向量库里。结果用户问“员工休年假需要提前几天提交申请”,系统召回的前五条里居然有三条都来自《差旅费用报销制度》——字面上撞了“申请”“提交”这种词,但跟休假毫无关系。
我以为是embedding模型质量不行,就把所有候选向量模型拉出来横向对比。跑了几天,分数都在0.72上下浮动,没什么本质区别。后来我气呼呼地跟一个做搜索多年的老哥吐槽,他说了一句话点醒我:“你chunk都没按语义分,向量再准也没用,因为它召回的目标单位本来就是错的。”
这句话我记到现在。RAG不是一个“检索模型问题”,而是一个“索引结构问题”。检索质量的上限由分块和入库方案决定,向量模型只是在给定块上做排序。块内容本身是错配的,排序再精准也只是把错的内容排前面。这里有个关键认知:如果实体被切碎,或者上下文被切断,那么无论用什么向量模型、什么数据库,都救不回来。
从那以后,我给自己定了个规矩:任何RAG项目,第一件事是盘点文件类型,确定每类文件的主键切分策略,然后才轮到选向量模型。
下面直接进入正题。按我实际碰到的场景,把常见的RAG入库拆成五大类来逐个说。
2. 事务性文档:按章节结构入库,不要按字符窗口切块
事务性文档是我遇到最多的一类,包括各类制度、说明书、研究报告、合规文档。它们有一个共同特征:有明确的层级结构,比如章、节、条、表、并且一个概念往往横跨多个相邻段落。按固定窗口切块,相当于把一本完整的教材撕成一页页单张,再让读者根据单张碎片回答问题,怎么可能准确。
2.1 为什么固定字符块在这里是灾难
理论上,embedding模型能捕捉语义相似度,但它能“看到”的上下文范围很有限。块如果太小,语义不完整;块如果太大,向量被平均稀释,检索精度下降。我在踩坑中总结出三个典型病状:
- 主题交叉污染:相邻两章讲的是完全不同的主题,固定窗口会把下一章内容卷进来,导致检索到的块看似提到了答案关键词,实际上不过是邻接噪音。
- 语义实体被拦腰截断:比如“承诺不可撤销”被切成“承诺不可”和“撤销”两段,任何向量模型都难以恢复原意。
- 表格与段落脱节:文档里的表格通常会承载大量参数,但普通字符分块会把表头和表体硬生生拆开。
2.2 正确方案:先用版面分析“破冰”,再按标题层级聚合
处理这类文档的第一步,是要用专业的文档解析工具把PDF还原成结构化内容。我自己在用的组合是:Comprehend+自定义规则(针对扫描件),或者直接用开源方案做版面分析,把标题、正文、表格、页眉页脚分别抽出来。这步别嫌麻烦,它决定了后面能否按结构切分。
然后核心动作来了:用标题层级作为切分锚点,把内容聚合到最小有意义的语义单元。
具体做法是这样:
- 解析文档,抽出目录和所有标题,标注层级(H1/H2/H3)。
- 从最小层级标题开始,检查该标题下的内容长度。
- 如果内容过长(比如超过1500字),再按段落切分,但要把上文的同级标题信息作为元数据注入。
这里最关键的是给每个分块补上上下文元数据。比如一个块来自“第三章 请假制度 -> 3.2 申请流程”,那我会在存储时把这个层级路径和摘要信息一起存入向量库,检索时优先命中的块会带着章节路径返回。用户问“年假要提前多久申请”,系统不光返回了正文片段,还自动过滤出它属于请假制度一章,不需要靠向量全库扫描来猜测。
存储结构参考这个设计:
id: "policy_3_2_04" content: "员工申请年假,需提前7个工作日填写OA流程……" chapter_path: "请假制度 > 申请流程" section_title: "申请流程" doc_type: "制度文件" effective_date: "2024-01-01"在这种结构下,我甚至不需要特别调优向量模型的阈值。因为候选集先经过了元数据过滤,向量排序只负责在同章节内容里做精度排序,误召率直接掉下来。实测命中率从0.63提升到0.88,没换任何embedding模型。
2.3 表格怎么处理
表格是个大坑。我之前用过一段时间的markdown化提取,直接把表格转成竖排文本喂给向量模型,效果一般。后来换了个思路:表格整体作为一个块,不做内容切分,同时在块描述里人工/自动加上表格标题和字段摘要。
比如“请假审批权限表”这种表格,入库时存成:
content: "时间<=3天:主管审批;<=7天:部门经理审批;>7天:分管副总审批……" table_caption: "请假审批权限矩阵" fields: ["时长", "审批人", "流程"]这样用户问“三天年假需要谁审批”,命中的是整张表,而不是某个被打散的数字单元格。这里有个经验:宁可让一个块大一点,也要保证它讲的是完整的一件事。
3. 对话记录类文件:保住“回合”和“说话人”,RAG才有逻辑
第二种高频场景是聊天记录、客服对话、会议纪要。这类文件和事务性文档完全相反,它们没有标题层级,段落之间也不是并列关系,而是呼应关系。如果按字符或固定窗口切块,最典型的翻车场景是:用户问“这个方案运维那边同意了没”,系统让模型读了两句客服互相问候的闲话,随便拼出一个有分析意味的假回答,实际上驴唇不对马嘴。
3.1 对话类文件的本质是“回合”
我处理对话类数据时,最小的切分单元不是“若干字符”,而是一个完整的对话回合。所谓回合,指一个用户提问、后续几个连续的跟问与回答,直到话题切换为止。这一过程需要先做对话分段,再对每段做结构化入库。
这里推荐的做法是两步走:
- 第一步,按说话人和主题做临时分段:根据发言间隔、话题关键词变化,把长对话切成一节节。
- 第二步,把每一节作为一条块,块内保留原始多轮内容,并在块头上附加“对话主题摘要”。
为什么要在块头补摘要?因为向量模型对长对话里的因果链条不敏感。比如那句“这个方案运维那边同意了没”,单独看这句话,向量召回一定会跑偏。但如果你在一个块里存了“用户担心方案落地 -> 技术讨论 -> 运维初步认可需要走审批 -> 结论待定”,模型召回该块后,上下文自然就把因果链带进来了。
我在项目里用的入库数据结构是:
id: "conversation_20240115_007" role: "用户/客服混合对话" topic: "新版本发布审批流程" turn_count: 8 raw_dialogue: "(多轮原文,保留说话人标记和发言顺序)" cluster_summary: "用户质疑排期,客服解释需运维审批,最终约定25日复核"我印象最深的是用一个旧客服日志数据集做实验:原来按512字符切块,让模型回答“客户对上次物流延误的最终处理结论”,召回质量一塌糊涂;改成回合制分块后,没有变动任何参数,明显能感觉到回答条理变顺了——因为它终于看到“客户催促 -> 客服致歉 -> 提出补偿 -> 客户接受”这条有头有尾的线了。
3.2 检索阶段的“顺藤摸瓜”技巧
入库只是前半程。对这类数据,我还习惯在检索后段加一道工序:当命中一个对话块时,把它的前后相邻块也拉进来作为扩展上下文。这样做的前提是,入库时我保留了一条连续的对话ID,且每个块都有prev/next指针。
这个操作很像人在翻聊天记录时“往前翻两屏、往后翻两屏”的行为。虽然向量排序只选中了一段对话,但大模型拿到扩展上下文后,它能看到更完整的来龙去脉,而不是靠猜。我做AB测试时,这类文件加邻居扩展后,答案准确率又涨了将近8个百分点。
说到底,这类文件的入库核心不是追求“小”,而是追求“完整结构化”。对话块宁少勿碎。
4. 代码仓库与日志:建索引要懂“作用域”,别跟看小说一样切
再往上走一层,就是代码类RAG。Git仓库、API文档、报错日志,这类数据如果用自然语言切分法来做,基本就是灾难——因为代码是按作用域、层级、引用来组织的,而不是按行数组织的。
4.1 代码入库必须尊重命名空间和调用链
我见过很多团队直接把代码文件按照函数行数切块,比如30行一段,然后全量灌进向量库。结果用户问“这个查询接口的超时时间怎么设置”,系统可能会召回几个毫无关系的函数片段,还带着半个不完整的import块。原因很简单:代码的语义边界是类和函数,不是行号。
我的处理思路是分三层入库:
- 第一层:类/接口级。把每个类的方法、字段、注释聚合到一块,命名包含类名、文件路径、包名。
- 第二层:函数级。单个函数作为一个最小检索单元,但保留所属类、调用方、被调用方。
- 第三层:文档级。仓库根目录的README、部署文档等,按标题层级切,但每块都必须带上仓库名和模块路径。
存储结构里,我习惯单独维护一张调用关系表,不被向量库包括,但它被检索链路使用。当用户的问题带明显的“谁调用了它”特征时,我用代码图谱先做候选定位,再用向量做语义模糊匹配。这种混合方式,被圈里人叫结构化检索增强,本质上就是在向量之外补一层规则路径。
关于“检索单元”和“召回单元”分离,我再多解释两句。检索单元是最小可定位粒度(函数),召回单元是模型生成答案时可以阅读的上下文(整个类或模块)。入库时两层都存,输出时只输出召回单元。这个方法解决了一个我之前经常头疼的问题:函数特别多的时候,喂给模型的上下文总是不够整;现在我可以精准定位到一个函数,但把它的周围大概百来行代码一起交给模型去读。
4.2 日志和报错信息:按“案例”建档,不做全文切分
日志和报错是另一种特殊“文件”。很多人把日志按时间窗口切块,但日志里的有效信息往往是一组连续事件组成的“案例”,比如“服务A调用服务B超时 -> 重试三次 -> 熔断 -> 恢复”,这一整条链路才有价值。
我给日志类数据做了“案例化处理”,大概分三步:
- 按traceID或requestID聚合相关日志。
- 把同一traceID下的日志压成一段带时间线的案例描述。
- 给案例打标签,比如“超时”“熔断”“参数错误”“权限不足”,并把异常类名作为元数据存储。
用户问“连接MySQL经常超时是什么原因”,系统命中的不是一个时间窗,而是完整的错误案例。返回的块里不仅有时间线、异常类名,还有当时的调用参数摘要。大模型基于这样的上下文回答,能给出“大概率是连接池满了,建议排查max_active”这种有实际价值的答案,而不是泛泛复述日志原文。
日志入手要克制切碎,因为组合出一个“案例”需要完整的时间线。全量切碎之后,模型只会给你一堆断点,只能猜。
5. 结构化数据与知识图谱:用“实体关系”解决“知识割裂”
第五类文件是被不少人忽视的:数据库表、产品属性、规则引擎、本体文档(ontology)。这类数据有个共同点——它们的意义不隐藏在自然语言上下文,而是隐藏在实体间的关系里。如果光把它们转成文本灌进向量库,等于把一张关系网拍扁成一张纸。
5.1 从表格到三元组:别让关系散落
举个例子,一个产品配置库里有“产品型号A支持模式X/Y/Z,适用地区D/E/F,但许可证类型为I”。如果直接整行文本嵌入,检索“D地区能买到哪种支持模式X的产品”时,向量库只能匹配到“D地区”字面,并不能理解“产品-模式-地区”之间的约束关系。
我的处理方式是先把结构化记录转成三元组,实体节点建文本索引,关系存图库(Neo4j或者简单点用自建表),然后在RAG检索时走混合路径:
- 用户问题 -> 意图分类,判断有没有明确的实体和关系需求。
- 有 -> 先走图谱检索,找出实体关联子图,把图里的节点和边序列化成一段“关系上下文”。
- 无 -> 常规向量检索。
- 两者结果拼在一起喂给LLM。
我在实践里用了一个不算复杂的办法:把每个三元组同时写一条自然语言句子进向量库,比如“产品A -> 支持模式X -> 适用地区D”,句子短而聚焦,向量检索能命中;同时把三元组的关系表存进结构化库,做图谱召回时可以直接拿关系。两路召回合并后,重排再输入给模型。
这一套下来,原本“知识割裂”的问题被明显缓解。用户问的明明是组合条件,向量库只能给“字面相关”的内容,图谱补充了“关系相关”的内容。二者合一,模型才真正有完整的信息可依。
5.2 本体(Ontology)和Agentic RAG的配合
最近圈里天天聊ontology rag、agentic rag,我理解的核心并不是换一套检索框架,而是让检索代理(Agent)具备“先找结构、再找细节”的能力。也就是说,当用户抛来一个复杂问题,Agent不是一次性把所有关键词塞进向量库,而是先定位本体里的相关实体和组织关系,再用这些实体作为“检索入口”去召回文档块。
我在本地跑Ollama搭过一个轻量的Agentic RAG,就是这种玩法。第一步,先让模型把提问拆成“实体”和“关系”。第二步,用实体去查我建好的图谱。第三步,把图谱返回的关联节点转化为检索query,再走向量库。整体上,检索链路从“一次向量召回”升级成了“实体落地 -> 图谱导航 -> 多路召回”,逻辑清晰,效果也更可控。
如果你也在做类似项目,我建议不要一上来就上大模型做主体识别。先从规则和关键词搞起,跑通了再换成LLM抽取,否则变量太多没法定位到底是哪一步降低了命中率。关键词表、命名实体词典,这些老土的东西在RAG里永远有效。
6. 入库方案与检索调优的合璧:不换模型也能把命中率拉起来
前面讲了四种文件类型各自的入库方案,现在把这些经验归纳成几条可落地的调优建议。很多人一开始就盯向量模型选型,但我要说,有一个系统的调试顺序:入库方案 -> 块大小与元数据 -> 检索召回策略 -> 重排 -> 模型选型。这是我自己反复验证过的优先级。
6.1 实测对比:不同入库方案对同一查询的命中率差异
我做了一次小规模对照实验,文件集包含制度文档20份、客服对话20份、代码仓库2个,统一用同一款embedding模型和同一向量库,只改变入库方案,对比检索命中率:
| 查询类型 | 固定字符分块命中率 | 按结构/语义分块命中率 | 改善幅度 |
|---|---|---|---|
| 制度知识查询 | 63% | 88% | +25% |
| 客服对话追溯 | 41% | 79% | +38% |
| 代码函数定位 | 55% | 83% | +28% |
实验下来非常直观:从头到尾没有更换embedding模型,也没有调整向量数据库参数,仅仅是改对了入库方案,命中率就有接近30个百分点的提升。“九成的锅不在向量”这句话,在这组数据里体现得淋漓尽致。
6.2 怎么设计自己的“测试集”,避免靠感觉调参
我知道很多人在做RAG优化时是凭印象的,今天调个chunk size,明天开个重排,效果全凭主观。这里我强烈建议建一个三类问题测试集,每类10到20问,固定下来,每次改动之后跑一遍对比。
具体下来可以这样:
- 点查类:“X政策里规定的额度和期限是多少”,用来考验向量命中是否精确。
- 关系类:“A条件和B流程之间的关联是什么”,用来考验元数据和图谱是否有效。
- 跨文档类:“把文档A的规则套用到文档B的场景”,用来考验多块拼接能力。
每道题都写好参考答案里应包含的要点。跑完看召回结果里有没有这些要点,就能定量评估入库方案是否更优。别等上线了再做评估,那会非常被动。
6.3 混合检索+重排的低成本组合拳
在入库方案稳定之后,我自己倾向再加一道低成本的混合检索:向量召回 + 关键词(BM25)召回,二者合并后用模型做粗排(Rerank)。这个组合对术语很多的场景尤其有用,比如医疗、法律、代码报错这类领域,关键词命中往往比向量更可靠。向量负责语义泛化,关键词负责精确制导,二者互补。
实现上不用搞太复杂,趁手的方式是在向量库里存原始文档的同时,用Elasticsearch或Meilisearch建一份全文索引。检索时两个结果做加权合并或交给Rerank模型排序。如果你用的是Ollama本地方案,Rerank可以直接用一个轻量模型跑,成本可控。这样在不动embedding模型的条件下,又能把命中率往上推个5到10个百分点。
6.4 别忘了更新和清理:入库方案也要做“版本管理”
最后一个很少人注意的坑,是入库方案本身需要版本管理。文档更新、切分规则变化、结构调整,都可能导致向量库里的旧块和新块混在一起,检索结果出现“既老又新”的混乱状态。我的习惯是每次重建索引时记录方案版本号,存在每个块元数据里。这样检索时如果发现命中结果版本号参差不齐,就能快速定位到是不是没做全量刷新。
另外,文件去重也很重要。同一条制度内容在多个文档里重复出现时,入库时必须做指纹去重,否则检索一出来就是五六条重复内容,既浪费上下文空间,也影响排序准确性。成本最低的简单方法就是计算文本的哈希签名,重复内容直接跳过或标记替换,效果立竿见影。
7. 最后的私货:把这套东西落地到本地RAG项目的快速清单
前面讲了不少,可能有人会觉得头绪很多。这里我把自己的落地流程压缩成一张清单,你照着走一次,大概率能避开我踩过的大多数坑。
- 盘点文件类型:制度/文档、对话、代码、图表、结构化数据,分类存放。
- 按类型定入库策略:事务文档按标题层级切,对话按回合切,代码按类/函数切,结构化数据转三元组+自然语言双存。
- 给每个块补元数据:来源文档、章节路径、日期、版本号,缺什么补什么。
- 先做混合检索,别急着上重排:向量+关键词跑通一遍,看命中率有没有明显短板。
- 每改一次方案,跑一遍固定的三类测试集,记录对比结果。
- 最后才考虑换embedding模型或调整向量库。
说实话,市面上讲RAG优化的内容非常多,但大家总喜欢在检索侧用花活,反而把最基础、最有杠杆作用的入库方案给忽略了。每次听到有人跟我说“换了向量模型还是不准”,我的第一反应都是先去看他文件是怎么切的。十次里有八次,问题出在入库。
最后分享一个多年的体会:RAG优化的正确姿势是“嚼碎了再喂”,而不是“喂了再找”。如果一个块本身就讲得清楚、单元边界合理、元数据完备,向量检索只需要做它最擅长的事情——粗召回。别想着用向量模型弥补结构上的不合理,那是本末倒置。先把手上的文件好好分门别类,入库方案做对了,你会发现很多“检索不准”的困扰,根本不需要动向量模型就已经解决了一大半。