团队 wiki 进了知识库之后,删改和权限变化怎么跟上

3 阅读14分钟

我们组的问答机器人背后有一个定时任务,是我两年前写的。每天凌晨两点,它用一个管理员账号把团队 wiki 的全部页面导出成 markdown,清空检索索引,重新切块、算向量、写回去,整个过程跑一个多小时。白天机器人回答问题时查的就是这份索引。这个脚本很少出错,我也很久没碰过它。

夜间全量重建的几处隐患

让我重新审视它的是一件很小的事。一位同事问机器人某个内部工具怎么申请权限,机器人给出了一套步骤,引用的那页 wiki 我当天上午刚删掉,因为流程已经换了。机器人没有错,它查的是前一天凌晨的快照,我删页面的动作要到第二天凌晨才会反映进去。

顺着这件事往下想,我发现全量重建的问题不止延迟一天这么简单。

先是权限。导出用的是管理员账号,所以索引里是全部页面,不管原来的页面对谁可见。机器人最初只给组里用,大家权限差不多,这个问题被掩盖了。后来有别的组的人也开始用,wiki 里有几页是只给组内看的,机器人照样会从里面检索内容来回答。

重建过程中还有中间状态。清空再写入的那一个多小时里,索引是残缺的。凌晨很少有人用,可我们有同事在别的时区。

删除和改名在全量重建里倒不成问题,删除天然生效;麻烦在任何一处小改动都要整体重算一遍,wiki 越大,重建时间越长。

我试过改成增量:按页面的修改时间,只重建前一天改过的页面。这个版本只跑了两周就被我撤回了。修改时间能发现新增和修改,却发现不了删除,删掉的页面根本不在导出结果里,脚本无从知道索引里该清掉哪些块。改名在 wiki 里表现为旧路径消失、新路径出现,增量脚本会把新页面加进来,旧页面的块却留在索引里,同一段内容出现了两份。权限变化更麻烦,一个页面被收紧了可见范围,它的修改时间不一定变,增量脚本完全感知不到。我加了一张"已删除页面"的墓碑表来补,表本身又需要人维护,结果还是回到了全量重建。

这次折腾让我看清了一点:新增、修改、删除、权限变化,处理语义各不相同,不能都简化成"重新切块再写入索引"。我的脚本只处理好了新增。修改要保证旧版本不再误导回答,删除要让内容退出召回,权限变化要让人看不到他已经无权看的东西,这几样它都没管住。

从选型评估里接触到它

组里今年在评估要不要把自建的这套检索换成托管服务,负责选型的同事给我转了几份候选的帮助文档,其中一份讲的就是选型表里写的 Agent Context。

吸引我的是文档里关于知识来源的描述:知识库可以通过上传文件建,也可以同步钉钉、飞书的文档进来。我们的 wiki 刚好放在其中一个平台上。产品介绍里把这条链路描述为一套连接器流程:授权与配置,确定数据源、访问范围和策略;存量初始化,导入已有内容和元数据;增量同步,处理内容增删改和权限变更;监控与容错,跟踪同步状态、处理错误。同步时会保留来源、作者、时间、权限和版本。

这几步正好对上我脚本缺的那几块。不过材料写的是设计,我关心的是每一种变化实际到了知识库里是什么样子。安装用帮助文档里的 CLI 方式一条命令装好,这里不展开。

手动路径先走一遍

我没有一上来就配同步任务。我想先用命令行把四种变化手动做一遍,搞清楚知识库这一侧的基本动作,再去看同步任务做的是不是同样的事。

先建一个知识库,叫 team-wiki,把导出的几页 markdown 传上去:

ctxdb kb upload-file team-wiki ./export/onboarding.md --wait

第一次我没加 --wait,命令很快返回,结果里 ingest_status 是 processing。我马上去搜,没搜到,差点以为上传出了问题。后来看文档知道,processing 只表示已受理,解析和建索引是异步的,要等到能检索再返回,得加 --wait。这一点对写脚本的人很重要:如果我拿它替换夜间任务,上传完立刻做校验的那一步,必须等到可检索以后再跑。

传完以后检索,带上 --verbose:

ctxdb kb search "新人入职第一周要开通哪些账号" --kb=team-wiki --top-k=6 --verbose

返回的 chunks 里,每一块除了 content 和 score,还多出了 doc_name、kb_id、doc_id 和 tags。这几个字段对我来说比检索结果本身更有用:有了 doc_id,我就能把一块检索结果对应回知识库里的一篇文档,再对应回 wiki 上的一个页面。我原来的脚本里没有这层对应关系,切出来的块只带了一个页面路径,改名以后就断了。

修改。wiki 上的入职页面改了一段,我导出新版本,用 kb update-file 替换知识库里对应的那篇文档。再用同一个问题检索,返回的块是新内容,旧段落里独有的说法也搜不到了。这正是我想要的语义:同一个对象换了内容,旧版本退出召回。doc_id 在替换前后是否保持不变,我当时没有专门记下来,下一轮要补上这一项,它决定了外部引用会不会断。我原来的增量脚本做不到这一点,它只会追加。

删除。我挑了两页已经作废的流程说明,用 kb documents-batch-delete 一次删掉,然后用 kb documents-list 看文档列表,再拿这两页里特有的说法去检索,确认没有再召回。在我自己的脚本里,删除是最容易漏的一步,因为源头消失以后,没有任何东西会提醒你派生出来的块还在。这次我养成了一个习惯:删完必搜,用被删文档里独有的句子搜一遍。

改名在手动路径里没有专门的动作,我只能当成删掉旧文档、再上传新文档来处理。这样做的后果是知识库里对应的文档换了一篇,任何按旧 doc_id 记下的引用都会失效。我们的机器人在回答里会附上来源链接,改名以后这些链接要跟着更新。同步任务遇到 wiki 里的改名会怎么处理,是另一件需要观察的事,我也记进了清单。

权限变化,我在手动路径里没法模拟源头的权限收紧,只能看知识库这一侧。知识库有自己的访问控制,kb acl 那一组命令管的就是这件事,这部分我打算单独花时间看,这里不展开。源平台上的页面权限变了以后,同步链路会怎么改知识库这一侧,是我接下来最想弄清楚的问题,下面会再说到。

同步任务我看到了什么,没看到什么

手动路径走完,我在控制台里配了一个从 wiki 所在平台同步的任务,范围先限定在一个不大的目录。存量那部分按预期进来了,文档列表里能看到这些页面,检索也能命中。

配置时我在授权这一步停了一下。连接器流程的第一步是授权与配置,确定访问范围和策略。如果我还像原来那样用一个能看全部页面的账号去授权,那么不管同步链路多好,知识库这一侧拿到的也是一份没有区分可见范围的内容,夜间脚本的权限问题会原样搬过来。我这次选了一个只读、范围限定在测试目录的账号,宁可多配几次,也不想一开始就把全部页面放进去。

增量的部分,这次试用里我没有做足够的观察,不能断言它在每一种变化上的具体表现。产品材料里说,增量同步会监听文件变化,持续把更新写进来,处理增删改和权限变更。这是设计说明,我这次只验证了存量导入。修改一页之后多久能在检索里看到新内容、删除一页之后对应的文档是否会从知识库里退出、源头权限收紧时知识库这一侧怎么跟着变,这三件事我列进了下一轮的验证清单,每一件都按手动路径里的方法验:改完搜、删完搜、收权限后换账号搜。

只同步正文是不够的

回头看我的夜间脚本,它丢掉的不只是删除和权限,还有几乎全部的元数据。导出的 markdown 里只有标题和正文,页面的作者、最近一次的修改人、修改时间、可见范围,统统没有带过来。当时觉得这些信息对检索没用,现在看,没有它们,很多判断根本做不了:一段内容是谁写的、什么时候改的、本来该给谁看,机器人一概不知道,它只能把所有内容都当成同样可信、同样可见的材料。

来源标识让系统知道两次同步过来的是不是同一个对象,有了它,修改才能变成替换,不会多出一份。版本和时间用来区分先后。作者和权限决定这条内容能给谁用。生命周期状态则用来支撑下线和后续评估,一篇内容过时了,系统得有地方记下它已经不该再用。

还有一层是派生数据。一篇文档进了知识库,不只是原文件被存起来,还会被切块、建向量和全文索引,可能还会抽出实体和关系。Agent Context 的底层是多模存储,行存、列存、向量、图和对象存储都有,同一篇文档在不同的存储里各有一份派生结果。所以"同步完成"的标准不能只是原文件已上传,各层是否都指向同一个有效版本,才决定检索出来的东西对不对。我在手动路径里只验证了检索这一层,实体和关系那一层还没看。

冲突不会因为"最新"而自动消失

做这次试用时,我想起了 wiki 里一个老问题。同一个流程,wiki 里有一页说明,另一个系统里有一份正式发布的规范,两者对某个步骤的描述不一致。wiki 那页上周刚编辑过,规范的发布时间更早,可规范才是正式依据。

如果同步链路采用"后写覆盖",就会把新鲜度和效力混成一件事。文档里关于知识库的描述是冲突不会自动覆盖,需要经过冲突审核。我认同这个方向:保留来源和版本,至于哪个来源更权威,那本来是组织规则的事,交给规则和人判断,同步链路不该替我们拍板。

还有一种情况我也遇到过:有些资料是成批发布的,比如一组配套的操作手册,只有整批改完才算生效。如果逐页实时同步,问答机器人可能在一段时间里看到半套新规则、半套旧规则。我原来的夜间全量重建在这一点上反而有个意外的好处,它总是一次切到一个完整的快照。换成持续同步以后,这类成批发布的资料,我倾向于先在 wiki 侧改完整批,再整体触发更新,不追求单页的低延迟。

验收要细到每一篇文档

这次试用让我改掉了一个旧习惯。以前我验收重建结果,看的是索引里的块数有没有大幅变化、几个固定问题能不能答对。这两个指标都说明不了删除是否彻底、权限是否正确。

我现在的验收清单是按对象写的:wiki 上的每一页能不能通过 doc_id 对应到知识库里的一篇文档;修改以后,旧内容还会不会被召回;删除以后,用独有的句子还能不能搜到;权限收紧以后,换一个没权限的账号还能不能查到。任务答对了只是其中一项。延迟低不代表删除彻底,块数一致也不代表内容之间没有冲突。

下游的重算成本也要算进去。一页 wiki 改了一个错别字,原来的脚本要整体重建;现在 kb update-file 只替换这一篇。可我也意识到,局部更新做得越细,越要清楚哪些派生结果依赖这一篇,比如从这篇里抽出来的实体和关系是不是也跟着更新了。这一层我还没有深入看。

用下来的感受

对我来说,最实在的变化是"一篇文档"终于成了可以单独操作的对象。替换、删除、查来源,都能对应到具体的 doc_id 上,我不再需要靠清空重建来保证一致。重建期间索引残缺的那一个多小时,也会随着夜间脚本退役一起消失,前提是同步链路在增量上表现得和材料里说的一致。夜间脚本里最让我不安的删除问题,在手动路径里有了明确的操作和验证办法。

也有麻烦的地方:上传是异步的,不加 --wait 容易误判。还有一个是我自己判断上的犹豫,增量同步的实际行为我还没观察够,在验证完之前,我不敢把夜间脚本直接停掉。

接下来

下周我会让同步任务和夜间脚本并行跑一段时间,夜间脚本照旧维护原来的索引,同步任务维护 team-wiki 知识库。每天挑几页做过改动、删除或权限调整的页面,在两边分别检索,把结果记在一张对照表里,列包括页面、变化类型、源头操作时间、新知识库里第一次检索到变化的时间、旧索引的表现。删除和权限这两列都稳定了,我再考虑把机器人切到新的知识库上,夜间脚本保留一段时间作为回退。

有一个问题我还没想清楚,跟同步链路本身无关,却恰好卡在它的边界上。设想一位同事转岗,他原先能看的项目文档在源平台上已经收回,同步链路也正确地把知识库这一侧处理好了;可他上周和 Agent 的对话里引用过其中几段,这些内容可能已经被提炼进了他的私有记忆。记忆归属于他本人,内容却来自他已经无权访问的文档。源头权限收紧时,要不要连带处理由此派生出来的记忆,处理到什么粒度,我在文档里没找到答案。在把 Agent Context 推给更多组之前,这是我准备先去问清楚的一点。