做了这么多年RAG落地项目,我早就习惯了一个现象:系统答错问题,第一反应就是换模型。有的团队在模型上花了不少预算,又是调prompt又是试微调,结果准确率还是上不去。我的实际体验是,RAG系统答错,有相当大比例的问题根本不在模型推理阶段,而是文档在进入系统之前就已经“坏”了。这个“坏”不是文件损坏,而是原始文档从形态到内容,在解析、清洗、切块、向量化这一整条前置链路里,信息已经丢失、错乱,或者压根没被正确提取出来。
RAG的全称是检索增强生成,但检索的前提是“备料”。你给模型喂的语料如果不干净、切得不合理、格式解析不到位,那模型就算再强,也只能在残缺文本上凭空猜测。我见过一个真实案例:某企业把产品手册丢进知识库,系统每次回答参数都会错一位小数,排查了半天,发现是PDF里的表格在解析时串了列。这种问题,换什么模型都救不回来。
所以这篇文章不聊模型选型,不聊向量数据库怎么搭,就聚焦“文档进入系统之前”这段路:文件解析、数据清洗、表格图片处理、文本切块、元数据标注,以及当RAG答错时怎么一步步定位是不是这些前置环节出了问题。无论你是准备在Mac上自己搭一个本地RAG知识库,还是正在做一个正经的RAG项目,这些内容都能直接拿过来用。
1. 先别急着甩锅给模型:RAG链路里“看不见”的第一公里
1.1 RAG不是“模型问答”,而是“数据管道+模型”的组合系统
很多人对RAG的理解是“给模型挂一个知识库,然后它就能回答问题”。但这个理解容易让人忽略一整条链路:原始文档 → 解析抽取文本 → 清洗去噪 → 文本切块 → 向量化嵌入 → 索引存储 → 检索召回 → 拼接上下文 → 模型生成答案。
在这条链路里,模型只占最后一小段。前面一大段都是“文档进入系统之前”的活儿。如果打个比方,把RAG比作开饭店,模型是大厨,文档预处理就是买菜、洗菜、切菜。菜没洗干净、切得乱七八糟,大厨再厉害也炒不出好菜。检索结果本身是脏的、偏的,生成阶段自然跟着错。
我在实际项目里发现,大多数回答质量问题,追根溯源都能追到某个前置环节。比如PDF里的文字本身是图片,没做OCR就直接向量化了,结果向量库里存的全是乱码;再比如文档里有不少水印、页眉页脚、目录页码,这些噪声被一起切进chunk,检索出来的top-k结果全是无关信息。这些问题扔给模型去“理解”,模型当然一头雾水。
1.2 文档进入系统之前,到底包含哪些环节
把“文档进入系统之前”拆开看,至少包含以下几步:
- 格式识别与解析:把PDF、Word、HTML、PPT、扫描件等转成纯文本或结构化文本。这一步最容易出错,PDF看似简单,内部却可能包含文字图层、图片图层、表格边界、复杂排版。
- 内容清洗:去掉页眉页脚、页码、水印、表格边框符号、重复标题、超链接、脚本标签等噪声。
- 特殊内容处理:表格要不要转成Markdown?图片要不要OCR或生成文本摘要?跨页断句怎么合并?
- 切块与关联:按什么逻辑切成小块,切完是否保留上下文衔接,是否补充标题或摘要作为元数据。
- 向量化入库前检查:向量化之前抽样检查切好的文本块,确认没有乱码、截断、丢字。
很多团队把这几步压缩成“读一下PDF然后直接embedding”,这就是问题源头。后面几章,我会把每个环节逐一拆开讲,包括能直接抄的参数经验和踩坑记录。
2. 文档进系统前最容易翻车的三个地方
2.1 解析环节:PDF不是“文字文件”,而是“排版文件”
PDF是一个排版格式,不是一个文本格式。一句话在PDF里可能被打散成多个文本框,也可能因为字体编码问题变成乱码,更常见的是扫描件——整页就是一坨图片。所以我处理PDF的第一原则是:先搞清楚这份PDF是文本型还是扫描型。
- 文本型PDF:直接用pymupdf(fitz)、pdfplumber、camelot提取文字与表格。
- 扫描型PDF:必须先做OCR,我常用PaddleOCR或Tesseract,中文场景PaddleOCR效果更好。
实操经验:文件到手后,先随机抽几页看看提取结果。如果提取出的文本里出现大量缺字、乱码、英文夹杂,多半是字体嵌入或编码问题,别硬调提取库,换个方案比如OCR重排反而更快。
| 场景 | 推荐工具 | 备注 |
|---|---|---|
| 文本型PDF快速提取 | pymupdf / PyMuPDF | 快,适合大量文件 |
| PDF表格提取 | pdfplumber / camelot | 表格会保留行列结构 |
| 扫描件文字识别 | PaddleOCR / Tesseract | 中文推荐PaddleOCR |
| 混合格式整体解析 | unstructured / docling | 能识别标题正文结构 |
举个例子,用pymupdf提取文本很简单:
import fitz doc = fitz.open("manual.pdf") for page in doc: text = page.get_text() if len(text.strip()) < 20: print(f"第{page.number + 1}页疑似扫描件,需走OCR")这段代码本身不复杂,核心价值在于那个“字符数阈值”判断。一页文本少于20个字符,基本可以断定是图片页或者扫描页,就不要再继续用纯文本方案了。
在Mac上搭建RAG知识库,完全可以用本地工具完成解析。装一个docling,它能把PDF、Word、HTML统一解析成Markdown,结构化程度比直接抽纯文本好得多,后续切块也方便。不需要非得上一套重型平台。
2.2 清洗环节:网页、Word里藏着看不见的脏数据
网页转文本时,最常见的问题是把导航栏、页脚、cookie弹窗、脚本内容一起抓进来。Word文档里也有大量看不见的隐藏字符、修订记录、文本框残留。这些脏数据进入向量库,不但浪费存储,还会在检索时“以噪生噪”。
我的清洗习惯是逐项过滤:
- 删除页眉页脚、页码标识、目录页和封面页。
- 合并空行,去除非ASCII控制字符、零宽空格、emoji。
- 网页HTML解析时,直接用BeautifulSoup的
get_text(separator='\n'),并剔除script、style、nav、footer等标签。 - Word转文本时,优先转成Markdown(pandoc或mammoth),保留标题层级,后续切块可以直接利用标题结构。
HTML清洗我一般这样处理:
from bs4 import BeautifulSoup def html_to_clean_text(html): soup = BeautifulSoup(html, "html.parser") for tag in soup(["script", "style", "nav", "footer", "aside"]): tag.decompose() text = soup.get_text(separator="\n") lines = [line.strip() for line in text.splitlines() if line.strip()] return "\n".join(lines)清洗做完,再做一道“人工抽检”:随机抽5页转出来的文本,肉眼确认有没有残留噪声。这道抽检成本很低,但能省下后面向量化和排查的大量时间。
另外回应一个常被问到的问题:有没有本地的RAG文本拆解工具?我的答案是没有“万能工具”,但可以按格式组合一套pipeline。入口判断文件类型,PDF走pymupdf加OCR,Word走mammoth,HTML走readability或上面的BeautifulSoup方案,最后统一转成Markdown或纯文本。如果不想自己维护,unstructured和docling都是开箱即用的本地方案。
2.3 表格和图片:RAG知识库能不能存图片?
有人问“RAG知识库能存储图片吗”。直接说结论:RAG知识库本身不能直接存图片,向量库里存的仍然是文本向量。但图片里的信息完全可以通过下面几种方式进入知识库:
- 文字型图片:OCR提取图片中的文字,把文字作为文本块入库。适合包含文字信息的截图、扫描件。
- 视觉信息为主的图片:用多模态模型生成图片描述,把描述文本入库。适合产品图、流程图、界面截图。
- 文档配图:更好的做法是把图片和标题、上下文说明一起保存,检索结果里返回图片路径作为参考。
表格的处理比图片更微妙。表格不能被简单“压平”成一行纯文本,否则“参数A对应值B”这种对应关系会完全丢失。我的做法是:把表格转成Markdown或HTML结构保留,切块时单独识别表格区域,让表格作为一个整体进入向量库。用llama_index里的markdown reader或unstructured的table处理都能做到这一点。如果表格很大,可以按行拆成若干子表,但每个子表前面要带表头描述。
对于RAG项目,影响检索质量最大的往往不是“存了什么”,而是“以什么结构存进去”。图片和表格尤其如此。这部分放到第四章继续展开,先看更核心的切块问题。
3. 切块策略:一个被低估的RAG瓶颈
3.1 为什么切块方式比模型更影响回答质量
RAG的检索单元是chunk,不是整个文档。模型回答问题,看的是检索回来的几个chunk拼接成的上下文。如果chunk太大,一个块里塞了太多无关内容,向量化后主题不聚焦,检索精度下降;如果chunk太小,关键信息被截断,上下文不完整,模型没法理解。
我见过有人把整份50页文档直接做成一个chunk,也见过有人把一个句子切成一个chunk,效果都不好。切块本质上是“上下文完整性和语义聚焦度之间的平衡”。
热词里有人提到“滑动窗口”这个概念,其实切块时的overlap就是一种滑动窗口思想。相邻两个chunk之间保留一部分重叠文本,避免关键句恰好被切在边界上。这个设计对检索召回率的提升非常明显,尤其当原文中存在大量长句时。
3.2 常见切块方案对比与参数选择
先说参数起点:
- 中文场景,chunk size建议从300到500字起试,别一上来就上1000。
- overlap建议为chunk size的10%到20%,既能保持衔接,又不会造成太多重复。
- 英文文本按token算更合适,chunk size约200到400 token。
切块方案按内容结构调整:
- 固定字符切分:简单粗暴,按字符数切,适合日志、聊天记录这类无结构文本。
- 递归字符切分:先按段落,再按句子,再按字符,尽量让每个块落在自然边界上。LangChain的RecursiveCharacterTextSplitter就是干这个的。
- 结构感知切分:利用Markdown标题、PDF章节、HTML标题切块,每个块天然有章节归属,检索质量明显更高。
- 语义切分:用embedding计算句子相似度来聚类切块,效果更好但耗时长,适合精调阶段。
我在项目里最常用的是“结构感知切分加固定块大小兜底”:先按Markdown标题把文档切成章节,再对超长章节递归切分。切完之后给每个块补上章节路径作为元数据,这样检索时就算命中的是第三层小标题下的内容,也能知道它属于哪个大章节。
3.3 一个可复用的切块实操示例
以Mac本地环境为例,用Python写一个简单但有效的切块逻辑:
import re def split_by_markdown_headers(text: str) -> list: """按 Markdown 标题切分,返回 (章节路径, 文本) 列表""" lines = text.splitlines() chunks = [] current_path = [] buffer = [] def flush(): nonlocal buffer if buffer: chunks.append(("/".join(current_path), "\n".join(buffer).strip())) buffer = [] for line in lines: m = re.match(r"^(#{1,6})\s+(.*)", line) if m: level = len(m.group(1)) title = m.group(2).strip() flush() current_path = current_path[:level] while len(current_path) < level: current_path.append("") current_path[level - 1] = title else: buffer.append(line) flush() return chunks这个函数把文档按Markdown标题拆成带层级路径的块。实际使用中,再用一个字符边界逻辑把超长块二次切分,并加上overlap:
def split_long_chunk(text, chunk_size=400, overlap=80): if len(text) <= chunk_size: return [text] chunks = [] start = 0 while start < len(text): end = start + chunk_size chunks.append(text[start:end]) if end >= len(text): break start = end - overlap return chunksoverlap取多少?我一般先定chunk_size的15%,然后抽几条边界句子人工看:如果发现很多句子被从中间截断,就加大overlap;如果发现检索结果里重复内容太多,就减小。调参没有银弹,只能基于抽样观察。顺便说一句,如果用的是LlamaIndex或LangChain,这些切块器都内置了,但手写一遍能让你更清楚参数到底在控制什么。
4. 结构化与非结构化:知识库类型如何决定RAG的上限
4.1 知识库、RAG知识库、结构化知识库各是什么
有人问“kg知识库、rag知识库和结构知识库如何区分以及各自的应用场景”,这里用一个表格说清楚:
| 类型 | 存储形态 | 检索方式 | 擅长的内容与应用场景 |
|---|---|---|---|
| 普通知识库/文件库 | 原始文档、网页、附件 | 关键词/全文检索 | 文件归档与浏览 |
| RAG知识库(向量知识库) | 文档切片后的embedding向量 | 向量相似度检索 | 非结构化文档问答、客服助手 |
| KG知识库(知识图谱) | 实体、关系三元组 | 图查询/关系推理 | 多跳问答、关系查询、一致性要求高的业务 |
| 结构化知识库(数据库表) | 表格数据行 | SQL/接口查询 | 订单、物料、指标等明细数据 |
简单说,如果回答“产品A有哪些参数”,RAG知识库就够了;如果回答“产品A和产品B共同参与的流程有哪些中间节点”,那是知识图谱的场景。很多团队一开始就用RAG硬扛所有问题,扛不动了就去换模型,其实该换的是知识表示方式。
RAG知识库也有自己的边界:它擅长语义相似度召回,不擅长精确计算与逻辑推理。比如“上个季度总费用是多少”这种聚合性问题,用RAG从文本里拼凑,十有八九会出错;这时候应该走结构化数据库查询。
4.2 给切块结果补上“元数据”,让检索更准
文档进入系统之前,真正拉开差距的是元数据。同样是两个chunk,一个有元数据“来源:产品手册/空调系列/能效参数”,一个只有裸文本,检索质量和下游展示效果完全不同。
我建议每个chunk至少包含这些元数据:
- 来源路径(文件路径或URL)
- 一级/二级章节标题
- 文档类型(说明书、FAQ、合同、代码文档等)
- 语言
- 更新时间或版本号
- 如果是表格来源,注明table类型
在Mac本地搭建RAG知识库时,用LlamaIndex的MetadataAwareSplitter,或者手动在embedding前给元数据字段赋值都能做到。检索时这些元数据可以作为过滤条件,也可以拼进上下文里,让模型知道文本块来自哪里。比如检索命中了能效参数那一节,把“来源:产品手册/空调系列/能效参数”拼到chunk前面,模型回答时就不会乱发挥。
4.3 针对不同内容类型(文档、表格、长文)的预处理方案
我给的通用方案是“先分类,再处理”:
- 自然语言文档(Word、Markdown、HTML正文):清洗后按章节切块,保持结构。
- 表格/数据文件(Excel、PDF表格、CSV):转成Markdown或JSON,行数多的拆成子表,加表头描述。
- 图片/扫描件:先OCR成文本,再按文本流程处理;必要时用多模态模型补充视觉描述。
- 长文(技术手册、制度文件):用结构感知切块,并在块内保留上下文提示,比如“上文提及的产品型号为ABC-2000”。
- 代码片段(技术文档里的示例代码):不要用普通字符切分,保留代码块完整性,按函数或代码块切。
还有一个容易忽略的点:切块之后要检查“块内是否自洽”。也就是说,单独看这个chunk,读者能不能大致看懂它在讲什么。如果不自洽,回到全局补一句上下文引导语进去。这个做法对提升检索命中后模型回答的连贯性非常有帮助。特别是跨页断句这种问题,两页之间段落被拆开,切块时要把上一页的结尾和下一页的开头拼完整,别让句子断在半路。
5. RAG答错时的排查路线图:从数据倒推到模型
5.1 三步定位法:先查文档,再查切块,最后查生成
当用户反馈“答错了”,我的排查顺序是倒着链路走,但先查数据。
第一步,复现问题并固定问法。拿到一条错误回答,先记录问题原文,再看模型答的是哪一段话、检索命中了哪些chunk。很多RAG框架都支持打开检索明细,没有的话就自己打印检索结果。
第二步,检查检索命中的chunk。重点看两个指标:命中内容是不是真的与问题相关,命中的chunk是不是足够完整。如果命中内容驴唇不对马嘴,问题大概率在文档处理或向量化,而不是模型。用向量相似度检索工具单独查一下“和这个query最相似的内容是什么”,能快速定位是embedding模型的问题还是文档侧的问题。
第三步,检查chunk内容本身。如果检索结果相关,但模型回答依然错误,把命中的chunk拼起来自己读一遍。如果发现chunk里有乱码、截断、串列、噪声,说明解析清洗环节有漏洞。这一步看起来笨,却是最高效的定位方式。
5.2 常见问题速查表与排查工具
整理一张速查表,方便对照:
| 现象 | 大概率环节 | 检查建议 |
|---|---|---|
| 回答内容张冠李戴 | 表格解析串列 | 检查原始表格结构是否保留 |
| 回答漏关键参数 | 文本抽取丢字 | 抽检PDF提取文本 |
| 命中的chunk与问题无关 | 切块过大/embedding模型不适配 | 检查chunk size和相似度分数 |
| 多个问题答案都提到同一段噪声 | 清洗不彻底 | 查看chunk原文有没有页眉页脚水印 |
| 回答总是“找不到答案” | 文档未入库或切块不能自洽 | 查向量库是否存在、检索时是否被过滤 |
| 某些格式文件完全不回答 | 解析失败 | 直接跑一次解析脚本看输出 |
排查工具上,我常用的有:LangSmith追踪RAG链路中检索与生成明细;LlamaIndex的QueryEngineTracer;手动的话,Chroma或Qdrant自带检索预览,打开一眼就知道召回的是什么。别等到线上出了错才去翻日志,把可观测性提前埋好,能省大量时间。
5.3 分享一下我踩过的坑
最后说几个真实踩坑记录。
第一个坑:扫描版PDF没OCR就入库。结果知识库对这份文档的检索结果全是乱码。这个排查不难,难在很多人根本不知道文件是扫描件。我现在的流程是入库前统一做一次文本密度检查,一页提取出来的字符数小于阈值就自动标记为疑似扫描件,走OCR通道。
第二个坑:表格转纯文本后串列。某次做设备参数问答,系统把“功率:2000W”答成“功率:2000V”,原因就是PDF表格解析时同一行文本把两列合并了。后来改成先把表格区域识别出来,转成Markdown再切块,问题才消失。
第三个坑:盲目加大chunk size。一开始以为块越大模型信息越多,就把chunk size提到1000字以上,结果检索命中率明显下降,有的块融合了三个不同主题。后来改用结构感知切块,块大小降到400字左右,准确率反而上来了。
这些坑的共同点,都是模型之外的文档链路问题。所以当你觉得模型不够聪明的时候,先别急着换模型,去查一查文档进入系统之前发生了什么。很多时候,答案就藏在那些被忽略的预处理细节里。