
14 万页文档、24 小时 4800 星这两个数字放在一起确实扎眼。但真正让我这个常年跟知识库打交道的人坐不住的不是星数而是“14 万页”这个量级本身。我经手过几个文档量在 8 万到 20 万页之间的内部知识库从 5000 页跨到十几万页这一步会塌掉多少东西我心里是有账本的召回率往下掉、首字节延迟往上飙、更新一次要跑一整夜、用户问一句系统给的答案引用的却是半年前的旧版本。所以看到 LLM Wiki 这类项目打出“工业级实现”的旗号时我的第一反应是拆它看它到底是把 Demo 包了个壳还是真的把工程上那几块硬骨头啃下来了。这篇不谈热度怎么来的只谈这类系统落到 14 万页这个真实量级时检索、解析、索引、治理四层各自该长什么样哪些参数是真要调的哪些坑是绕不过去的。如果你正在做企业知识库、文档问答、内部 Wiki 助手或者单纯想知道“为什么我的 RAG 一上量就废”下面这些内容应该能省你不少试错时间。1. 14 万页规模下Demo 级 RAG 到底在哪一步先崩1.1 先把账算清楚14 万页意味着什么很多人对“14 万页”没有体感我给一组我自己常用的估算口径。一页 A4 正文中文大约 700 到 900 字取中间值 800 字14 万页就是 1.12 亿字。按中文平均 1 字约 1.5 个 token 粗算原始语料在 1.6 亿 token 量级。如果按每块 500 字切分、块间留 10% 重叠切出来的 chunk 数量大约在 24 万到 25 万个。这个量级意味着几件事同时发生。第一全量重建索引的成本不再是可以随手做的操作一次全量嵌入按每百万 token 几毛到几块的价格波动加上调用并发限制跑完通常是小时级。第二向量索引从“内存里随便放”变成“必须考虑内存占用和磁盘分层”24 万个 1024 维 float32 向量裸数据就接近 1GB加上图索引结构和元数据实际占用要按 3 到 5 倍估。第三检索的候选池变大了top-20 里混进无关片段的概率显著上升而这些垃圾片段会实打实吃掉你给模型的上下文预算。文档规模chunk 量级索引内存估算全量重建耗时典型失败表现5000 页约 9000百 MB 级几分钟基本可用偶有漏召回5 万页约 9 万1 到 2 GB半小时到 1 小时召回噪声变多答案开始跑偏14 万页约 24 万4 到 8 GB2 到 6 小时更新不同步、延迟抖动、引用错版本50 万页以上80 万以上20 GB 起半天以上必须做分片与分层检索1.2 第一个塌方点上下文预算被检索噪声吃掉Demo 阶段大家习惯 top-k 设 5 到 10效果看着还行。上了 14 万页之后你会发现同样 top-10 里真正相关的段落可能只剩两三段。原因不复杂语料变大之后语义相近但答案无关的段落成比例增加比如十几份不同年份的制度文件里都有“报销流程”这四个字向量空间里它们挨得非常近。这时候最自然的反应是把 top-k 调大调到 20、30想着多给点总没错。结果更糟上下文塞满了相似但过时的段落模型被互相矛盾的表述干扰答案开始模棱两可。我的经验是top-k 不该无脑加而应该做“召回宽、重排严”——先用混合检索召回 50 到 100 个候选再用重排模型压到 5 到 8 个真正进入上下文的片段。这个思路在第 4 节会展开讲实现。1.3 第二个塌方点更新频率和索引一致性对不上这是工业级和 Demo 级最本质的分水岭之一。Demo 里文档是静态的一次灌完就完事。真实场景里14 万页文档每天都在动有人新增会议纪要、有人修订制度、有人删掉过期方案。如果你还是全量重建中间那几个小时的窗口期里用户查到的就是旧版本如果做增量写入但没处理删除被删掉的文档会永远飘在索引里变成“幽灵引用”。我见过最典型的事故是这样某团队做增量更新时只处理了新增和修改忘了删除。三个月后有人问一个早已废止的流程系统一本正经地引用了旧文件还带了精确的页码。这种错误比“答不上来”危险得多因为它看起来特别可信。2. LLM Wiki 的四层骨架以及被严重低估的治理层2.1 解析层、索引层、检索层、生成层的职责边界一个能扛住 14 万页的系统我习惯按四层去拆每层只干一件事层与层之间用明确的数据契约连接。这样做的好处是出问题时能快速定位在哪一层而不是所有东西糊在一起靠调 prompt 碰运气。层级核心职责输入输出最常见失败模式解析层把异构文档转成结构化文本PDF/Word/HTML/图片带元数据的纯文本 版面信息表格错行、扫描件空白、代码块被压平索引层建立向量索引与倒排索引分块后的片段可检索的双索引分块边界切断语义、元数据缺失检索层召回、融合、重排、压缩用户问题少量高精度片段召回噪声、重排模型与语料域不匹配生成层组织答案并挂引用片段 问题带出处的回答幻觉、引用错位、跨文档张冠李戴解析层是最脏最累的一层也是最容易被低估的。很多项目在这里偷懒直接把 PDF 用最简单的库抽文本结果表格全乱、双栏排版读成一句话、页眉页脚混进正文。这些脏数据进了索引后面无论怎么调检索都救不回来。我自己的判断标准很朴素解析层做不好后面所有环节的上限就被锁死了。2.2 治理层Schema、生命周期与权限这三件事除了上面四层工业级实现还得有一层“治理”它不参与在线问答链路但决定了系统能不能长期活下去。治理层管三件事。第一是Schema。每个 chunk 必须带上稳定的元数据字段文档 ID、文档版本号、章节路径、页码、生效日期、失效日期、密级、所属业务域。这些字段不是锦上添花是后面做过滤、做引用、做时效性判断的基础。少了“生效日期”你就没法处理“新旧制度冲突”这类问题少了“章节路径”引用就是一堆没有上下文的页码。第二是生命周期。文档有创建、生效、修订、废止四个状态。废止不等于删除它应该保留在库里但默认不参与检索除非用户明确要看历史版本。这个设计能救很多“为什么引用了作废文件”的投诉。第三是权限。这是企业场景的硬需求。14 万页里必然有不同密级的文档检索时必须先按用户权限过滤候选集而不是先召回再过滤——后者会导致“模型看到了但没告诉你”的尴尬严重时还可能通过措辞泄露信息。向量数据库大多支持带过滤条件的近似检索但这个过滤条件的写法有讲究第 4 节会讲。提示如果一开始没设计元数据字段后面补的代价极高因为要重新解析全部 14 万页并重建索引。这块设计务必前置。3. 从 PDF 到可检索片段解析与分块的处理链路3.1 四类文档四套解析策略14 万页语料里文档类型绝对不是单一的。我一般先做一次抽样统计把文档分成四类分别定策略。第一类是原生电子版 PDF 和 Word。这类最好处理直接抽文本即可但要额外保留版面信息尤其是标题层级。标题层级是后面做“父子块”和“章节路径”的依据丢了它分块就变成了纯按字数切香肠。第二类是含复杂表格的文档。财务报表、参数对照表、排班表都属于这类。我的做法是表格单独抽成一个结构化块转成 Markdown 表格或者“行-列-值”的三元组文本并且表格不参与普通分块它作为一个完整单元入索引。原因是表格被切断之后基本无法还原语义一张 20 行的表切一半剩下那半张就是噪声。第三类是扫描件和图片型 PDF。必须走 OCR。这里有个容易忽略的点OCR 之后要做一次版面重排否则你会得到一堆按识别顺序排列、行序错乱的文本。我的经验是优先用带版面分析能力的 OCR输出按区块组织的结构而不是一整段流水文本。第四类是 HTML 和在线文档导出物。重点是去导航、去页脚、去广告位只保留正文容器。这类文档噪声最多如果不清洗索引里会塞满“上一篇”“下一篇”“版权所有”这类完全无意义的片段白白占掉召回名额。3.2 分块别切香肠切语义分块这件小事是 Demo 和工业级差距最大的地方之一。我见过太多项目直接用固定长度滑动窗口切500 字一刀切完就完。在 14 万页的量级下这种切法会造成大量“半句话片段”和“跨章节拼接”检索出来读着都别扭模型自然也用不好。我实际在用的策略是三层结构父块按文档的自然章节切一个二级标题下的内容算一个父块长度不固定可能 2000 字也可能 8000 字。子块在父块内部按段落和语义边界切控制在 300 到 600 字块间保留 10% 到 15% 的重叠重叠的作用是防止关键句正好落在切口上。索引单元只对子块做向量化但子块的元数据里存父块 ID。检索命中子块后返回时可以按需带出父块的上下文这叫“小块检索、大块喂给模型”。这个设计的逻辑很直接小块让向量表达更聚焦相似度算得准大块让模型拿到足够上下文不至于断章取义。两部分各取所需。def split_document(doc, max_child_len600, overlap_ratio0.12): parent_blocks split_by_heading(doc) # 按标题层级切父块 children [] for p_idx, parent in enumerate(parent_blocks): paragraphs split_by_paragraph(parent.text) buffer, buf_len [], 0 for para in paragraphs: if buf_len len(para) max_child_len and buffer: child_text \n.join(buffer) children.append({ parent_id: f{doc.id}#p{p_idx}, text: child_text, heading_path: parent.heading_path, page_start: parent.page_start, doc_version: doc.version, effective_date: doc.effective_date, }) # 保留尾部作为重叠避免关键句被切断 keep int(max_child_len * overlap_ratio) tail child_text[-keep:] if keep 0 else buffer [tail, para] if tail else [para] buf_len len(tail) len(para) else: buffer.append(para) buf_len len(para) if buffer: children.append({ parent_id: f{doc.id}#p{p_idx}, text: \n.join(buffer), heading_path: parent.heading_path, page_start: parent.page_start, doc_version: doc.version, effective_date: doc.effective_date, }) return children这段代码没什么花哨的但里面几个字段值得说。heading_path让引用能精确到“第 3 章第 2 节”用户点开能直接跳转。doc_version和effective_date是后面处理版本冲突和时效性的关键。很多人写分块函数时只输出文本等到要加引用、加过滤时才发现元数据没存只能重跑一遍 14 万页这个返工成本非常痛。注意分块长度不是越短越好。我实测下来中文语料在 400 到 600 字区间召回效果比较稳低于 300 字会大量丢失上下文高于 800 字向量表达开始发散相似度算不准。4. 混合检索与重排把 14 万页里那 20 段捞上来4.1 单一向量检索在长文档库上为什么必然失手向量检索的强项是语义泛化你问“怎么请假”它能找到写着“休假申请流程”的段落即使字面完全不同。但它在长文档库上有两个天生短板一是对精确术语不敏感用户问一个内部系统代号或者一个具体条款编号向量相似度很可能给出完全无关的结果二是对否定和限定条件不敏感“不含税价格”和“含税价格”在向量空间里贴得非常近。14 万页的语料里专有名词、编号、型号、条款序号的密度非常高。纯向量检索在这种语料上的表现说实话我实测过几次都不理想。所以工业级实现基本都会走混合检索倒排索引负责字面精确匹配向量索引负责语义泛化两路结果融合。4.2 RRF 融合、重排与上下文压缩的三段式融合我一般用倒数排名融合RRF它不需要归一化两路分数量纲直接按排名算分实现简单且稳def rrf_fuse(dense_ids, sparse_ids, k60): scores {} for rank, doc_id in enumerate(dense_ids, start1): scores[doc_id] scores.get(doc_id, 0) 1.0 / (k rank) for rank, doc_id in enumerate(sparse_ids, start1): scores[doc_id] scores.get(doc_id, 0) 1.0 / (k rank) return sorted(scores.items(), keylambda x: x[1], reverseTrue)k取 60 是个常见默认值它的作用是平滑排名靠前文档的分数优势。如果你的倒排索引明显更可靠可以把两路的权重做成不对称的比如倒排那一路乘 1.2。这个需要拿你自己的评估集去试没有通用答案。融合之后是重排。重排模型和嵌入模型不是一回事它做的是 query 和 passage 的交叉编码精度高但慢所以只能用在少量候选上。我的配置是两路各召回 50 条融合后取前 40 条进重排重排后取前 6 条进上下文。这个链路在 14 万页量级上P95 延迟大概能控制在两秒出头具体取决于重排模型的大小。第三段是上下文压缩。40 条候选重排完前 6 条里可能每条都有 600 字全塞进去就是 3600 字再加上系统提示和历史对话很容易顶到上限。我的做法是先按句子切分用轻量打分挑出与问题最相关的句子重组通常能把 3600 字压到 1500 字左右且不丢关键信息。压缩这一步对答案质量的影响比很多人想象的大——喂进去的每一条无关句子都在稀释模型对关键信息的注意力。4.3 权限过滤要放在检索里不能放在检索后前文提到过这点这里补实现细节。带过滤的向量检索主流向量库都支持但要注意两点。一是过滤字段要建索引否则每次检索都要做全量扫描延迟会失控。二是过滤后的候选数量要留余量如果你的 top-k 设 20但用户权限只能看其中 5% 的文档那过滤后可能只剩 1 条根本不够用。我的做法是先在权限范围内统计可检索文档比例再按比例放大 top-k或者干脆对高密级文档单独建索引分区按权限路由到不同分区检索。5. 增量索引与版本治理知识库不能每次全量重建5.1 文档指纹与变更检测14 万页的系统全量重建只能作为兜底手段日常更新必须是增量的。增量更新的第一步是可靠地识别“哪篇文档变了”。我用的方案是双指纹内容指纹 结构指纹。内容指纹是对正文做归一化去空白、去页眉页脚、统一标点之后算哈希结构指纹是对章节标题序列算哈希。两者任一变化就判定需要重新解析。为什么要分开因为有些文档只是调整了章节顺序或者加了新章节正文其实没变但如果整体重算内容指纹会误判为大改导致不必要的重嵌入。# 增量更新任务的判定规则示例 change_detection: content_hash: sha256_normalized_text structure_hash: sha256_heading_sequence rules: - both_same: skip # 完全没变跳过 - content_same_only: reindex_meta # 只改结构更新章节路径即可 - structure_same_only: reindex_chunks # 正文变了重新分块嵌入 - both_different: full_reindex_doc # 大改整篇重建这套规则跑下来日常增量里真正需要重新嵌入的文档通常只占全部变更的 30% 到 40%能省掉大量计算。5.2 索引原子切换与删除语义增量写入最怕的是“写了一半”的状态一部分新块进了索引一部分还在处理这时候用户来查询拿到的就是新旧混杂的结果。解决办法是双索引 别名切换。新建一个索引版本全量写完之后把读别名原子性地指向新版本旧版本保留一段时间用于回滚。这个模式在搜索引擎领域很成熟搬到向量库上同样适用。删除的处理要更小心。物理删除向量在很多向量库里代价很高容易导致索引结构退化。我的做法是软删除 定期合并给块打一个deleted标记检索时通过过滤条件排除等到低峰期再统一做索引合并清理。同时维护一张“文档 ID → 块 ID 列表”的映射表删除文档时按映射精确清理避免漏删留下幽灵块。还有一类特别隐蔽的问题同一文档的多个版本同时存在于索引中。修改文档时如果只写新块不删旧块检索时新旧版本都会被召回模型看到两套说法就会开始“综合”输出一个谁都没说过的结论。这个坑我踩过一次排查了两天才定位。后来我强制在写入逻辑里加了一条任何写操作必须伴随同文档旧块的失效标记且这两步在同一个事务里。6. 一次“答案对不上原文”的完整排查链路6.1 从用户反馈到定位五个必须记录的字段某天业务方反馈问“供应商准入的最低保修期要求”系统答的是“两年”但原文写的是“三年”。这种问题最麻烦因为答案看上去合理只是数字错了。要能查这类问题线上每次问答必须完整记录五个字段原始问题、召回的候选列表含分数和来源、重排后的片段、最终进入上下文的片段、模型输出。少了任何一个排查就只能靠猜。我们当时就是靠这五个字段把链路一段段切开看的。6.2 逐段排查的完整过程第一步看召回。把这次的候选列表拉出来检索“保修期”相关块发现 top-50 里确实有正确的那条排在 12 位。说明召回没问题问题在后段。第二步看重排。正确片段重排后掉到了第 9 位而进入上下文的是前 6 位所以它被挤掉了。这时候要问为什么重排把它压下去了把重排模型对这 9 条的分数打出来看发现排在前面的几条来自一份已废止的旧制度里面写的是“两年”。第三步看元数据。查这两份文档的元数据effective_date一个是一年前一个是三年前且带deprecated标记。问题清楚了废止文档没有被过滤掉它的表述又和问题高度相似重排模型不会判断时效性自然给了高分。第四步修复。三处改动一是在检索入口加硬性过滤deprecatedtrue的文档默认不进候选除非用户显式要求查历史二是在重排之后加一层“同时效性去重”同一主题下只保留最新有效版本三是在拼装上下文时把每段的生效日期显式写进提示词让模型自己也有一层判断依据。第五步验证。把这个 case 加进离线评估集同时构造了 20 个类似的“新旧版本冲突”问题回归测试。修复后这组 case 的准确率从 65% 提到 96%且没有影响其他类别。排查环节观察到的现象根因修复动作召回正确片段排 12 位正常无需处理无重排正确片段掉到第 9 位废止文档语义高度相似检索入口加时效过滤元数据废止标记存在但未被使用过滤条件缺失硬过滤 同时效去重生成答案未提示版本差异提示词缺时效信息上下文注入生效日期这个排查链路我后来固化成了标准流程。任何“答案对不上原文”的问题都按召回、重排、元数据、生成四段切不要一上来就怀疑模型幻觉。十次里有七八次锅不在生成层。7. 延迟账本、评估集与上线后的持续迭代7.1 把 P95 延迟拆成四段来管14 万页量级的系统用户对延迟的容忍度比 Demo 阶段低得多。我习惯把端到端延迟拆成四段单独监控每段设阈值超了就告警。阶段典型耗时主要影响因素优化手段查询理解与改写100 到 300 ms是否走小模型改写简单问题跳过改写混合召回150 到 400 ms索引规模、过滤条件过滤字段建索引、分片重排300 到 900 ms候选数量、模型大小候选数控制在 40 以内生成800 到 3000 ms上下文长度、输出长度压缩上下文、限制输出长度拆开之后你会发现优化点非常明确。上下文从 3600 字压到 1500 字生成阶段能省下将近一半时间重排候选从 100 降到 40重排阶段直接减半。这两刀下去端到端 P95 从五秒多降到两秒出头用户体验的差别是质变的。另外提一句缓存。相同或高度相似的问题在知识库场景里重复率非常高尤其是制度和流程类问题可能一周被问几十次。做一层语义缓存相似度超过阈值直接返回上次结果并带上缓存时间戳提示能砍掉相当一部分请求。但要注意缓存必须绑定文档版本号文档一更新相关缓存立即失效否则又会回到“答的是旧版本”的老问题。7.2 评估集怎么建才不是自欺欺人我见过太多团队的评估集是“从系统已经答对的问题里挑 100 个”这种评估集只能证明系统会做它会做的题毫无意义。真正有用的评估集必须从真实用户提问分布里采样并且刻意包含四类难例跨文档的多跳问题、新旧版本冲突问题、带精确编号的查找类问题、以及语料里根本没有答案的问题。第四类特别重要。很多系统在“没有答案”时的表现是硬编一个答案这是最伤信任的。评估集里必须有一批“无答案”样本专门看系统会不会老实说“资料里没有提到”。我给自己定的线是无答案样本上的拒答准确率不低于 90%低于这个数就回去调提示词和召回阈值。评估指标我一般看四个召回命中率正确片段是否进了 top-k、答案准确率人工判定、引用准确率引用的段落是否真的支持答案、拒答准确率。四个指标一起看才能定位问题只看答案准确率会被误导——有时候答案对了但引用错了这种在业务上是不可接受的。8. 几个我反复验证过的配置与心态说几个具体到数字的经验都是踩过坑之后固化的。分块长度中文语料 400 到 600 字重叠 10% 到 15%这个区间我试过很多次短于 300 丢上下文长于 800 表达发散。混合检索两路各召回 50融合后取 40 进重排重排后取 6 进上下文这个配比在十几万页量级上比较平衡候选再多重排就成瓶颈了。上下文压缩目标控制在 1500 字以内超过这个数生成质量和速度都会往下走。这些数字不是金科玉律但作为起点能帮你少走不少弯路。还有一个心态上的事。做这类系统最容易被“效果不好”这件事击垮因为它不像普通功能那样有明确的 bug 可修。我的做法是把每次 badcase 都当成一次免费的标注攒够 50 个就做一次归因分析看是解析问题、分块问题、召回问题还是生成问题按类别统计占比。你会发现修好占比最高的那一类整体效果会有肉眼可见的提升。这种“按分布修问题”的思路比一个个 case 去调 prompt 有效得多也是我觉得从 Demo 走向工业级最该建立的习惯。