☰
RAG工程化实践:Document建模、Chunk切分与Metadata Filter设计
2026/10/19 13:00:35 网站建设 项目流程

做 RAG 这十来天,我最大的感受是:网上教程里的 Demo 和真实业务里能跑的系统,完全是两种东西。Demo 阶段只要回答得“像那么回事”就行,工程化阶段则要回答“每次都能用”“数据再多也不乱”“权限边界不能破”。这篇是第 10 天的记录,主题是从 RAG Demo 走向工程化,重点拆解三个关键点:Document 的统一建模、Chunk 的切分策略、以及 Metadata Filter 的定向检索。无论你是想把公司内部文档做成问答助手,还是正在维护一个知识库检索项目,这篇文章都能给你一套可落地的设计思路。文章里的代码和参数都来自我这两周的实测,可以直接复制改改就能用。

1. 先想清楚:Demo 和工程化到底差在哪

1.1 大多数 Demo 的隐形天花板

我自己第一次写 RAG 时,流程非常简单:把一份 Markdown 文档读进来,按固定长度切块,塞进向量库,然后召回 Top-K 文本块拼进 Prompt,让大模型生成答案。整个链路半小时就能跑通,在自己那几份测试文档上表现相当不错,当时心里还挺得意,觉得“检索增强”也没多难。

但一旦把同样一套逻辑放到真实场景里,问题就一个接一个冒出来。首先是文档格式杂:这里有 PDF、有 Word、有扫描件、还有带排版痕迹的 HTML;其次是文档数量上去之后,向量检索召回的文本块经常来自完全不相关的位置——比如问“某项目的预算审批流程”,结果召回了同一批资料里另一篇关于“预算编制”的内容;再一个是隔离需求,同一个知识库里放着不同团队的资料,总不能让人问出另一个团队的内部数据。这三个问题,本质上都不是“把向量相似度调高一点”能解决的。它们暴露的是更早阶段的设计缺失:数据怎么建模、文本怎么切、检索时能不能带条件。

如果你现在还处在“塞几篇文档进去跑通”的阶段,我建议先别急着优化模型和 Prompt,而是回头把这层数据底座补齐。否则后面每加一类文档、每多一个用户,你都会发现自己是在给一栋地基歪了的楼补墙。

1.2 工程化要补的三块短板

对比了一下我前后两个版本的实现,工程化相对 Demo 至少要多补三块短板。

第一块是数据建模。Demo 里一份文档就是一个字符串,切完块就完事。工程化之后,每份文档必须有全局唯一的 ID、来源路径、标题、所属部门、文档类型、上传时间等信息,否则后面没法做权限过滤、没法做数据更新、也没法定位“这条回答到底来自哪一份原始资料”。

第二块是切分策略。固定长度切块简单粗暴,但会拦腰截断段落和表格;切太大召回粒度粗,切太小又丢失上下文。这块需要针对业务里的真实文档类型做调整,不能一套参数吃遍所有文件。

第三块是检索过滤。纯向量检索是“全局找相似”,但真实业务几乎总是需要“在一个范围内找相似”——比如只看某个部门、只看某一年、只看某类文档。没有 Metadata Filter,你就只能用“先召回再人工筛”这种笨办法,效率低且不稳定。

我把这三块分别对应到三个概念上:Document、Chunk 和 Metadata Filter。下面逐个展开。

1.3 为什么是这三件事

有人可能会问:工程化 RAG 不是还涉及向量库选型、Prompt 优化、模型微调、评估体系吗?确实都重要,但 Document、Chunk 和 Metadata Filter 是排在它们前面的数据链路问题。

打个比方:向量库是你的仓库,模型是你的售货员,而 Document 是入库时的商品登记信息,Chunk 是商品怎么分装摆放,Metadata Filter 是顾客结账时能不能按品类快速锁货架。商品登记混乱、分装不合理、货架不能按标签筛选,后面售货员再聪明也忙不过来。

我见过不少团队花大力气调 Prompt、换大模型,结果召回的还是脏数据,最后答案自然好不了。与其在末端打补丁,不如把前面这三层先做扎实。接下来我就按数据进系统的顺序,从 Document 开始讲。

2. Document:先把喂给模型的东西建模

2.1 一个统一的 Document 数据模型

做工程化第一件事,就是别再把“文档”当成字符串。我建议所有来源、所有格式的文件,在进入处理管线后,都归一化成同一个结构,核心就两个字段:正文内容和一个元数据字典。

class Document: def __init__(self, content, metadata): self.content = content # 清洗后的纯文本 self.metadata = metadata # 结构化元数据

