尧图精选

RAG检索不准九成锅在入库:不同文件类型的结构化切分与混合检索实战

🕒 发布时间:2026/10/1 23:30:01 📁 来源:尧图网络
做 RAG 项目的人十个里有八个在检索效果不好的时候第一反应是换个更强的 Embedding 模型。我见过太多团队把 bge-large 换成 bge-m3再换成某个榜单上排名更高的模型折腾一圈下来召回率只涨了两三个点该找不到的还是找不到。问题出在哪出在入库这一步就被做废了。我做了几个 RAG 知识库项目之后越来越确信一件事检索不准九成的锅不在向量模型而在于你把所有文件都当成同一种东西用同一套方案切完就往向量库里塞。PDF 里的表格、Word 里的条款、Markdown 里的代码块、Excel 里的参数矩阵它们的语义结构完全不同用一把刀切所有菜切出来的东西能好吃才怪。这篇内容适合正在做 RAG 知识库、被检索命中率折磨的开发者也适合刚接触 RAG 想少走弯路的同学。我会从为什么不同文件要不同入库方案讲起把 PDF、结构化文档、代码、表格这几类文件的处理思路拆开讲清楚再聊混合检索和 pgvector 的落地细节最后说说 Agentic RAG 和 GraphRAG 这些新玩法在入库层面意味着什么。全程都是我自己踩过坑之后的实操总结不是文档翻译。1. 先搞清楚为什么统一入库是检索不准的头号元凶1.1 向量检索的本质是语义相似度不是关键词匹配很多人对 RAG 检索有个误解觉得向量库是个更聪明的搜索引擎。其实不是。向量检索做的事情是把你的 query 编码成一个高维向量然后在库里找余弦相似度最高的那几个 chunk。它匹配的是语义不是字面。这个特性带来一个直接后果chunk 的语义完整性决定了检索质量的上限。如果你把一个完整的条款从中间切断前半句乙方应在收到通知后 30 日内和后半句完成交付并提交验收报告被切成两个 chunk那 query交付期限是多久去检索两个 chunk 的向量都不完整可能都排不进 top-k。而如果你把整段话保留成一个 chunk它的向量就完整表达了交付期限这个语义命中率立刻不一样。所以入库方案的核心目标只有一个让每个 chunk 在语义上是自洽的、完整的、可独立理解的。不同文件类型达成这个目标的方式完全不同。1.2 不同文件类型的语义单元根本不是一回事我拿几类常见文件举例你感受一下它们的语义单元差异有多大文件类型天然语义单元切分难点合同/条款类 PDF一个条款条、款、项条款跨页、编号层级深技术手册 PDF一个小节 配图说明图文混排、表格穿插Markdown 文档一个标题下的段落代码块不能被切断源代码一个函数/类语法结构必须完整Excel/CSV一行记录或一个参数组表头语义要带上会议纪要一个议题的讨论发言人轮次交错你看合同要按条款切代码要按函数切表格要按行表头切。如果你统一用 512 token 的固定窗口去切合同会被切得七零八落代码会被拦腰截断表格会丢掉表头变成一堆没有意义的数字。这就是为什么统一入库方案必然导致检索不准。1.3 一个真实的对比固定切分 vs 结构化切分我在一个法律知识库项目里做过对比测试。同一批 200 份合同 PDF两种入库方案方案 A固定窗口按 512 token 递归切分overlap 50 token直接入 pgvector。方案 B结构化切分先解析出条款层级按条为单位切分每条带上所属章节标题作为上下文前缀。测试集是 100 个真实业务 query人工标注正确答案。结果指标方案 A方案 BRecall561%84%Recall1072%91%MRR0.480.71Embedding 模型完全一样向量库完全一样唯一变量就是入库切分方案。Recall5 差了 23 个百分点。这 23 个点换多少个 Embedding 模型都补不回来。提示如果你现在检索效果不好先别急着换模型。把 top-k 检索出来的 chunk 打印出来看一眼如果 chunk 本身读起来就语义不完整那问题百分之百在入库环节。2. PDF 入库别再用 PyPDF2 硬切了分层解析才是正路2.1 PDF 是排版格式不是语义格式这是所有麻烦的根源PDF 这个格式的设计目标是在任何设备上看起来都一样它存的是每个字符的坐标位置不是段落、标题、表格这些语义结构。所以你用 PyPDF2 提取出来的文本本质上是一堆按坐标排序的字符流标题和正文混在一起表格变成一堆错位的数字双栏排版会左右串行。我最早做 PDF 入库就是PyPDF2.extract_text()一把梭然后按字符数切。结果双栏的论文提取出来是左栏第一行 右栏第一行 左栏第二行 右栏第二行这种鬼东西检索能准才怪。正确的做法是分层解析先判断 PDF 的类型再选对应的解析策略。2.2 三类 PDF三种解析策略我把 PDF 分成三类处理方式完全不同第一类原生文本 PDFWord 导出的、LaTeX 编译的这类 PDF 有完整的文本层解析相对容易。推荐用pdfplumber或PyMuPDF (fitz)它们能保留文本块的位置信息你可以根据坐标判断哪些是标题、哪些是正文、哪些是表格。import fitz # PyMuPDF doc fitz.open(contract.pdf) for page in doc: # 按块提取保留位置信息 blocks page.get_text(dict)[blocks] for block in blocks: if block[type] 0: # 文本块 for line in block[lines]: text .join(span[text] for span in line[spans]) # 根据字号判断是否标题 font_size line[spans][0][size] if font_size 14: print(f[标题] {text}) else: print(f[正文] {text})关键点用字号和加粗判断标题层级。一级标题字号最大二级次之正文最小。这样你就能重建出文档的层级结构而不是一坨纯文本。第二类扫描件 PDF图片型这类 PDF 没有文本层必须走 OCR。我试过几套方案实测下来PaddleOCR对中文文档的识别效果最稳尤其是表格识别。但 OCR 有个坑它会把版面结构也识别进来产生大量噪声。所以 OCR 之后一定要做后处理去掉页眉页脚、页码、水印这些重复内容。第三类图文混排 PDF技术手册、产品说明书这类最难。文字和图片交错图片里往往还有关键信息比如电路图、流程图。我的处理方式是文本走文本解析图片单独抽出来用多模态模型生成图片描述然后把描述作为文本 chunk 的一部分入库。这样检索时query 能同时命中文字和图片描述。2.3 条款类文档的切分按编号层级切别按字数切合同、法规、标准这类文档天然有编号层级第X章、第X条、第X款。切分时应该按最细的编号单元切但把父级标题作为上下文前缀带上。举个例子原文是第三章 交付与验收 第十二条 乙方应在收到甲方通知后 30 日内完成交付。 一交付地点为甲方指定仓库。 二交付时需提供完整的验收报告。切出来的 chunk 应该是[第三章 交付与验收 第十二条] 乙方应在收到甲方通知后 30 日内完成交付。 [第三章 交付与验收 第十二条 一] 交付地点为甲方指定仓库。 [第三章 交付与验收 第十二条 二] 交付时需提供完整的验收报告。为什么要带前缀因为单独看交付地点为甲方指定仓库这句话你不知道它属于哪个条款、哪个合同。带上第三章 交付与验收 第十二条这个前缀chunk 的语义就完整了检索交付地点在哪时命中率会高很多。这个前缀策略我实测下来在条款类文档上能提升 15-20 个点的 Recall。代价是 chunk 变长了一点但完全值得。2.4 跨页条款的处理这是最容易被忽略的坑合同 PDF 经常出现一个条款跨两页的情况。如果你按页解析这个条款就被切成两半了。我的处理方式是解析时先合并所有页的文本块再按编号规则重新切分而不是按页切。具体做法是把所有页的文本块按页码和 y 坐标排序拼成一个连续的文本流然后用正则匹配编号如第[一二三四五六七八九十]条来定位条款边界。这样跨页条款就能被正确合并。注意跨页表格更麻烦。如果表格跨页表头只在第一页出现第二页的表格数据就丢了表头。这种情况需要在解析时检测表格连续性把第一页的表头补到第二页的数据上。3. 结构化文档与代码按语法结构切让 chunk 自带上下文3.1 Markdown 和 Word按标题层级切代码块单独处理Markdown 和 Word 这类有明确标题层级的文档切分逻辑很清晰按标题切每个标题下的内容作为一个或多个 chunk。但有两个细节要注意第一代码块不能被切断。Markdown 里的代码块 包裹的部分是一个语义整体如果从中间切开检索出来的代码片段没法用。所以切分时要先识别代码块边界把代码块作为不可分割的单元。第二表格要保留表头。Markdown 表格如果行数多可能需要拆成多个 chunk但每个 chunk 都要带上表头否则数据行没有意义。import re def split_markdown(text, max_chunk_size800): # 先按标题切 sections re.split(r\n(?#{1,6}\s), text) chunks [] for section in sections: # 识别代码块保护起来 code_blocks re.findall(r[\s\S]*?, section) # 用占位符替换代码块 for i, cb in enumerate(code_blocks): section section.replace(cb, f__CODE_{i}__) # 按段落切分 if len(section) max_chunk_size: paragraphs section.split(\n\n) # ... 合并段落直到接近 max_chunk_size # 还原代码块 for i, cb in enumerate(code_blocks): section section.replace(f__CODE_{i}__, cb) chunks.append(section) return chunks3.2 源代码按函数/类切用 AST 而不是正则代码入库是很多技术知识库的刚需。但代码的语义单元是函数和类不是行数。你用固定行数切一个函数被切成两半检索出来的代码根本没法用。正确做法是用 AST抽象语法树解析。Python 用内置的ast模块Java 用javalangJavaScript 用esprima。以 Python 为例import ast def split_python_code(source): tree ast.parse(source) chunks [] for node in ast.walk(tree): if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): # 提取函数/类的源码 start node.lineno - 1 end node.end_lineno code \n.join(source.split(\n)[start:end]) # 带上所属类和模块信息作为上下文 chunks.append({ type: type(node).__name__, name: node.name, code: code }) return chunks每个 chunk 除了代码本身还要带上函数名、所属类、模块路径、docstring。这些元信息在检索时非常有用——用户问这个函数怎么用函数名和 docstring 就是最强的检索信号。3.3 表格数据行 表头 表名三位一体Excel、CSV、数据库表这类结构化数据入库时最容易犯的错是只存数据不存语义。比如一行2024-01-15, 张三, 5000你光看这行数据根本不知道它是什么意思。但如果带上表头和表名变成[销售记录表] 日期2024-01-15, 销售员张三, 金额5000语义就完整了。我的做法是每一行数据都转成表名 字段名值的自然语言描述再入库。这样向量检索时query张三的销售额是多少能直接命中这一行。对于宽表字段很多可以按字段分组把相关的字段放在一个 chunk 里。比如用户信息表把基本信息姓名、性别、年龄放一个 chunk联系方式电话、邮箱、地址放另一个 chunk。3.4 一个通用原则chunk 要能独立回答一个问题上面讲了这么多切分策略其实背后有一个通用原则每个 chunk 应该能独立回答一个具体问题而不需要依赖其他 chunk 的上下文。你可以用这个原则来自检随便抽一个 chunk 出来问自己如果用户的问题正好对应这个 chunk他只看这一段能不能得到答案如果不能说明这个 chunk 的上下文缺失了需要补上标题、表头、所属模块等信息。这个自检方法我每次做新知识库都会用比任何自动化指标都直观。4. 混合检索向量不是万能的关键词检索该上还得上4.1 纯向量检索的三个盲区向量检索很强但它有三个明显的盲区盲区一精确匹配失效。用户搜ERR_4032这个错误码向量检索可能返回一堆语义相近但错误码不同的结果。因为错误码这种字符串语义相似度没有意义必须精确匹配。盲区二低频专有名词。公司内部的产品名、项目代号、人名这些词在 Embedding 模型的训练语料里可能根本没出现过编码出来的向量没有区分度。盲区三否定和数值。不含酒精的产品和含酒精的产品向量相似度可能很高但语义完全相反。数值比较大于、小于向量检索也做不好。这三个盲区恰好是关键词检索BM25的强项。所以混合检索不是可选项是必选项。4.2 BM25 向量怎么融合才合理混合检索的核心是融合策略。常见的有两种策略一加权求和。把 BM25 分数和向量相似度分数归一化后加权相加。简单但权重难调。策略二RRFReciprocal Rank Fusion。不看分数只看排名把两个检索结果的排名做倒数融合。公式是score Σ 1/(k rank)k 通常取 60。我实测下来 RRF 更稳因为它不需要调权重对分数尺度不敏感。两种检索各取 top-50RRF 融合后取 top-10效果比任何单一检索都好。def rrf_fusion(vector_results, bm25_results, k60, top_n10): scores {} for rank, doc_id in enumerate(vector_results): scores[doc_id] scores.get(doc_id, 0) 1 / (k rank 1) for rank, doc_id in enumerate(bm25_results): scores[doc_id] scores.get(doc_id, 0) 1 / (k rank 1) sorted_docs sorted(scores.items(), keylambda x: x[1], reverseTrue) return [doc_id for doc_id, _ in sorted_docs[:top_n]]4.3 pgvector 里怎么同时做向量和关键词检索如果你用 PostgreSQL pgvector好消息是关键词检索可以直接用 PG 内置的全文检索tsvectortsquery不需要额外部署 Elasticsearch。建表时同时建向量列和全文检索列CREATE TABLE documents ( id SERIAL PRIMARY KEY, content TEXT, embedding vector(1024), content_tsv tsvector GENERATED ALWAYS AS (to_tsvector(simple, content)) STORED ); CREATE INDEX ON documents USING ivfflat (embedding vector_cosine_ops) WITH (lists 100); CREATE INDEX ON documents USING GIN (content_tsv);查询时分别查两个索引再在应用层做 RRF 融合-- 向量检索 SELECT id, content, 1 - (embedding $1) AS score FROM documents ORDER BY embedding $1 LIMIT 50; -- 关键词检索 SELECT id, content, ts_rank(content_tsv, query) AS score FROM documents, to_tsquery(simple, $2) query WHERE content_tsv query ORDER BY score DESC LIMIT 50;提示中文全文检索 PG 原生支持不好需要装zhparser或pg_jieba扩展。如果不想折腾可以用simple配置配合应用层分词或者干脆用pg_trgm做模糊匹配。4.4 元数据过滤被低估的检索增强手段除了向量和关键词元数据过滤是第三个检索维度而且经常被忽略。入库时给每个 chunk 打上元数据标签文件类型、来源、日期、部门、权限等级检索时先按元数据过滤再在子集里做向量检索。比如用户问2024 年第三季度的销售政策你可以先过滤date 2024-07-01 AND date 2024-09-30 AND doc_type policy再在过滤后的子集里做向量检索。这样既缩小了检索范围又避免了跨时间、跨类型的语义干扰。pgvector 支持在 WHERE 子句里做元数据过滤配合向量索引使用。但要注意过滤条件的选择性会影响索引效果。如果过滤后剩下的行很少PostgreSQL 可能直接走全表扫描不走向量索引。这种情况可以用分区表或者把过滤条件也编码进向量。5. 从 Agentic RAG 到 GraphRAG入库方案要跟着架构演进5.1 Agentic RAG 对入库提出了什么新要求Agentic RAG 的核心思想是让 Agent 自己决定要不要检索、检索什么、检索几次。这对入库方案提出了新要求要求一chunk 要能被 Agent 理解。传统 RAG 是检索-拼接-生成Agentic RAG 是 Agent 主动选择工具和检索策略。这意味着 chunk 不仅要语义完整还要有清晰的元数据让 Agent 能判断这个 chunk 是不是我要找的。要求二支持多粒度检索。Agent 可能先检索粗粒度的摘要再下钻到细粒度的原文。所以入库时最好同时存摘要层和原文层摘要用于快速定位原文用于精确回答。要求三支持结构化查询。Agent 可能会生成 SQL 查询而不是向量检索。所以结构化数据要同时以向量可检索和SQL 可查询两种形式入库。5.2 GraphRAG 的入库逻辑从 chunk 到实体关系GraphRAG 的思路和传统 RAG 完全不同。它不是在 chunk 上做向量检索而是先抽取实体和关系构建知识图谱再在图上做检索。这对入库方案意味着入库时要做实体抽取和关系抽取。具体流程是文档切分成 chunk这一步和传统 RAG 一样用 LLM 从每个 chunk 里抽取实体人、组织、产品、事件和关系把实体和关系存入图数据库Neo4j、NebulaGraph同时保留 chunk 和实体的映射关系检索时先从 query 里识别实体在图上找到相关实体和它们的关系再回溯到原始 chunk 获取详细内容。GraphRAG 的优势是能回答需要多跳推理的问题比如张三的上级所在部门的负责人是谁。这种问题传统 RAG 很难处理因为答案分散在多个 chunk 里。但 GraphRAG 的代价是入库成本高——每个 chunk 都要过一遍 LLM 做实体抽取token 消耗大速度慢。我的建议是不要一上来就上 GraphRAG。先把传统 RAG 的入库做扎实等遇到明确的多跳推理需求再考虑引入图谱。很多团队 GraphRAG 上了一半发现维护成本太高又退回传统 RAG 了。5.3 Ontology RAG给入库加一层本体约束Ontology RAG 是在 GraphRAG 基础上更进一步预先定义本体Ontology约束实体和关系的类型。比如定义合同这个本体它有甲方乙方金额期限这些属性实体抽取时只抽这些预定义的类型。这样做的好处是图谱结构规整检索时可以用本体做推理。坏处是本体设计需要领域专家参与前期投入大。适合领域固定、知识结构稳定的场景比如医疗、法律、金融。5.4 一个务实的演进路线结合我自己的经验给一个务实的演进路线阶段入库方案适用场景第一阶段按文件类型结构化切分 混合检索大多数知识库场景第二阶段加元数据过滤 多粒度摘要原文文档量大、类型多第三阶段引入 Agentic 检索策略复杂查询、多轮交互第四阶段GraphRAG / Ontology RAG多跳推理、领域知识密集大部分项目做到第二阶段就够用了。第三、第四阶段是锦上添花不是雪中送炭。别为了用新技术而用新技术先把基础入库做扎实。6. 实操中那些文档不会告诉你的坑6.1 Embedding 模型的维度不是越高越好很多人选 Embedding 模型只看榜单排名不看维度。bge-large 是 1024 维bge-m3 也是 1024 维但有些模型是 4096 维。维度越高存储和检索成本越高但效果不一定更好。我的经验是1024 维对大多数场景够用了。除非你的语料特别专业比如医学、法律否则没必要上 4096 维。省下来的存储和检索时间比那一点点效果提升更值。另外入库和检索必须用同一个模型。我见过有人入库用 bge-large检索用 bge-m3结果向量空间不一致检索效果一塌糊涂。换模型时一定要全量重新入库。6.2 chunk 大小没有标准答案但有调试方法chunk 大小设多少512 token1024 token没有标准答案取决于你的文档类型和 query 特点。我的调试方法是准备 20-30 个真实 query人工标注正确答案所在的 chunk然后测试不同 chunk 大小下的 Recall5。通常 256-512 token 适合问答型场景512-1024 token 适合总结型场景。overlap 一般设 chunk 大小的 10-20%。6.3 入库时的清洗比切分更重要很多人花大量时间调切分策略却忽略了清洗。实际上入库前的清洗能解决一半的检索问题。要清洗的东西包括页眉页脚、页码、水印重复的免责声明、版权信息OCR 产生的乱码和错位字符HTML 标签、Markdown 残留符号多余的空格、换行、制表符我一般会写一个清洗 pipeline把常见噪声用正则批量去掉。这一步做扎实后面的切分和检索都会轻松很多。6.4 别忘了给 chunk 加来源指纹每个 chunk 入库时一定要记录它的来源信息文件名、页码、章节、原始位置。这样检索出来之后你能追溯到原文用户也能验证答案的可靠性。更重要的是来源信息可以作为检索的辅助信号。比如用户问最新的政策是什么你可以根据来源文件的日期做排序加权让新文件的 chunk 排前面。6.5 定期重建索引别让脏数据累积知识库是活的文档会更新、删除、新增。如果只增不删向量库里会积累大量过期数据检索时被旧数据干扰。我的做法是给每个 chunk 打上版本号和时间戳定期做全量重建。增量更新虽然省事但容易产生碎片长期来看全量重建更干净。重建频率取决于文档更新频率一般一周到一个月一次。7. 回到那个标题九成的锅真的不在向量写到这里我想回到最开始那个判断。为什么我说九成的锅不在向量因为向量模型解决的是给定两个文本判断它们语义是否相似这个问题。它是个通用能力不针对你的具体文档。而入库方案解决的是怎么把你的文档切成语义完整的单元这个问题它是领域特定的需要你理解自己的文档结构。通用能力的天花板是固定的你换个模型最多提升几个点。而领域特定的优化空间是巨大的切分方案改对了提升二三十个点是常事。所以下次检索不准的时候先别打开模型榜单。打开你的入库代码把检索出来的 chunk 打印出来读一读。如果 chunk 本身读起来就不通顺、不完整那答案已经很明显了。我自己现在做新知识库流程是这样的先花半天时间把文档类型摸清楚每类文档写一个专门的解析器切分策略按文档结构定制然后才考虑 Embedding 模型和检索策略。这个顺序不能反。反过来做就是在错误的地基上盖楼盖得越高塌得越狠。最后分享一个我常用的自检清单每次入库方案上线前过一遍随机抽 20 个 chunk每个都能独立读懂吗表格 chunk 带表头了吗代码 chunk 语法完整吗条款 chunk 带编号前缀了吗混合检索的 BM25 和向量都跑通了吗RRF 融合参数调了吗元数据字段设计好了吗能做过滤和排序吗清洗 pipeline 覆盖了页眉页脚、乱码、重复内容吗换 Embedding 模型时有没有全量重建索引这六个问题都能答是你的 RAG 检索效果大概率不会差。答不上来的回去补课比换模型有用得多。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →