这个版本里我们做了一个听起来有点反潮流的组件:一个完全不调用模型的知识编译器。文档导入之后,它用纯确定性文本工程把文档「编译」成两类产物------实体知识(某个型号有哪些别名、在哪些句子里被提到)与文档要点(一句话摘要、关键原句、关键术语),提问时这些静态事实置顶注入。这篇讲它内部怎么工作,后半篇讲知识治理与后台编译队列。
为什么第一版编译器必须无模型
原因很实际。这是本地优先应用,用户可能挂着云端强模型,也可能只靠本机跑量化小模型,甚至暂时没配模型。编译发生在「导入文档」这个用户动作上,如果编译期调模型,导入就会变慢、变贵、变得不可复现------同一份文档,今天编译和明天编译结果可能不一样,模型一抖动产物就漂移。不可复现的编译产物没法做事务切换,也没法写回归测试。
所以第一版编译器是纯规则的:切句、噪声过滤、模式匹配、打分排序,全部确定性。模型增强被压成一层默认关闭的可选层,纪律非常硬:增强结果必须逐字回指原文(每个事实都能在原文里找到原话),零有效增强、超时 30 秒、解析失败,三种情况一律返回空、降级回规则种子。增强层是锦上添花,永远不是必需品。
输入重建:先把重叠分片缝回去
编译器面对的不是原始文档,而是切块产物。切块为了语义连贯带 overlap,相邻分片共享一段重叠文本。要做实体提及定位和字符坐标,第一步必须把分片缝合成一份虚拟文档,否则每个实体的位置都只是「某分片内的局部坐标」,跨分片无法对齐,引用也指不回原文。
缝合用的是最长公共前后缀:相邻两个分片,后者前缀与前者后缀的最长公共部分就是重叠区,直接接续而不重复拼接。纯字符串完全相等在真实数据里太脆(切块边界可能差一两个字符),实际做了 overlap 加 2 的容错,在期望重叠长度附近容忍少量错位。缝合的同时建一张字符区间归属表(CharRun):重建文本的每一段字符区间,对应哪个 chunk、什么页码、什么段落号。后续所有字符坐标都基于重建文本计算,再经 CharRun 映射回物理分片------任何模块都不允许再用分片各自的局部坐标。
两个规则抽取器
缝合之后进入抽取,两个抽取器都是纯函数。
要点抽取器回答「这篇文档讲了什么」。先过切句与噪声过滤(标题行、「本节小结」一类结构性废话被剔掉),再用一组定义动词等启发式信号筛掉无效句;产物包括:tldr(第一条实质性句子)、top3 打分原句(按位置、信号词密度等规则打分,取前三)、keyTerms(高频且有信息量的术语)。全部是原文句子,不做改写------可回指、无幻觉。
实体抽取器回答「文档在谈哪些东西」,只产候选,来源是三类机械模式:
- 引号内的专名;
- 型号模式(形如
ZK-900的字母连字符数字组合); - 括号里的中英别名候选,如「中控主机(ZK-900)」。
括号候选有个专门的防碎片设计:括号两侧设遮蔽区,括号匹配完成后,其邻近区域不再参与其他模式的抽取,避免从半句话里切出残缺实体名。所有候选名称统一归一化:NFKC 标准化、小写、去标点,作为实体合并与精确匹配的键。
必须强调「候选」这个定位:抽出来的实体是查询路由线索,不是真理。它决定的是「用户提到这个名字时,该把哪份文档的静态事实顶上来」,不直接参与生成任何断言。规则抽取宁可漏报(漏掉时退回普通片段检索),也不靠猜测硬报。
提及定位:非逐字子串一律丢弃
实体有了候选名,下一步是在重建文本上定位每一次提及。先用候选名与分片做相交选片 (只在可能包含该名的分片上找,避免全文扫描),再对分片原句做逐字 indexOf 定位,记录字符起止。关键纪律是:不是逐字子串的匹配直接丢弃。模糊匹配看似召回更高,但会把「部分字符碰巧相同」的误报实体钉进数据库,而误报实体在查询路由里会长期错误地置顶文档------这种慢性污染比漏报难查得多。
编译写入:单事务里的世代切换
编译是可重入的:文档可能被反复重新编译。写入因此被设计成一个严格的单事务序列:
text
compileDocument(文档):
stitch 缝合重建文本
→ 规则种子(要点 + 实体候选)
→ 可选 LLM 增强(默认关,失败回落种子)
→ 相交选片 + 逐字定位提及
→ 单事务写入:
1. 删除该文档的旧提及
2. 摘要世代号 +1,写入新摘要
3. 新别名按并集归并进实体
4. 重算实体计数
5. GC 计数归零的实体
事务提交 → compile_generation 原子切换
世代字段是这套设计的核心:新旧两代编译产物在事务内完成交接,提交前任何查询读到的都是旧的完整一代,提交后整体切到新一代,不存在「半截新数据」的中间态。别名用并集而不是覆盖------重新编译不会把上一代积累的别名丢掉。
查询侧路由:静态事实置顶,但可以一键关掉
编译产物在查询侧通过一个路由开关生效(默认开):问题中的名称经归一化后,先查实体精确名,再用名称加别名做 LIKE 候选,命中后取出对应文档的静态条目;keyTerms 则用于过滤和排序文档要点。命中的静态内容以「实体知识:《文档名》」「文档要点:《文档名》」两个明确抬头置顶注入,与片段检索结果在提示词里严格分区,模型知道哪块是文档写明的事实、哪块是临时召回的上下文。
这个开关关掉之后,链路与旧版零差异------这不是客套,是反向验证能成立的工程前提:eval 的三开关矩阵正是靠这些真能关、关了真回退的开关,才测得出每个组件的真实贡献。
知识治理:冲突、重复与同名替换
编译器让文档「变厚」之后,治理问题浮上桌面,三个能力全部用确定性手段解决,零新增模型调用。
冲突检测是一个纯函数,从三个维度比对同名实体的事实:版本号维度、标量数值维度、标题描述维度。两组事实构成冲突需要同时满足「同名、同维度、值不同且无法豁免」。两条豁免规则是从真实数据里长出来的:同一文档内出现双值算版本升级(文档自己声明了新旧两版),豁免;不同维度上同名但不同义(一个是型号一个是代号),豁免。判出的冲突进入治理面板的待裁决区,由人做最终裁决,系统不擅自合并。
重复文档检测复用现成资产:从两份文档各采样若干 chunk,用库里已有的嵌入向量做跨文档 KNN,相似度 ≥ 0.92 且至少形成 2 对高相似片对,才判定重复。阈值加片对数两个条件缺一不可------单片撞高相似可能是公共模板段落,两片同时撞才是文档级雷同。因为只算库存向量,检测本身不产生任何新的模型调用。
同名重新导入 走替换而不是追加:同库同名但内容哈希不同,复用原文档行做内容替换(replaceContent),文档 id 不变------历史回答里的引用因此不会失效;随后世代重置、重新编译。旧版那套「新切片赢者通吃」的整体覆盖,至此变成了身份连续的受控更新。
后台编译队列:为什么刻意只跑单并发
编译从手动触发变成了导入后的自动动作(autoCompile 默认开),队列设计反而非常克制:
- 单并发 FIFO:同时只编译一份文档,其余排队;
- 同库去重:排队期间同一文档再次入队自动折叠;
- 可取消:用户可以取消排队中或进行中的任务,失败任务支持重试;
- SSE 进度事件:排队、开始、进度、完成、失败全程推给界面,知识库页有编译工具栏,设置面板有开关与模型选择。
为什么不做并发?两个资源都扛不住。嵌入式 SQLite 的写锁意味着并发编译只会互相等锁、徒增忙等;本地嵌入模型在编译期要吃 CPU 和内存,多文档并行在普通办公机上直接拖垮整个应用。单并发不是能力不足,是对运行环境的诚实------配合 FIFO、去重和明确的进度反馈,单人使用的体感反而是「稳」而不是「慢」。
一个跨调用的坑:带 g 的正则有 lastIndex
编译器上线后出现过灵异现象:同一份文档重复编译,实体结果偶尔不一致。根因是一个老 Javascript 陷阱------带 g 标志的正则对象是有状态的 ,lastIndex 会停在上次命中的位置。型号模式用一个模块级正则反复 .test(),第二次调用从上次的 lastIndex 开始匹配,结果取决于上一次调用停在哪:
ts
// 错误:复用带 g 的正则,test() 结果跨调用不确定
const MODEL_RE = /[A-Z]{2,}-\d{3,}/g;
if (MODEL_RE.test(sentence)) { /* ...*/ }
// 正确:每次用源 pattern 新建无状态副本
const re = new RegExp(MODEL_PATTERN_SOURCE);
修法是型号相关的所有匹配一律每次 new RegExp 生成无状态副本。这种 bug 的可怕之处在于单测里跑一遍永远是绿的------只有跨调用复用时才现形,也再次说明编译器这类纯文本组件,确定性不是承诺出来的,是把每一处隐藏状态都抠掉之后剩下的性质。