这个设计看起来简单,但解决了几个实际问题。第一,下游所有模块——切分、向量化、存储、召回——都只依赖这一个结构,不用为 PDF 写一套、为 Word 再写一套逻辑。第二,metadata 可以在整个链路上被携带和传递,切分后的每个 chunk 都会继承文档级元数据,最后召回时才能知道这块文本属于哪个部门、哪一年、哪个文档。

我最初偷懒没做统一结构,直接用“文件路径+字符串”到处传,结果后期想加字段时,改一处就要牵动五六处代码。建议你从第一天就养成习惯:凡是进入 RAG 管线的外部数据,一律先转成 Document。

2.2 元数据字段怎么设计

Metadata Filter 的效果,完全取决于元数据设计得好不好。我的经验是:先梳理业务里会出现哪些筛选条件,再倒推字段,而不是想到什么加什么。

以企业内部知识库为例,一套比较通用的基础字段大致是:

字段名类型示例用途
doc_idstringdoc_20240121_001全局唯一,去重与溯源
titlestring项目预算审批办法展示来源标题
sourcestring/finance/docs/xxx.pdf定位原始文件
doc_typestringpdf识别格式
categorystring制度类业务分类筛选
departmentstring财务部权限与范围过滤
authorstring某部门同事责任归属
created_atdatetime2024-01-21时间范围过滤
page_numint3定位页码

需要注意,metadata 字段一旦设计好,后期迁移成本很高,所以最好在一开始就按“查询条件 + 展示溯源 + 运维标记”三类来规划。查询条件就是用户或系统未来可能用来筛数据的维度,展示溯源就是回答问题时需要给用户看的引用来源,运维标记则包括上传批次、解析耗时、切分版本等。

提示:不要把来源路径、内部文件名这类敏感信息直接拼进回答内容。展示给用户时,用映射表转成规范的文档标题和链接,避免路径泄露和 XSS 风险。

2.3 多格式解析的实操建议

归一化成 Document 的关键难点,是从各种文件里把正文提出来。我实测下来,没有一个库能通吃所有格式,必须要做分层处理。

我的策略是:文本类文件走一条路,PDF 单独走一条路,Office 和网页类再分别处理。文本类最简单,直接读进来按编码检测处理即可。PDF 则要看是电子版还是扫描版:电子版优先抽文本层,顺序乱了就换用按坐标提取的方式;扫描版必须先做 OCR,否则抽出来的全是空字符。Office 类用对应解析库抽取段落,必要时保留表格的单元格顺序。

这里最容易踩的坑是 PDF 的“文本顺序错乱”和“表格被拦腰截断”。我的经验是:解析之后不要急着切块,先做一轮清洗——合并断行、去掉页眉页脚、把疑似乱序的文本段按规则重排、对特别长的表格行做结构化拆解。清洗完之后,再进入 Chunk 环节。你宁可在这里多花半小时写清洗规则,也不要让脏文本一路流到检索结果里,因为召回阶段很难发现“文本本身是乱的”。

3. Chunk:切分粒度直接决定检索上限

3.1 参数是怎么定出来的

Chunk 是 RAG 里最容易被轻视、但对效果影响最大的环节之一。切分粒度直接决定了检索召回的“自然语义单元”是多大:切太大,一个块里塞了多个主题,查询向量一平均,相似度就被稀释了;切太小,每一块信息不完整,召回回来也回答不了问题。

常见的两个参数是 chunk_size 和 overlap。我建议按 token 而不是按字符数来算,因为大多数嵌入模型都是按 token 计数的。一个经验参考值:中英混合场景下,一个汉字大约对应 1 到 1.5 个 token,所以如果你用最大支持 512 token 的嵌入模型,chunk_size 设在 400 到 450 token 比较稳妥,留出余量给查询语句的 token 开销;overlap 设在 50 到 100 token,也就是差不多 10% 到 20%。

为什么一定要有 overlap?很简单:一句话可能前半段在上一块、后半段在下一块,如果没有重叠,切分边界上的信息就永久丢失了。但 overlap 也不能过大,否则相邻块内容高度重复,召回后拼接进 Prompt 时废话太多,还会造成“同一个答案被说了两遍”的观感。

3.2 三种切分策略的适用场景

我比较常用的切分策略有三种,适用范围不太一样。

第一种是固定窗口切分,按固定 token 数硬切,实现最简单,适合内容结构不强、统一以叙述为主的文档(比如新闻报道、技术博客文集)。缺点是会切断段落和句子,语义完整性一般。

第二种是递归结构切分,先按文档的天然层级(标题、段落、句子)一层层往下拆,直到子块小于目标大小,再对超大块做固定窗口兜底。这种策略对 Markdown、带标题的网页、培训资料这种“天然有结构”的文档效果最好,也是我日常的主力方案。递归切分本质上是在“尊重原文结构”和“控制块大小”之间找一个平衡点。

第三种是语义切分,试图根据句子向量的相似度判断语义边界,在“话题转换处”切块。听起来很美好,但代价是计算量大、延迟高、边界判断不一定稳定。我目前只在“文档主题切换非常频繁”的场景里考虑它,常规业务用第二种就够了。

3.3 一份可直接参考的切分实现

下面这个示例是我在项目里实际用过的递归切分思路,去掉了与具体框架的绑定,只保留核心逻辑。它的做法是:请求头列表按标题层级定位候选切点,优先在段落边界切分,若单块仍超限,再退回到句子边界,最后用固定窗口兜底。

def recursive_split(text, max_tokens=450, overlap_tokens=80): # 1. 先按段落边界粗切 paragraphs = split_by_paragraph(text) chunks = [] for para in paragraphs: if estimate_tokens(para) <= max_tokens: chunks.append(para) else: # 2. 超长段落按句子边界再切 sentences = split_by_sentence(para) buf = "" for sent in sentences: if estimate_tokens(buf + sent) > max_tokens: if buf: chunks.append(buf.strip()) buf = sent else: buf += sent if buf: chunks.append(buf.strip()) # 3. 后处理:给相邻块补充 overlap 文本 final_chunks = [] for i, chunk in enumerate(chunks): if i > 0: prefix = get_tail(chunks[i - 1], overlap_tokens) chunk = prefix + chunk final_chunks.append(chunk) return final_chunks

这里有两个细节值得说明。第一,overlap 我是在“切完块之后”再往前一块的末尾补文本,而不是在切分函数内部实时叠加,因为这样更不容易造成重复内容叠加。第二,estimate_tokens 我用了一个基于字符数加权估算的函数,实测在中文场景下误差在 10% 以内,够用了;如果追求精确,可以换用对应分词器的 tokenizer。

注意:切分后的每个 chunk 必须保留它所属文档的完整 metadata,还要额外加一个 chunk_index 字段,标记它在原文档中的顺序。排序信息对后续“把多个相关块按原文顺序拼接”非常重要,否则回答会被打乱成碎片。

4. Metadata Filter:从“全场扫描”到“定向召回”

4.1 为什么纯向量检索搞不定精准定位

纯向量检索的本质是“整个库找相似”。数据量小的时候问题不大,数据量一大,尤其是文档主题比较接近时,问题就来了:查询向量和几十个块都很相似,Top-K 里挤满了同一主题但不同部门、不同时期的内容。你以为自己在做定向问答,实际上模型是在“一堆相似材料里猜答案”。

最典型的例子是权限隔离。知识库里同时有“研发部”和“市场部”两份产品文档,用户只该看到自己部门的。如果不在检索阶段就过滤掉市场部的块,那么即使最终 Prompt 里做了“只根据给定资料回答”的限制,上下文里还是混入了不该出现的内容,既影响答案准确性,也带来数据越权风险。所以 Metadata Filter 不是“锦上添花”,而是工程化的安全底线。

4.2 先过滤还是后过滤,这是个关键选择

Metadata Filter 有两种实现位置:先过滤后检索(pre-filter)和先检索后过滤(post-filter)。

先过滤是在向量查询前,先在数据库层用元数据条件圈定候选集,再在这个子集里做向量相似度搜索。它的优点是过滤不掉召回精度,能真正避免无关数据干扰;缺点是需要向量库支持结构化条件过滤,并且过滤条件过严时候选集太小,可能召回不足。

后过滤则是先做一次完整的向量搜索,取回 Top-K 之后,在应用层把不符合元数据条件的块剔除,再补召回到目标数量。它的好处是实现简单,但隐患很大:如果 Top-K 里大部分都是被过滤掉的块,最后真正可用的候选所剩无几,回答质量直线下降。

我的建议是:把 pre-filter 作为默认方案,把 post-filter 仅作为“向量库不支持条件过滤”时的降级方案。真实业务里,检索精度和安全隔离这两点都太重要了,不值得为了省一点实现成本去冒风险。

4.3 一个能落地的筛选链路实现

在实际工程里,Metadata Filter 通常不是单个条件,而是一组条件的组合。比如“只看财务部 + 只看制度类 + 只选 2024 年之后的文档”。所以过滤表达式的设计要支持逻辑组合。

我习惯用类似 JSON 的结构来表达过滤条件,简洁且方便前后端传递:

{ "$and": [ { "department": { "$eq": "财务部" } }, { "doc_type": { "$eq": "制度类" } }, { "created_at": { "$gte": "2024-01-01" } } ] }

查询侧的逻辑也很直接:先把用户的自然语言请求解析出结构化的条件(比如通过分类模型或规则词表),拼成上面的过滤条件,再连同用户的查询文本一起传给检索层。检索层执行时,先在候选集上打过滤条件,再跑向量相似度排序。

这个过程里有两个我踩过的坑。第一个是字段类型不一致:把 created_at 存成了字符串,过滤时却拿日期对象去比较,导致结果为空或者全量返回。解决方法是入库时统一做类型校验,并在查询前把条件里的字符串做显式转换。第二个是“过滤太严导致召回不足”:比如某部门只有几十个块,再叠加两个条件后几乎没剩什么可检索。这种情况我会设计一个降级逻辑:当候选集数量低于阈值时,自动放宽部分非核心条件,并打上“检索范围放宽”的标记,让上层知道当前结果可能不够精确。

5. 常见问题与排查技巧实录

5.1 高频问题速查表

这两周把 RAG 从 Demo 往工程化推的过程里,我记录了不少典型问题。下面这张表是遇到频率最高的几种,以及对应的排查方向。

现象根因排查与解决
回答里出现明显不属于当前范围的内容未做 pre-filter 或过滤条件失效检查请求链路里过滤条件是否被正确传递,确认向量库过滤语法正确
同一段答案被重复说两遍overlap 过大导致相邻块内容高度重复调低 overlap 比例到 10%~15%,或在拼接前按 doc_id+chunk_index 去重
召回结果全是某个文档的块,其他文档几乎召不到chunk 数量分布极度不均,或切分粒度不合理检查各文档切块数,考虑按主题或章节重新切分
查询很明确,但召回内容答非所问chunk 切得太大,一个块里塞了多个主题减小 chunk_size,改递归结构切分,保证每块主题聚焦
过滤后结果为空字段类型不一致或条件语法错误打印实际过滤表达式和候选集数量,逐步拆解验证
解析后文本顺序错乱PDF 文本层坐标顺序问题改用按阅读顺序重排的解析策略,清洗阶段处理乱序片段

5.2 召回质量排查三板斧

很多人觉得 RAG 效果不好,第一反应是“换更大的模型”或者“调 Prompt”,但我建议先做一次召回质量排查。我的做法是三个步骤,成本低且效果明显。

第一步,把一次查询的完整链路日志打开:记录查询文本、过滤条件、召回块数量、每块对应的 doc_id、chunk_index 和相似度分数。这一步能直接告诉你“模型看到的是不是你想要的那些内容”。我遇到过不少“答案不正确”的案例,最终都定位在“该召回的块根本没召回”上,而不是模型能力问题。

第二步,针对同一查询,人工评测“期望召回块”和“实际召回块”的重合度。具体做法是:在测试集里为每个问题标注它应该命中哪几个 chunk,然后跑检索看这几个 chunk 是否出现在 Top-K 中。如果连续多次都不出现,说明切分策略或嵌入模型与业务语义不匹配,需要调整;如果命中了但回答还是不好,那问题才可能出在 Prompt 或生成环节。

第三步,用“最小复现”的方式二分定位。把知识库缩到只有几份文档、几十个块,手动检查每一块的切分边界和 metadata,看看是不是有问题数据被埋在了里面。我排查过的一个典型案例是:某批文档解析后所有 chunk 都继承了同一个错误的上传批次号,导致按批次过滤时永远返回空结果。这种问题在全量数据里几乎看不出来,缩小范围后一眼就发现了。

提示:建议从第一天就为 RAG 项目加上一个“请求日志”功能,把查询条件、召回详情、最终回答写到结构化日志里。它不会直接影响效果,但会在未来每一次排查时帮你省下大量时间。

5.3 一些小成本高收益的优化习惯

最后分享几个我在这一周里养成的、投入很小但收益明显的小习惯。

一个是在切分阶段就把“溯源信息”烙印进每个 chunk 的 metadata。这样线上任何一个回答都可以反查到它的原始文档、所在章节、大概页码,出问题的时候能第一时间找到源头,而不是对着答案猜数据来源。

另一个是每次改动切分参数或过滤规则后,都跑一遍固定的回归测试集。我的做法是维护了一份包含 20 个典型问题的测试集,每个问题标注了期望命中的 doc_id 范围。这样每次改完代码,跑一下召回命中率,数值掉了就说明改坏了。这比“感觉还行”可靠得多。

还有一个是不要把所有文档一股脑放进同一个集合。我目前按业务域拆成多个集合,每个集合配置独立的切分参数和过滤字段。这样“制度类文档”和“项目文档”可以各自用最合适的策略,互不干扰。

我个人在实际操作里体会最深的一点是:RAG 工程化的瓶颈往往不在模型,而在数据链路的规范性。Document 建模、Chunk 切分、Metadata Filter 这三件事做扎实了,后面换模型、调 Prompt 都只是增量优化;这三件事不做,再好用的模型也救不了混乱的数据。如果你正在从 Demo 往工程化过渡,我建议你先把这三层补上,回头再看检索效果,应该会有明显不一样的感受。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询