1. 先把“14 万页”这个数字拆开看:它到底难在哪
14 万页文档喂进大模型,24 小时涨了 4800 个星。这个组合我看了半天,第一反应不是羡慕,而是替做这件事的人捏把汗。给大模型建一个能用的 Wiki,也就是把一堆散落在硬盘、网盘、共享目录里的 PDF、Word、扫描件、网页存档,变成模型能查、能引、能说清楚出处的知识底座,这件事的 Demo 版本一个下午就能跑通,但做到“工业级”,中间隔着的活能把人熬瘦。我带过几个类似的知识库项目,规模从几千页到十几万页都有,踩过的坑基本重合,所以看到这个标题,脑子里浮现的全是解析队列、切分边界、召回率曲线这些东西,而不是“又有新玩具了”。
先说清楚这东西是干什么的。一份文档进来,系统要做的事是:把版面拆开、把文字抽干净、把表格和公式保住、按语义切成合适大小的块、给每块打上来源和位置标签、写进向量库和关键词索引、查询时双路召回、重排、拼进上下文、让模型带着出处回答。听起来是一条直线,实际上每一环都在丢信息,而最终答案的质量取决于这条链上最弱的那一环。这也是为什么很多人第一次做完 RAG 会失望:不是模型不行,是前面的管道在漏。
“工业级”三个字,我认为至少对应四个硬指标:质量可评测、更新可增量、成本可预测、故障可定位。少一个,就只能算内部玩具。下面我按这条主线,把 14 万页这个量级下的真实做法掰开讲。
1.1 三个量级门槛:页数、并发、更新频率
很多人被“14 万页”吓到,其实单纯从存储和算力看,这个量级一点都不大。我按常见的中文办公文档估一遍:一页 A4 正文大约 600 到 900 个汉字,取中位数 800 字,14 万页就是 1.12 亿字。中文一个字大约折算 0.6 到 0.8 个 token,取 0.7,总量在 7800 万 token 上下,算上一亿也够。
真正麻烦的是另外两个维度。第一是解析的异构性:14 万页里通常只有六成是规整的电子版 PDF,剩下四成里混着扫描件、双栏排版、跨页表格、手写批注、页眉页脚重复、公式图片化。第二是更新的持续性和并发压力:文档不会一次灌完就完事,业务侧每天都在改,用户查询还要在几百毫秒内返回。真正压垮项目的,往往是“某份关键制度文件更新了,旧版本还在被检索到”,而不是向量库装不下。
我把这几条摊开做个对照,你就明白力气该往哪使:
| 维度 | 表面难度 | 实际难度 | 主要成本花在哪 |
|---|---|---|---|
| 页数量级(14 万页) | 高 | 低 | 一次性解析与 embedding,千元量级 |
| 文档异构性 | 中 | 极高 | 解析路由、OCR、版面还原、人工抽检 |
| 增量更新 | 低 | 高 | 内容指纹、失效重建、索引切换 |
| 在线延迟 | 中 | 中 | 召回通道、重排模型、缓存策略 |
| 答案可追溯 | 低 | 高 | 元数据设计、引用定位、版本绑定 |
这张表我一般会拿给刚接手项目的同学看,目的很直接:别再纠结“向量库选哪个”了,那个决策的权重可能只占 5%。
1.2 为什么“能跑通 Demo”和“工业级”之间隔着一条河
Demo 的判断标准是“问三个问题有两个答对了”,工业级的判断标准是“问一百个问题,错的那几个我能解释清楚为什么错,并且知道怎么修”。这两个标准之间的距离,就是整个工程量。
我给你还原一个典型场景。某公司的内部知识库,用户问“出差住宿标准是多少”。Demo 版本能从向量库里捞到一段关于报销的文字,模型编一个看着合理的数字,用户信了,然后出事。工业级版本必须做到:召回的片段带源文档 ID、页码、章节路径、生效日期;如果有两个版本的制度同时存在,要按生效时间过滤;答案里的每个数字要能点回到原文;如果没找到,要明确说没找到,而不是硬编。这四件事,没有一件是靠换个更强的模型能解决的。
所以我看一个知识库项目靠不靠谱,先不看它用了什么框架,而是问三个问题:你的解析抽检率是多少?你的元数据里有没有版本和生效时间?你有没有一套至少 200 条的评测集?这三个问题答不上来的,基本还停在 Demo 阶段。
2. 整体架构设计:一条能扛住 14 万页的流水线长什么样
我不太喜欢一上来就谈框架选型,容易跑偏。先定分层,分层定完,每一层用什么东西是可替换的,这才是有长期价值的部分。我在多个项目里最后收敛下来的结构是五层:解析层、切分层、索引层、检索层、组装层。离线部分做重活,在线部分只做轻活,这条界限划清楚,后面 80% 的性能和成本问题都会自动消失。
2.1 五层分层:解析、切分、索引、检索、组装
解析层负责把任意格式变成结构化块。输入是 PDF、DOCX、HTML、Markdown、图片,输出是带版面信息的块序列,每个块包含文本、类型(正文/标题/表格/图注/页眉)、层级路径、页码、坐标。这一层的产出质量决定了整条链路的天花板,因为后面所有环节都只能在这个基础上做加减法,无法凭空造出丢失的信息。
切分层负责把块序列组装成语义完整的片段。注意我说的是“片段”而不是“固定长度文本”,这两个词差别巨大。切分要尊重文档结构:标题不切、表格不切、列表项尽量不切,段落按语义边界切,实在超长才做滑窗。
索引层维护三套东西:向量索引、关键词倒排、元数据表。三套要能互相对上,靠的是统一的 chunk_id。绝对不要只建向量索引,纯语义检索在专有名词、编号、型号、人名上会翻车,这是我在项目里反反复复验证过的结论。
检索层做多路召回加重排,还要处理查询改写、同义词扩展、时间过滤。
组装层做上下文预算分配、去重、排序、引用标注。这一层最容易被忽略,但它直接决定模型看到的东西是“有用信息”还是“一堆重复废话”。
2.2 选型背后的取舍:为什么我坚持“离线重活 + 在线轻活”
这条原则我吃了亏才彻底信。早期我做过一个版本,查询时实时做查询改写、多轮扩展、动态重排,看起来很智能,实际上一到并发上来就崩,尾延迟能飙到好几秒。后来全部改成预计算加缓存,在线只保留必要的两路召回和一次轻度重排,平均延迟压到几百毫秒,用户体感完全不一样。
具体拆开是这样:解析、切分、embedding、关键词抽取、章节摘要,全部在离线流水线完成,可以慢慢跑,跑错可以重跑,不占在线资源。在线的部分只做三件事:把查询编码、两路召回取 TopK、重排取 TopN。查询改写这种耗时的操作,改成离线维护一份同义词表和别名表,查询时查表替换,成本接近于零。
向量库的选择我是这个态度:单机几百万级向量,用带 ANN 索引的本地库完全够用,14 万页切出来通常也就十几万到几十万个 chunk,一台中等配置的机器就能扛。上分布式集群的唯一理由是高可用和水平扩展,而不是“数据量大”。这个判断能帮你省掉大量本不该有的运维复杂度。
3. 解析层:把 PDF 变成干净文本,脏活都在这里
我敢说,一个知识库项目 70% 的返工都发生在这层。原因也简单:写字的人不会考虑机器怎么读。有人用空格排版,有人用表格画框线,有人把标题做成图片,有人在每页页脚塞版权声明,有人用三个空行制造段落间距。这些东西对人的眼睛是格式,对解析器全是噪音。
3.1 文档分型与解析路由
我的做法是先分型再解析,不要指望一个解析器通吃。用文件后缀加前几页的文本密度做粗分类:能抽出足够多字符且字符分布正常的,走文本型解析;抽出来几乎为空或者全是乱码的,判定为扫描件,走 OCR;页数少但图片占比高的,走版面分析加 OCR 的组合。
import hashlib def route_document(path, sample_pages=3): """根据抽样页的文本密度决定解析路径""" probe = extract_text(path, pages=range(sample_pages)) text_len = len(probe.strip()) per_page = text_len / max(sample_pages, 1) if per_page > 500: return "text_pdf" elif per_page > 50: return "hybrid" # 部分页面含图,混合处理 else: return "scanned_ocr"这个路由逻辑很土,但实用。实测下来分型准确率能到九成以上,剩下那一成靠人工抽检捞出来。抽检这件事必须做,我的标准是每个批次随机抽 30 页,人工看解析结果,错误率超过 2% 就整批重跑或者换解析器。听起来费人力,但 14 万页里如果有一万页解析错了,你后面所有调优都是在错误的地基上盖楼。
页眉页脚清理属于必做项,而且要做在切分之前。我的做法是统计每页首尾三行的重复率,重复出现在超过 60% 页面上的短行,判定为页眉页脚,从正文里剔掉。这一步做完,检索的噪音能降一大截,很多人忽略了它,结果每次召回都带一堆“内部资料 请勿外传”。
3.2 表格、公式、图注这些“硬骨头”怎么处理
表格是重灾区。我遇到过一份文件,表格是用空格对齐的,解析出来变成一串数字连在一起,语义全丢。处理原则是:能用结构化的方式还原就还原成 Markdown 表格,还原不了就整块保留为图片并单独存一份表格摘要。
注意:表格绝对不要按行切分。一张跨页的费用标准表被切成两半,前半段只有表头没有数据,后半段只有数据没有表头,检索出来就是废的。宁可整块超长,也不要切断。
公式的处理思路类似。行内公式保留原始写法,独立公式块单独成块并附带前后文说明。图注要和图片绑在一起,因为很多文档的关键信息藏在“图 3 系统架构示意”后面的说明文字里。
还有一个坑:跨页段落。上一页结尾一句没写完,下一页接着写,简单拼接会多出一个页码或者页眉。我的做法是在解析阶段就做跨页合并,把同一段落被页码打断的部分接回去,再接不上就给这块打一个cross_page=True标记,切分时特殊处理。
4. 切分层与元数据:Chunk 策略决定检索上限
切分是整个链路里最被低估的一环。很多人的做法是固定 500 字一刀切,重叠 50 字,然后抱怨检索不准。问题出在:一刀切会把一个完整的论证切成三段,每段单独看都不完整,检索匹配到其中一段,模型拿到的信息是残缺的,只能靠猜。
4.1 分块参数怎么算出来,不是拍脑袋
我的分块参数是这样定的,不是拍脑袋,是倒推出来的。先看 embedding 模型的最大输入长度,再看业务问题的典型复杂度。如果用户的问题多是“某个具体数值是多少”这类点查询,块可以小,400 到 600 字,召回精度高;如果是“某套流程怎么走”这类需要上下文的查询,块要大,800 到 1200 字。
我一般的配置是:目标长度 800 字,上限 1200 字,最小 200 字,重叠 100 到 150 字。重叠的作用是防止答案正好卡在边界上被切断,但重叠不宜过大,否则同一段内容在检索结果里出现两次,白白浪费上下文预算。
def chunk_by_structure(blocks, target=800, hard_max=1200, min_len=200, overlap=120): chunks, buf, buf_len = [], [], 0 for b in blocks: # 标题、表格、代码块触发强制断点 if b.type in ("heading", "table", "code") and buf: chunks.append(join(buf)) buf, buf_len = [], 0 buf.append(b.text) buf_len += b.length if buf_len >= target: chunks.append(join(buf)) # 保留尾部作为下一块的开头,保证上下文连续 tail = take_tail(buf, overlap) buf, buf_len = tail, sum(len(t) for t in tail) if buf and buf_len >= min_len: chunks.append(join(buf)) return chunks这里有个细节:take_tail取的是最后一句完整的话,而不是硬截断 120 个字。原因很简单,半句话接在下一块开头,既破坏了下一块的语义完整性,又给检索引入了噪音。
4.2 元数据 Schema 与知识图谱式的交叉引用
元数据这件事,做得糙一点也能跑,做得细一点能救你无数次。我给每块配的最小集合是这样:
| 字段 | 说明 | 用途 |
|---|---|---|
| chunk_id | 全局唯一,建议用内容哈希 | 去重、引用定位 |
| doc_id | 源文档标识 | 版本管理、成组召回 |
| source_path | 原始路径 | 溯源、回溯解析问题 |
| title_path | 章节路径,如“第三章 > 3.2 报销标准” | 排序权重、答案引用 |
| page_range | 起止页码 | 展示“第 12 页” |
| block_type | 正文/表格/列表/图注 | 组装时差异化处理 |
| effective_date | 生效时间 | 时间过滤,避免用旧制度 |
| version | 文档版本号 | 灰度、回滚 |
| keywords | 抽取的关键词 | 关键词路召回 |
title_path这个字段价值极高。我在重排时给标题路径匹配的块加权重,用户问“报销标准”,命中“第三章 > 3.2 报销标准”下面的块,优先级明显高于正文里偶然提到“报销”两个字的段落。这个简单的加权,能把准确率拉高不少,成本几乎为零。
交叉引用属于加分项:文档里说“详见附件二”,如果能把附件二的 doc_id 关联上,检索时顺着链接把相关内容也带出来,用户体验会明显提升。这个可以离线做,用正则抽“见第 X 章”“参见附表”这类模式,逐步补关系,不用一上来就搞复杂图谱。
5. 检索层:混合检索、重排与上下文组装
到这一层,前面的工作开始兑现。如果解析和切分做得好,这里的调优空间其实不大;前面做得烂,这里再怎么调都是在抢救。
5.1 向量 + 关键词双路召回与融合
纯向量检索的短板我列几个你一定遇到过的:产品型号“A-2200X”和“A-2200Y”向量距离极近;人名、缩写、编号检索几乎失效;否定表述容易搞反。这些靠调 embedding 模型解决不了,必须上关键词路。
关键词路的构建有两个选择:用现成的全文检索组件,或者自己做简易的 BM25。我一般用现成的,省事。中文分词这块要注意,通用分词器会把“客户服务中心”切成“客户/服务/中心”,检索“服务中心”时匹配不上,所以要维护一份业务词典,把专有名词加进去。
两路结果融合,我用 RRF,因为它不需要归一化不同来源的分数,带着位置信息直接算,稳。
def rrf_fuse(rank_lists, k=60, top_n=30): """rank_lists: [[chunk_id, ...], ...] 每路按相关性降序""" scores = {} for ranks in rank_lists: for pos, cid in enumerate(ranks, start=1): scores[cid] = scores.get(cid, 0) + 1.0 / (k + pos) return sorted(scores.items(), key=lambda x: -x[1])[:top_n]k取 60 是常见默认值,我实测过 30 到 100 的区间,差异不大,别在这上面纠结太久,把精力放在召回数量上。我的经验值是每路召回 30 到 50 条,融合后取 30 条进重排,重排后取 5 到 8 条进上下文。
5.2 重排与上下文预算计算
重排用交叉编码器,效果比向量相似度好很多,代价是慢。所以我的策略是:只在 30 条候选上跑重排,不扩大范围。这个量级下,一次重排几十毫秒,可以接受。
上下文预算要算清楚。假设模型上下文窗口够大,我也不会把 30 条全塞进去,噪音会稀释信号。按每块 800 字、取 6 块算,正文大约 4800 字,加上标题路径、页码、引用标记,算 5500 字,折合 token 约 4000。留出系统和历史对话的空间,这个量很舒服。
提示:宁可少放几块,也不要把重复内容放进去。多路召回经常把同一段内容以不同 chunk 召回,组装前必须按内容哈希去重,否则模型会反复看到同一段话,注意力被稀释。
组装顺序也有讲究。我把块按“与查询的相关度 × 标题路径权重”排序,再把相邻块按文档顺序排列,让模型看到的是连贯的段落而不是打乱的碎片。这个细节对答案的连贯性影响不小,试一次对比就知道。
6. 实操:从零把一个最小可用版本跑起来
前面讲的是设计,这一段给你能直接抄的骨架。我会把目录结构、关键参数、成本估算都写清楚,你按这个搭一版,14 万页跑下来不会有意外。
6.1 环境与目录结构
目录我建议这样分,好处是每一层的中间产物都落盘,出问题能精确回溯到是哪一步坏掉的,不用整条链路重跑。
knowledge_pipeline/ ├── raw/ # 原始文档,按业务域分子目录 ├── parsed/ # 解析后 JSON,一文档一文件 ├── chunks/ # 切分结果 ├── index/ │ ├── vector/ # 向量索引 │ ├── keyword/ # 倒排索引 │ └── meta.sqlite # 元数据表 ├── eval/ │ ├── questions.jsonl # 评测集 │ └── reports/ # 每轮评测报告 └── logs/ ├── parse_errors.log └── pipeline.jsonlparse_errors.log这个文件务必单独维护。14 万页里一定有一批解析失败或者抽出来是空的,把这些文件单独列出来集中处理,比混在正常结果里好排查一百倍。
6.2 关键参数与批量处理的节奏控制
批量处理最容易踩的坑是一次性把 14 万页全塞进内存。我的做法是分批,每批 500 页,处理后落盘,再进下一批。embedding 调用要做限速和重试,网络抖动是常态。
BATCH = 500 CONCURRENCY = 8 MAX_RETRY = 3 for batch in batched(files, BATCH): parsed = parse_batch(batch, workers=CONCURRENCY) chunks = [c for p in parsed for c in chunk_by_structure(p.blocks)] vectors = embed_batch(chunks, retry=MAX_RETRY, qps=20) persist(chunks, vectors)CONCURRENCY设 8 是我在多个环境里的平衡点,再往上解析器容易吃满内存,收益递减。qps=20是给外部接口留余量,具体按你用的服务调整。
耗时我心里有数:纯文本解析单页 0.3 到 0.5 秒,8 并发下 14 万页大约 1.5 到 2.5 小时;含 OCR 的文档单页 2 到 5 秒,如果有一半是扫描件,整体会拉到十几个小时。这是一个晚上能跑完的量,不要熬夜盯着,让它自己跑。
6.3 成本与存储估算表
我把这个量级的账算给你,省得你到处问人。
| 项目 | 估算值 | 说明 |
|---|---|---|
| 总字数 | 约 1.1 亿字 | 按平均每页 800 字 |
| 总 token | 约 8000 万 | 中文按 0.7 折算 |
| chunk 数量 | 约 14 万块 | 按每块 800 字,含重叠约 15% |
| 向量存储 | 约 600 MB | 1024 维 float32,含索引开销 |
| embedding 成本 | 千元量级 | 一次性,重跑同理 |
| 元数据存储 | 约 300 MB | sqlite 足够 |
| 全量重建耗时 | 数小时到一天 | 取决于 OCR 占比 |
看到这张表你就明白了:14 万页这个量级,存储成本可以忽略,算力成本一次性且不高。真正贵的从来是人,是调解析规则、做抽检、维护评测集的时间。所以我的建议是:钱该花在解析器上就别省,一个稳定的商业解析服务,比你自己写三个月的正则划算得多。
7. 增量更新与版本管理:Wiki 是会变的
全量灌完只是开始。真实场景里,文档每天都在动,制度会修订,产品手册会出新版,旧文档会下架。如果每次改动都触发全量重建,成本不说,用户在重建期间还会查到不一致的结果。
7.1 内容指纹与失效重建
我的做法是给每个文档算两个指纹:文件级指纹和块级指纹。文件级用文件内容哈希,块级用块文本哈希。更新时先比文件级,变了再逐块比,只有变化的块才重新 embedding。
def diff_and_update(doc_path): new_hash = sha256_file(doc_path) old = meta.get_doc(doc_path) if old and old.file_hash == new_hash: return "skip" new_chunks = chunk_by_structure(parse(doc_path).blocks) old_hashes = set(old.chunk_hashes) if old else set() new_hashes = {sha256_text(c.text) for c in new_chunks} to_add = [c for c in new_chunks if sha256_text(c.text) not in old_hashes] to_del = old_hashes - new_hashes return {"add": to_add, "delete": to_del}这套逻辑跑下来,日常更新的重算量通常只有全量的百分之几,成本几乎可以忽略。这里有个坑要提醒:删除块的时候,向量索引、关键词索引、元数据表要同步删,三处不一致会导致幽灵结果,明明文档已经下架,检索还能搜到。
7.2 索引切换与灰度
重建一个文档的索引时,千万不要直接覆盖。我的做法是写新版本,标记为active=False,全部写完校验通过后再原子切换。这样用户永远看不到半成品。
对于大版本的整体升级,比如换了 embedding 模型导致向量空间变了,必须全量重建。这时候的切换策略是双索引并行:新旧两套同时存在,用户查询走旧的,内部评测跑新的,等新索引的评测指标不低于旧索引,再切过去。切换后旧索引保留一周,方便回滚。
注意:embedding 模型一旦更换,旧向量和新查询向量不在同一个空间,混用会得到完全错误的结果。这条必须在运维手册里写清楚,我见过有人图省事混用,排查了两天才发现问题。
8. 评测与排障:怎么知道它变好了还是变坏了
没有评测的知识库项目,本质上是在盲调。你改了一个切分参数,感觉好像好了一点,但到底是真好了还是这几个问题碰巧对了,说不清楚。做久了你会发现,感觉是最不可靠的东西。
8.1 三层评测集与指标看什么
我的评测集分三层。第一层是召回评测,200 到 500 条问题,每条标注正确答案所在的 chunk_id,看 Top5 召回率和 Top10 召回率。第二层是答案评测,100 条左右,人工标注参考答案,看答案的正确性、完整性和引用准确性。第三层是在线反馈,收集真实用户的点赞点和踩点,每周抽样复盘。
第一层最关键,因为它定位问题最快。召回率上不去,后面全是白搭。我一般的及格线是 Top10 召回率 90% 以上,低于这个说明解析或切分有问题,别急着换模型。
def eval_recall(eval_set, retriever, k=10): hit = 0 misses = [] for q in eval_set: ids = [c for c, _ in retriever.search(q.text, top_k=k)] if any(cid in ids for cid in q.gold_chunk_ids): hit += 1 else: misses.append(q.id) return {"recall@%d" % k: hit / len(eval_set), "misses": misses}misses这个列表比数字本身更有价值。逐条看没召回的问题,你能发现规律:是不是表格里的内容检索不到?是不是缩写没做同义词扩展?是不是某批文档解析质量差?这些问题一条条修,比盲目调参效率高得多。
8.2 常见问题速查表
下面这些是我在不同项目里反复遇到的,直接列成表方便你对照排查。
| 现象 | 可能原因 | 排查方向 | 处理办法 |
|---|---|---|---|
| 答案里数字全错 | 表格解析失败,数字粘连 | 抽查对应文档解析结果 | 换表格解析方案,整表保留 |
| 检索不到专业术语 | 分词切碎专有名词 | 看关键词路召回结果 | 补业务词典,加同义词 |
| 同一问题两次答案不同 | 多路融合排序不稳定 | 打印融合前后排名 | 固定随机种子,加重排 |
| 明显相关的文档没召回 | 切分把关键段落切散 | 看该段落的 chunk 边界 | 调大块长,尊重标题断点 |
| 回答了已废止的制度 | 缺时间过滤 | 检查元数据生效日期 | 加 effective_date 过滤 |
| 引用页码对不上 | 分页信息在切分时丢失 | 查 page_range 字段 | 切分时继承块页码 |
| 扫描件内容全是空的 | 未走 OCR 路径 | 看解析日志的字符数 | 修正文档分型路由 |
| 查询延迟忽高忽低 | 缓存未命中或重排堆积 | 看 P99 而非平均值 | 加缓存,限制重排候选数 |
这张表我建议直接贴到项目文档首页。新人接手的时候,对着表排查,能省掉大量来回问人的时间。
最后说个我自己的体会。这类项目最容易犯的错,是在还没建立评测集的时候就开始调模型、换框架、试新方案。我踩过这个坑,折腾两周,回头一看指标根本没动,因为瓶颈压根不在那。真正管用的顺序是:先做 50 条评测集把基线跑出来,然后老老实实看 20 条失败的 case,你自然就知道该改哪了。14 万页听着吓人,但把它拆成解析、切分、召回、组装四段,每段都有明确的指标和边界,它就只是一个需要耐心的活儿。耐心这东西,比任何框架都稀缺。