尧图精选

RAG检索不准九成在入库:分类型语义切分与混合检索实战

🕒 发布时间:2026/10/2 18:43:37 📁 来源:尧图网络
1. 为什么说 RAG 检索不准九成的锅不在向量1.1 一个被反复验证的现场观察做过 RAG 实战的人大概率都经历过这个场景知识库明明塞了几百份文档用户问一个答案就在某份 PDF 第三页的问题检索出来的却是另外一份毫不相干的文件里的片段。第一反应通常是——Embedding 模型不行换一个更强的或者向量库不行换一个更贵的。折腾一圈下来命中率可能从 55% 涨到 60%然后卡住不动了。我前后搭过七八套不同规模的 RAG 知识库从个人几十份文档的小库到企业内部上千份制度文件的中型库都趟过。踩坑踩多了之后得出一个结论检索不准绝大多数时候不是向量模型的问题而是入库方案的问题。向量模型决定的是语义相似度算得准不准而入库方案决定的是进入向量库的到底是什么东西。后者是地基前者是装修。地基歪了装修再豪华也白搭。这个判断有一个很朴素的逻辑支撑向量检索的本质是拿 query 的向量去和库里每个 chunk 的向量算距离。如果 chunk 本身切得支离破碎、语义不完整、或者把表格切成了乱码那再强的 Embedding 模型也只能在一个错误的候选集里挑相对最像的结果自然好不到哪去。检索的上限在入库那一刻就已经被决定了。1.2 不同文件类型语义结构完全不同这是整篇文章的核心论点不同格式的文件其语义承载方式是不一样的所以入库方案必须区别对待。一份 Markdown 技术文档语义单元是标题层级和段落天然有清晰的边界一份 PDF 合同语义单元可能是条款编号还夹杂着页眉页脚和跨页表格一份 Excel 报表语义单元是行每一行是一个完整记录一份 PPT语义单元是单页的要点一份扫描件连文字都还没提取出来。你如果拿同一套固定长度切分 通用 Embedding的方案去处理这五种文件等于用同一把刀去切豆腐、切肉、切骨头能切但切出来的东西没法用。我见过太多 RAG 教程从头到尾只讲一件事RecursiveCharacterTextSplitterchunk_size 设 500overlap 设 50然后 load、split、embed、store 四步走完。这套流程跑 demo 没问题一旦上真实文档就露馅。因为它默认所有文件都是均匀的纯文本流而现实里几乎没有文件是这样的。1.3 这篇文章要解决什么适合谁看接下来我会把不同文件不同入库方案这件事拆开讲透为什么这么设计、每种文件类型具体怎么处理、参数怎么定、代码怎么写、踩过哪些坑。核心关键词会围绕RAG、向量检索、入库方案、Embedding、混合检索这几个展开但重点始终落在入库这个被大多数人忽略的环节上。适合的读者已经跑通过一个基础 RAG demo但发现真实文档检索效果差、想搞清楚问题出在哪的人正在搭企业知识库、面对一堆格式混杂文件的人以及想理解 RAG 瓶颈到底在哪、而不是盲目换模型的人。零基础也能看因为我会把每个决策背后的为什么讲清楚而不是甩一堆配置让你抄。2. 入库方案的整体设计思路拆解2.1 先想清楚一个 chunk 到底应该承载什么在动手写任何代码之前得先回答一个根本问题我们希望检索命中的最小单元是什么。很多人下意识觉得越小越好因为 chunk 小向量表达更聚焦匹配更精准。这个直觉只对了一半。chunk 太小会带来两个致命问题一是语义不完整比如把违约金为合同总额的 30%切成违约金为合同总额的和30%两段任何一段单独拿出来都答不了问题二是上下文丢失检索到了也不知道这句话是在说什么条件下成立的。反过来chunk 太大也不行。一个 2000 字的 chunk 里可能混了五六个主题Embedding 把它压成一个向量等于把五六个意思平均了一下结果哪个主题都不像。这就是所谓的语义稀释。所以正确的思路不是纠结大小而是让 chunk 的边界对齐文件本身的语义边界。Markdown 的语义边界是标题合同的语义边界是条款表格的语义边界是行PPT 的语义边界是页。入库方案的第一原则切分要顺着文件的语义结构走而不是顺着字符数走。2.2 为什么固定长度切分是万恶之源固定长度切分比如每 500 字符一刀之所以流行是因为它简单、通用、不需要理解文件结构。但它的代价是把语义结构彻底打碎了。我做过一个对比实验同一份 80 页的产品手册切分方案chunk 数Top-5 命中率典型失败案例固定 500 字符41258%参数表被切成碎片问某个参数值检索不到按标题层级切9684%基本无失败偶尔跨章节问题略弱按标题 表格整块保留10389%跨章节问题通过混合检索补足差距非常明显。固定切分输就输在它不认识标题这个东西一个二级标题下面的完整小节被它从中间拦腰截断前半段和后半段各自成了孤儿。注意按语义切分不是让你完全抛弃长度限制。语义单元如果本身超长比如一个没有子标题的万字大章节还是要在内部做二次切分但这时候的切分是在语义单元内部切而不是无视语义乱切。2.3 混合检索为什么是必备而不是可选光有好的切分还不够。向量检索有个天然短板它对精确匹配的关键词不敏感。用户问XX-2024 型号的额定功率是多少向量检索可能给你返回一堆讲功率的段落但就是漏掉那个写着XX-2024的表格行因为型号这种字符串在语义空间里没什么意思Embedding 抓不住。这时候就需要混合检索Hybrid Search向量检索负责语义召回关键词检索BM25 或全文索引负责精确召回两路结果融合排序。对于包含大量专有名词、型号、编号、代码的知识库混合检索几乎是刚需。我一般的配比是向量召回 Top-20、关键词召回 Top-20然后用 RRFReciprocal Rank Fusion融合取 Top-5 给大模型。RRF 的好处是不需要调权重对两路分数的量纲不敏感实测下来很稳。2.4 整体架构分层入库把上面的思路串起来我常用的入库架构是这样的文件类型识别层根据扩展名和内容特征判断文件类型纯文本、结构化文档、表格、演示文稿、扫描件等。解析层每种类型用对应的解析器尽量保留结构信息标题、层级、表格、页码。语义切分层按文件类型的语义边界切分超长单元内部二次切分。元数据附加层给每个 chunk 打上来源文件、章节路径、页码、类型等标签。双路索引层同时写入向量库和全文索引供混合检索使用。这个架构比load-split-embed-store四步走复杂但每一步都有明确的理由。下面逐层拆开讲。3. 核心细节解析与分类型实操要点3.1 纯文本与 Markdown顺着标题层级走Markdown 是最好处理的因为它自带结构标记。核心思路是用标题层级构建一棵树然后按子树切分。具体做法解析出所有标题及其层级一个二级标题到下一个同级或更高级标题之间的内容就是一个语义单元。如果这个单元超过阈值我一般设 800 字再在内部按段落切。import re def split_markdown_by_heading(text, max_len800): # 按标题行切分保留标题作为上下文 lines text.split(\n) sections [] current {heading: , content: []} for line in lines: if re.match(r^#{1,6}\s, line): if current[content]: sections.append(current) current {heading: line.strip(), content: []} else: current[content].append(line) if current[content]: sections.append(current) chunks [] for sec in sections: body \n.join(sec[content]).strip() # 标题拼进正文保证 chunk 自带上下文 full f{sec[heading]}\n{body} if sec[heading] else body if len(full) max_len: chunks.append(full) else: # 超长则在内部按段落二次切分 paras body.split(\n\n) buf sec[heading] for p in paras: if len(buf) len(p) max_len and buf ! sec[heading]: chunks.append(buf) buf sec[heading] \n p else: buf \n p if buf.strip(): chunks.append(buf) return chunks这里有个关键细节每个 chunk 都要把所属标题拼进去。因为标题本身就是最强的语义标签用户的问题往往和标题高度相关。把标题带上等于给每个 chunk 免费加了一个语义锚点。实测这一步能提升 5 到 10 个百分点的命中率。实操心得标题层级不要超过三级。四级以下的标题往往是列表项伪装的把它们当标题切会把 chunk 切得太碎。我一般只认#、##、###。3.2 PDF先分清是电子版还是扫描版PDF 是 RAG 里最麻烦的格式没有之一。处理前必须先判断它是电子版文字可选中还是扫描版图片。电子版 PDF 用pymupdf或pdfplumber提取文字重点是保留页码和阅读顺序。很多 PDF 是双栏排版直接提取会把左右栏文字交错在一起读起来是乱的。pymupdf的get_text(blocks)能拿到每个文本块的位置坐标可以按坐标排序还原阅读顺序。扫描版 PDF 必须先做 OCR。这一步的坑在于OCR 出来的文字没有段落结构全是碎行。我的处理方式是先按行合并把行尾没有标点的行和下一行拼起来再按空行或缩进切段。PDF 里最要命的是表格。表格被当普通文字提取出来会变成一堆数字和文字混在一起的乱码。正确做法是用pdfplumber的extract_tables()单独把表格抽出来转成 Markdown 表格或结构化文本作为一个独立 chunk 入库并在元数据里标记type: table。PDF 类型解析工具关键处理常见坑电子版单栏pymupdf按块坐标排序页眉页脚混入正文电子版双栏pymupdf blocks按 x 坐标分栏左右栏交错扫描版OCR 引擎行合并 段落还原无结构、错字含表格pdfplumber表格单独抽取表格被拆成乱码页眉页脚的处理也值得说一句。几乎每页都有重复的公司名、页码、文档标题这些内容如果入库会污染检索结果——用户问任何问题这些高频重复的页眉都可能被召回。我的做法是统计每页首尾几行出现频率超过 80% 的行直接丢弃。3.3 表格文件一行一个 chunk别切行Excel、CSV 这类表格文件语义单元就是行。每一行是一个完整记录绝对不能把一行切成两半。处理方式把表头提取出来每一行数据拼上表头形成一个表头: 值的文本 chunk。比如一行数据是[2024-01, 华东区, 1200]表头是[月份, 区域, 销售额]那 chunk 就是月份: 2024-01, 区域: 华东区, 销售额: 1200。import pandas as pd def table_to_chunks(file_path, sheet_nameNone): df pd.read_excel(file_path, sheet_namesheet_name) headers df.columns.tolist() chunks [] for _, row in df.iterrows(): parts [f{h}: {v} for h, v in zip(headers, row) if pd.notna(v)] chunks.append(, .join(parts)) return chunks这样每个 chunk 都是自解释的检索到之后大模型也能直接读懂。如果表格很大几万行可以考虑按业务维度分组但组内仍然是行级 chunk。注意表格入库一定要保留表头这个上下文。我见过有人直接把 DataFrame 转成字符串塞进去结果每行数据都没有列名检索出来大模型根本不知道那个数字是销售额还是库存量。3.4 演示文稿与图片一页一单元图片单独处理PPT 的语义单元是单页。每页的标题加正文要点构成一个 chunk。PPT 的文字通常很精炼一页也就几十到一两百字直接整页入库即可不需要再切。图片的处理要分两种情况。如果图片是装饰性的logo、背景图直接忽略。如果图片承载信息流程图、架构图、截图有两种方案一是用多模态模型生成图片描述把描述文本入库二是用 OCR 提取图中文字。前者适合理解型图片后者适合文字型截图。关于RAG 知识库能不能存图片这个高频问题答案是向量库存的是图片的文本表示不是图片本身。你可以存图片的 URL 作为元数据检索命中后把图片一起返回给前端展示但参与检索的永远是文本。3.5 Embedding 模型怎么选别迷信排行榜Embedding 模型排行看多了容易焦虑总觉得换个第一名命中率就能起飞。实际上模型选择要看三个维度语言、领域、成本。中文知识库优先选中文语料训练充分的模型如果是代码库选代码语料强的如果对成本敏感选维度低、推理快的。我一般会准备两三个候选模型在自己的真实数据上跑一遍小规模评测而不是直接抄排行榜。评测方法很简单准备 50 到 100 个真实问题每个问题标注正确答案所在的 chunk然后算 Top-5 命中率。这个自建评测集比任何排行榜都靠谱因为它测的是你的数据、你的问题分布。实操心得换 Embedding 模型带来的提升通常远小于优化入库方案带来的提升。我做过对比同一份文档固定切分换三个模型命中率在 55% 到 62% 之间波动而改成语义切分后直接到 84%。先把入库做对再考虑换模型。4. 实操过程与核心环节实现4.1 环境与依赖准备先把基础环境搭起来。我用的是 Python 生态核心依赖如下pip install pymupdf pdfplumber pandas python-docx python-pptx pip install langchain langchain-community pip install chromadb rank_bm25 pip install sentence-transformers向量库我选 Chroma因为它轻量、本地跑、支持元数据过滤适合中小规模知识库。全文检索用rank_bm25纯 Python 实现不需要额外部署服务。Embedding 用sentence-transformers加载本地模型避免依赖外部接口。4.2 文件类型路由一个分发器搞定整个入库流程的入口是一个文件类型分发器根据扩展名和内容特征决定走哪条处理链路。import os def route_file(file_path): ext os.path.splitext(file_path)[1].lower() if ext in [.md, .markdown]: return markdown elif ext .pdf: return pdf elif ext in [.xlsx, .xls, .csv]: return table elif ext in [.pptx, .ppt]: return slides elif ext in [.docx, .doc]: return docx elif ext in [.txt]: return text else: return unknown分发之后每种类型调用对应的解析和切分函数统一输出成{text, metadata}的 chunk 列表。metadata 里至少包含来源文件名、章节路径、页码、类型。这些元数据在检索时能做过滤比如用户问某个文件里的内容可以限定source xxx.pdf。4.3 统一 chunk 结构与元数据设计不管什么文件最终都归一化成同一个结构这样后续入库和检索逻辑就能统一。def make_chunk(text, source, section, pageNone, ctypetext): return { text: text, metadata: { source: source, section: section, page: page, type: ctype } }section字段存章节路径比如第三章 3.2 付款方式。这个字段在检索时特别有用因为用户的问题经常和章节标题相关可以拿 query 和 section 做一次额外的匹配加权。4.4 双路索引写入切分完成后同时写入向量库和 BM25 索引。import chromadb from rank_bm25 import BM25Okapi def build_index(chunks, embed_model): client chromadb.PersistentClient(path./rag_db) collection client.get_or_create_collection(knowledge) texts [c[text] for c in chunks] metadatas [c[metadata] for c in chunks] ids [fchunk_{i} for i in range(len(chunks))] # 向量索引 embeddings embed_model.encode(texts, normalize_embeddingsTrue) collection.add( documentstexts, embeddingsembeddings.tolist(), metadatasmetadatas, idsids ) # BM25 索引中文需要先分词 tokenized [list(t) for t in texts] # 简化处理实际用 jieba bm25 BM25Okapi(tokenized) return collection, bm25, texts中文 BM25 需要分词实际项目里用jieba切词。这里为了代码简洁用了字符级切分效果会差一些正式用一定要上分词器。4.5 混合检索与 RRF 融合检索阶段向量和 BM25 各召回一批然后用 RRF 融合。def hybrid_search(query, collection, bm25, texts, embed_model, top_k5): # 向量召回 q_emb embed_model.encode([query], normalize_embeddingsTrue) vec_results collection.query(query_embeddingsq_emb.tolist(), n_results20) vec_ids [int(i.split(_)[1]) for i in vec_results[ids][0]] # BM25 召回 tokenized_query list(query) bm25_scores bm25.get_scores(tokenized_query) bm25_ids sorted(range(len(bm25_scores)), keylambda i: bm25_scores[i], reverseTrue)[:20] # RRF 融合 rrf {} for rank, idx in enumerate(vec_ids): rrf[idx] rrf.get(idx, 0) 1 / (60 rank) for rank, idx in enumerate(bm25_ids): rrf[idx] rrf.get(idx, 0) 1 / (60 rank) top_ids sorted(rrf, keyrrf.get, reverseTrue)[:top_k] return [texts[i] for i in top_ids]RRF 里的常数 60 是经验值来自原始论文一般不用改。它的作用是平滑排名靠前和靠后的权重差异避免某一路的绝对分数主导结果。4.6 参数选择与计算过程几个关键参数的取值逻辑chunk 大小我一般设 500 到 800 字。这个范围是基于 Embedding 模型的有效上下文长度和大模型阅读能力综合定的。太短语义不全太长语义稀释。中文一个字大约对应 1.5 个 token800 字约 1200 token在大多数模型的最佳区间内。overlap语义切分下 overlap 可以设得很小甚至为 0因为切分点本身就是语义边界不需要重叠来补救。固定切分才需要 10% 到 20% 的 overlap。召回数量向量和 BM25 各召回 20融合后取 5。召回 20 是为了保证正确答案大概率在候选集里取 5 是为了控制给大模型的上下文长度。如果知识库特别大可以提到各召回 50。RRF 常数60经验值不用调。5. 常见问题与排查技巧实录5.1 检索不准的排查顺序遇到检索不准别急着换模型按这个顺序排查先看命中的 chunk 长什么样。把检索结果打印出来如果 chunk 本身是残缺的、乱码的、语义不完整的那问题在入库不在检索。再看正确答案的 chunk 在不在库里。如果压根没入库或者被切碎了那是切分问题。然后看正确答案的 chunk 排在第几。如果在前 20 但没进前 5那是排序融合问题调 RRF 或加 rerank。最后才考虑换 Embedding 模型。这个顺序能帮你省下大量瞎折腾的时间。我见过太多人跳过前三步直接换模型结果问题依旧。5.2 常见问题速查表现象可能原因排查方法解决检索结果全是页眉页脚页眉未过滤看命中 chunk 内容统计高频行并丢弃表格问题答不出表格被切碎检查表格 chunk表格整块抽取型号/编号检索不到纯向量检索看是否命中关键词加 BM25 混合检索跨章节问题答不全chunk 太碎看命中 chunk 数量适当增大 chunk 或加父文档检索相似问题召回错误文档语义稀释看 chunk 长度缩短 chunk 或语义切分中文检索效果差未分词检查 BM25 分词上 jieba 分词5.3 几个独家避坑技巧技巧一给 chunk 加来源前缀。在 chunk 文本前面拼上文件名和章节标题比如[产品手册.pdf 第三章 参数] 额定功率为...。这样即使检索到多个相似 chunk大模型也能根据来源区分。实测能减少张冠李戴式的错误回答。技巧二表格和正文分开存但共享来源。表格 chunk 和正文 chunk 用同一个 source 元数据检索时可以按 source 聚合保证一个问题的答案来自同一份文件。技巧三定期做检索回归测试。维护一个 50 到 100 条的问题集每次改动入库方案后跑一遍看命中率变化。没有这个测试集你根本不知道改动是变好还是变坏。技巧四警惕过度切分。有些人为了追求精准把 chunk 切到 100 字结果语义全碎了。切分的目的是对齐语义边界不是越小越好。5.4 关于 RAG 瓶颈的一点个人判断现在网上讨论 RAG 瓶颈很多人往 Agentic RAG、GraphRAG、本体 RAG 这些方向找答案。这些方向确实有价值但它们解决的是多跳推理知识关联这类高阶问题。而绝大多数人遇到的 RAG 问题还停留在最基础的检索不到正确答案层面这个层面的瓶颈九成在入库。我个人的经验是把入库方案做扎实能解决 80% 的检索不准问题。剩下的 20% 里一部分靠混合检索和 rerank 解决真正需要上 GraphRAG 这种复杂方案的场景其实很少。别被新概念带偏了节奏先把地基打牢。6. 不同文件入库方案速查与落地建议6.1 一张表看懂分类型方案文件类型语义单元切分策略特殊处理推荐 chunk 大小Markdown标题层级按标题切超长内部再切标题拼入正文500-800 字电子版 PDF段落/章节按块坐标排序后切过滤页眉页脚500-800 字扫描版 PDF段落OCR 后行合并再切段落还原500-800 字表格行一行一 chunk拼表头单行长度PPT单页一页一 chunk图片单独处理整页图片描述多模态生成描述存 URL 元数据描述长度6.2 落地时的优先级建议如果你现在手上有一个效果不好的 RAG 知识库别想着一次性全改。按这个优先级来先处理表格和 PDF这两类文件最容易出问题改完收益最大。然后给所有 chunk加上来源和章节元数据这一步成本低、收益稳。接着引入混合检索解决关键词召回问题。最后再考虑换 Embedding 模型和上更复杂的方案。这个顺序是我踩了无数坑之后总结出来的每一步都能带来可测量的提升而且不依赖任何花哨的新技术。6.3 后续可以扩展的方向入库方案做扎实之后如果还想进一步提升有几个方向值得试一是父文档检索检索时用小块匹配返回时给大块上下文二是rerank 模型在混合检索之后加一层精排三是查询改写把用户的口语化问题改写成更适合检索的形式。这些都是在好地基上的锦上添花顺序不能反。我在实际项目里最深的体会是RAG 这件事功夫在诗外。大家盯着模型和算法看但真正决定效果的往往是那些不起眼的入库细节——一个页眉有没有过滤、一个表格有没有整块保留、一个标题有没有拼进 chunk。这些细节做对了用最普通的模型也能跑出很好的效果做错了用最贵的模型也救不回来。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →