RAG检索不准?先检查入库:文本切分与元数据设计实战
先别急着骂向量模型。做RAG的人十有八九都经历过这种崩溃时刻文档明明入库了问一个稍微具体点的问题检索回来的片段就是驴唇不对马嘴。于是你开始怀疑向量模型不够强点积、范数、叉积公式翻来覆去研究甚至想把embedding模型从base换成large从开源换到闭源折腾一圈下来hit rate纹丝不动。我见过不少团队卡在这一步。他们没意识到RAG检索不准九成的锅根本不在向量而在入库。什么叫入库就是你手里那些PDF、Word、Excel、HTML、代码仓库要被拆成什么样的“碎片”塞进向量数据库。这个碎片切得好不好、是否保留了上下文、有没有带够元数据决定了检索环节能捞回什么材料。向量模型干的事只是把你的碎片变成一串数字坐标。如果碎片本身就是一堆乱序的句子坐标再准也是白搭。这篇内容要解决的正是“不同文件到底该怎么入库”的问题。我会把文本型PDF、扫描件、Office文档、表格、代码这些常见类型的入库策略逐一拆开讲再给一套可以直接抄作业的流水线设计思路顺带把我踩过的坑、调参心得和排查方法都放出来。适合正在做RAG落地、被检索质量折磨的工程和算法同学参考也适合刚入门、准备搭知识库的人搞明白底层逻辑。1. 先理清一条线RAG检索结果差到底差在哪一环1.1 向量化只是入库流水线上的一个环节很多人对RAG的理解是文本拆成小段 → 调embedding模型转成向量 → 存进向量数据库 → 用户提问时把问题也转成向量 → 算相似度召回。这个理解没错但它把链路严重简化了。真实的RAG链路应当是文档解析 → 清洗去噪 → 结构切分 → 向量化 → 索引写入 → 检索召回 → 重排过滤 → 交给LLM生成。向量化只是这条流水线中间的一步它前后都有大量决定胜负的细节。拿“向量化”这一步本身来说它做的是把一段文本映射到高维空间里的一个点。两个点之间的距离越近语义就越相似。这里用到的基础数学其实不复杂——向量点积衡量方向一致性范数衡量长度叉积在三维空间里才用得比较多文本向量的相似度计算基本用点积或余弦距离就行。我见过一些人对着这些公式纠结半天以为理解了它们就能提升检索效果——方向完全错了。真正该关注的是上游文本进向量模型之前是以什么形态进去的如果进去的是一段被拦腰截断的合同条款向量模型只会老老实实把这段残缺信息编码成坐标它不会帮你把丢失的主语、被切断的表格行重新补全。所以当检索不准时先别急着骂向量回去看看入库这一步。1.2 真正影响召回的三个隐性因素以我的经验影响召回效果的三个隐性因素排序是这样的文本切分质量、元数据密度、检索侧的query处理能力。向量模型的权重确实存在但优先级远没有前三者高。文本切分质量这个最好理解。同样是切分按固定字符数硬切和按章节语义边界切召回效果能差出好几个量级。举个例子一份产品合同中写“验收标准详见附表A”附表A正好是一个表格里面按行写了各项指标的验收值。如果采用无脑的固定长度切分表格可能被从中间劈开前半段进了chunk_A后半段进了chunk_B。用户问“验收延时不能超过多少毫秒”检索器在chunk_A里只找到表头和残缺的几行在chunk_B里找到后半截但没有表头说明这些数字指的是什么指标。两半都无法给出正确答案。元数据密度则决定了过滤器能不能发挥作用。很多入库方案只把文本内容放进向量库文件名、章节、页码、文档类型全扔掉了。检索时想限定“只看某某合同里的内容”结果没有过滤条件可用只能在全库范围里盲找。范围一大噪音自然就多。query侧的处理则是另一个话题后面我会单独展开。这里只强调一点如果入库侧的命中了已经有明显提升但query本身表达模糊可以考虑加一层查询改写或HyDE让大模型先根据query生成一段假想文档拿这段假想文档去检索。这与入库方案相辅相成但不能替代入库。我自己在一个合同问答项目里做过一次前后对比embedding从bge-small换到bge-large-zhhit rate提升了大概3个百分点后来把切分策略从固定500字符改成按层级切分又补全了元数据hit rate从54%直接拉到82%。从那以后我遇到检索不准的项目第一反应永远是打开入库代码而不是打开embedding模型文档。2. 不同文件就该有不同的入库方案2.1 先给文件分个类“入库方案”这四个字听起来玄落到具体操作上就是三件事怎么提取文件内容、怎么切分文本、怎么设计元数据。不同文件的结构差异巨大一套方案走天下一定出问题。我习惯先把文件分成七个类型文本型PDF、扫描/图片型PDF、Word与Markdown、HTML、Excel与CSV、代码文件、音视频。每个类型对切分和元数据有不同的策略先看总览表文件类型推荐处理方式核心元数据最容易踩的坑文本型PDFpdfplumber提取 按章节切分page、heading、doc_title双栏阅读顺序错乱扫描/图片型PDFOCR 版面分析page、bbox、ocr_conf表格结构被拍平Word/DOCX按段落 Heading层级切分heading_level、doc_title表格、批注残留Markdown/HTML按标题层级切分heading、url、lang导航栏、页脚噪音Excel/CSV按Sheet 行列重组描述sheet_name、col_names单元格被逐行拆断代码仓库按AST函数/类切分file_path、language、symbol缩进和换行被吞掉音视频/图片ASR转写 / 多模态向量化duration、transcript只有画面没有语义这张表是我做项目时的通用起点。下面每个类型挑重点说。2.2 文本型PDF警惕双栏和表格文本型PDF是最常见的入库对象也是最容易处理出问题的。核心难点有两个双栏排版和表格结构。双栏问题多出现在论文和技术报告里。PDF的读取顺序是按物理坐标从上到下、从左到右进行的如果一份论文是双栏排版直接提取出来的文本顺序会变成左栏上方一截、右栏上方一截、左栏中间一截、右栏中间一截……阅读顺序完全错乱切分出来的chunk语义自然也是乱的。处理方法是先做版面分析layout analysis把每一页按坐标区域划分成块再按照阅读顺序重排。pdfplumber可以拿到每个文本块的坐标然后我用纵坐标排序、横坐标判断是否同一行把双栏拆成左右两列再依次拼接。对于大多数双栏论文这个简单策略够用。表格问题更隐蔽。PDF中的表格本质上是一堆带有坐标的线条和文字直接按行提取的话单元格内容会被拆得七零八落。我的做法是用pdfplumber的extract_table方法直接按表格结构提取把整个表格转成Markdown格式的字符串作为单独的chunk存入。这样检索时表格的行列关系完整大模型拿到之后也能顺利理解。2.3 扫描件别急着上向量先OCR扫描件PDF跟文本型PDF不是一个物种。它的页面是图片里面没有任何文本层直接提取出来要么是空字符串要么是乱码。必须先做OCR识别。OCR这块我用得比较多的是PaddleOCR中文识别效果稳。但OCR只是第一步更关键的是版面分析。扫描件里如果包含表格OCR会把表格内容按坐标输出成一段文本行和列的边界就丢了。所以对于扫描件里的表格我会在OCR之前先做版面检测识别出表格区域再用表格结构识别的方式处理。这里有个细节值得记OCR结果一定要保留置信度字段或者至少把它们当作元数据。如果识别置信度普遍较低这批文件就不适合直接走纯文本路线得考虑用视觉多模态模型来做描述性提取。别硬扛。2.4 Word与Markdown标题层级是天然的切分边界这类文件的优势在于它们自带了“骨架”——标题层级。Markdown是一、二、三级标题天然分层Word的样式表里也定义了Heading 1、Heading 2。切分时直接以标题为边界把每一个标题连同其下的正文段落作为一个chunk。这样做出的chunk在语义上是完整的而且自带章节上下文。举个实际例子一份操作手册有“第一章 安装”“第二章 配置”按标题切分后chunk的标题字段分别记录“第一章 安装”和“第二章 配置”检索时用户问“安装需要什么依赖”命中的就是第一个chunk标题元数据还能帮助大模型确认答案的来源。要注意的是Word文档里的批注、修订痕迹和嵌套表格。批注和修订痕迹如果不清除会被当作正文提取出来污染向量库。我处理Word时用的是python-docx只读取paragraph样式为Normal和Heading的段落主动跳过批注。2.5 Excel与CSV不要按行硬切要“带表头重组”Excel和CSV是最容易被当成纯文本处理的文件但把每一行独立切成chunk是灾难性的。比如一张员工信息表表头是“姓名、部门、入职日期、绩效等级”如果按行切分每个chunk就变成了“张三、技术部、2023-05-01、A”没有任何说明这些字段之间是什么关系。用户问“技术部有几个绩效A的员工”检索器可以捞到这些词但大模型无法确认字段语义。我的做法是把表头列名和每一行数据拼成一个描述性的句子例如“员工张三所属部门为技术部入职日期为2023-05-01绩效等级为A”。这种情况下一个sheet里的多行可以按业务相关性再分组切分同时把sheet名称和文件路径作为元数据保存。这样检索到某一行的描述时模型能立刻知道每个值的含义。2.6 代码文件用语法AST切分别用行号代码文件入库是很多研发团队会忽略的场景。把整个仓库当成纯文本切成固定长度的chunk会让一个函数被拆成两半注释和函数定义分离。稳妥的方案是用语法解析器比如tree-sitter把代码解析成抽象语法树按函数、类、方法为边界切分。切出来的chunk包含完整的函数签名、docstring和函数体。元数据里记录语言类型、文件路径、函数名。这样做有两个好处检索命中一个函数时返回的是一段逻辑完整的代码大模型直接引用时不至于是半截代码造成语法错误。2.7 图片、音视频以及用本体/GraphRAG组织知识图片和音视频在多模态时代也经常被纳入知识库。简单场景下图片可以直接交给多模态向量模型编码或者先走一遍OCR提取文字再走文本向量化。音频和视频一般先做ASR语音转写把转写文本当作常规文档入库。这里有个坑只转写不标时间戳用户问“视频里什么时候提到了这个方案”根本答不出来。所以元数据里要保留时间点将转写文本按语义切段的同时记录每段对应的起止时间。另外还要提一下图结构组织。常规向量库把知识切碎成相互独立的chunk但知识本身是有联系的。现在比较热的GraphRAG、本体RAG思路就是把实体、关系抽取出来构建图谱检索时既走向量相似度也走图关系。这种做法在需要全局性、关联性回答的场景下比纯向量库强不少。不过它的复杂度和成本也高不建议刚上手就上先把入库基础打好再说。3. 实操一个“多类型文件知识库”的入库流水线3.1 第一步文档分类路由我接一个RAG项目时第一步永远不是写切分代码而是先写一个“文档分类路由”。它的作用很简单根据文件扩展名和内容嗅探决定走哪条处理管线。def route_document(file_path: str) - str: ext file_path.suffix.lower() if ext .pdf: # 打开PDF尝试提取前几个字符判断是否有文本层 with pdfplumber.open(file_path) as pdf: first_text pdf.pages[0].extract_text() or if first_text.strip(): return text_pdf return scan_pdf elif ext .docx: return word elif ext in (.md, .markdown): return markdown elif ext in (.html, .htm): return html elif ext in (.xlsx, .xls, .csv): return excel elif ext in (.py, .js, .java, .go, .rs): return code elif ext in (.png, .jpg, .jpeg): return image else: return generic这段代码的目的是把文件引到正确的处理函数上去避免后续所有逻辑都堆在一个大函数里。路由规则可以根据项目实际涉及的文件类型自由扩充。3.2 第二步按类型实现处理管线分类路由之后每种类型的处理逻辑应当尽量独立。这里我给出一个能直接用的骨架以PDF和Excel为例def handle_text_pdf(path: str): chunks [] with pdfplumber.open(path) as pdf: for page_num, page in enumerate(pdf.pages, start1): text page.extract_text() tables page.extract_tables() # 表格单独处理转成markdown后单独成chunk for table in tables: md_table table_to_markdown(table) chunks.append({ text: md_table, metadata: { doc_type: pdf_table, source: str(path), page: page_num } }) # 正文部分按段存储后续按章节聚合切分 if text: chunks.append({ text: text, metadata: { doc_type: pdf_text, source: str(path), page: page_num } }) return chunks def handle_excel(path: str): chunks [] xls pd.read_excel(path, sheet_nameNone) for sheet_name, df in xls.items(): columns 、.join(str(c) for c in df.columns) for _, row in df.iterrows(): row_desc f表格[{sheet_name}]中各列含义依次为{columns}。\n数据行 \ .join(f{col}为{val} for col, val in zip(df.columns, row)) chunks.append({ text: row_desc, metadata: { doc_type: excel, source: str(path), sheet: sheet_name } }) return chunks正文部分的切分我看着做。对于PDF正文我通常先按页提取然后识别页码中的标题样式字体大小、加粗把连续的段落按标题边界聚合。这个逻辑看起来简单但在真实文档上会碰到各种意外标题和正文用的字体一样大但颜色不同、页码是页眉的一部分混在正文里……所以每接一个文档源至少要在样张上跑一遍人工核对一次切分边界。3.3 第三步统一写入向量数据库切分完成后需要把每个文本块和元数据一起写入向量数据库。目前我本地实测用得最顺的是Chroma轻量、Python API顺手部署也省心。embedding既可以调用远程API也可以本地跑Ollama拉起bge-m3这类中文友好的向量模型。import chromadb from chromadb.utils import embedding_functions client chromadb.PersistentClient(path./kb_store) collection client.get_or_create_collection( namedocument_kb, embedding_functionembedding_functions.OllamaEmbeddingFunction( urlhttp://localhost:11434/api/embeddings, model_namebge-m3 ) ) for idx, chunk in enumerate(chunks): collection.add( ids[f{idx}], documents[chunk[text]], metadatas[chunk[metadata]] )写入时一个常见的返工点忘记带元数据或者元数据字段设计不合理。Chroma支持按metadata过滤检索时可以先通过where条件圈定文档范围再算相似度速度和质量同时提升。所以入库前先想清楚这个chunk来自哪里、属于哪个章节、位于第几页。这些信息比多换一次向量模型有用得多。3.4 第四步用“命中率”反向检验入库效果入库做完了不评估等于白干。我每个项目都会保留一份标准问题集比如50到100个来自业务真实场景的问题每条人工标注对应的标准答案和应该命中文档。跑评估时不看生成答案的质量先看检索命中率。检索回来的前5个chunk里只要出现包含标准答案关键词的chunk就算命中一次。这个指标能快速暴露入库方案的问题。我举个例子有一轮测试里很多问题返回的chunk都是同一批数据接口文档而业务合同里的内容很少被命中。后来检查发现合同PDF是双栏排版加扫描件混合类型之前只做了文本层提取部分页没有文本层就直接跳过了。补上OCR和版面重排后合同内容才开始频繁出现在召回结果里。如果不做评估只看线上反馈这个问题可能拖很久才能暴露。4. 常见问题与排查技巧实录4.1 切片过大过小到底怎么定切分长度没有绝对标准需要根据文档类型和业务问题类型来平衡。我自己的默认值是正文按章节边界切每个chunk控制在500到800个中文字符之间加200字符的窗口重叠。这个区间背后是两种错误倾向的折中。切太大例如超过1500字符通过率高的同时噪音也多。大模型再聪明扔给它一个涉及三个不同主题的长chunk它也容易答非所问。切太小比如只有一两句话召回可以命中很具体的问题但上下文语义缺失模型回答时会缺少背景信息。重叠窗口的意义在于避免一个完整语义单元恰好在边界处被劈开。拿合同条款举例一个条款可能300字前200字在chunk_A末尾后100字在chunk_B开头。设置重叠窗口后chunk_B开头可能包含前一个chunk末尾的150字完整句子最多被拆一次检索时依然能捞回来。4.2 表格入库后完全没法看怎么办最典型的表现是用户问一个表格里的数据检索回来的chunk是一行行的“张三|技术部|A”既没有表头也没有行列关系。我排查过几个项目发现问题通常出在表格提取这一步——有人用pdfplumber的extract_text把表格里的文字按坐标顺序生拼硬凑成了一个字符串。解决思路前面已经说了就是把表格转成Markdown格式保留“|”分隔符和表头行。这样不仅检索时关键词集中大模型看到Markdown表格后也能很快理解数值对应的列含义。如果表格非常复杂比如存在跨行跨列的合并单元格先用pdfplumber提取再对提取结果做一次后处理把空单元格用上方或左方的值补齐保证矩阵完整。4.3 扫描件OCR后还是一堆乱码扫描件OCR出来是乱码有几个常见原因原始图片清晰度太低、文字旋转了角度、表格线和字符粘连。最有效的干预手段是预处理先转成灰度图再做二值化必要时做倾斜校正。PaddleOCR自带版面分析和方向检测对大多数正常扫描件够用。如果OCR结果乱码比例还是太高需要走一个分水岭决策这批文档是否真的有文本检索价值。有些扫描件其实是盖章扫描正文内容在另一个电子版本里已经存在。与其和OCR死磕不如直接换更可靠的输入源。我做过一个项目客户提供了一批30年前的扫描档案OCR后错字率超过20%后来改用人工校对后的小结文本入库检索效果瞬间正常。入库方案的价值在于“按需选择”不是硬把所有东西塞进同一个管道。4.4 元数据过滤不生效检索反而变差我用Chroma踩过的坑之一是metadata的值类型没有统一。有的条目里source是字符串有的条目里的某个字段传成了None过滤时条件写死等于某个字符串部分文档被静默过滤掉。排查方法是入库后先用collection.get按metadata过滤一次看返回条数是否合理。如果返回为空优先检查写入时metadata的类型和大小写。Chroma对metadata的要求比较严格字段值只能是字符串、整数、浮点数或布尔值嵌套结构会被丢弃。另外要强调元数据过滤如果太严格会牺牲召回。比如用户提问没有明确指定文档来源过滤条件优先用宽松的比如只限制doc_type不要把所有维度都加上。4.5 检索不准时先别动向量做query改写和混合检索入库逻辑检查完之后如果检索还有偏差下一个方向是query处理。这其实就是前面提到的“从意图识别到检索语义”那一层。用户输入常常是口语化、含糊的比如“合同里那个百分比是多少”。这个问题缺少关键实体直接拿它做向量检索召回大概率是散的。我常用的处理是先用LLM做一次query改写把问题拆解成几个子检索式再逐个检索取并集。改写后的query往往更具体包含文档类型、实体、业务动词。另一个有效手段是混合检索在向量召回之外叠加一个BM25关键词召回然后把两路结果按RRF倒数排名融合重新排序。向量擅长语义匹配BM25擅长精确词命中两者互补后很多单路检索的漏召回问题会消失。我自己在本地知识库项目上把这种混合检索架构固定下来之后hit rate的稳定性明显比单靠向量高不少。再往上走还有agentic rag的思路——让智能体在一次会话里多次检索、用上一轮结果修正下一轮query适合复杂多步问题但依赖前面入库基础够扎实。4.6 这些坑踩完之后我现在的入库流程长这样我把这套流程沉淀成一个固定的模板后续每个新项目先套用再按文档类型微调。第一步永远是分类路由第二步按类型走独立处理器第三步统一写元数据规范第四步跑测试集评估。这四步走完绝大多数项目的检索命中率都能到达业务可用线。如果让我再提一个建议元数据规范一定要在项目早期就定下来而不是想到一个字段加一个。我见过一个项目库里有两种写法混着“source”和“来源”并存后面每次检索都要做字段归一。浪费时间不说还容易漏。把字段名、类型、含义写在一个简单的表格里挂在项目文档开头比任何架构设计都实用。做RAG这么长时间我的体会是向量模型像个翻译你给它什么材料它就翻译什么材料。材料本身是碎纸片翻译再精准也拼不出完整的故事。先花时间把入库方案打磨到位让进入向量库的每一块文本都有清晰语义、完整上下文和足够的元数据检索效果自然就会上来。这个顺序不能反。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →