企业版 AI 文档源

Odoo 企业版 AI 文档源为什么不直接吃原附件:checksum 扇出重建、跨 Agent 重索引与访问边界讲透

很多人以为把 Documents 里的文件加进 AI Agent,只是“拿原附件做个向量索引”。但从 `ai_documents_source` 源码看,Odoo 企业版真正关心的是文档源和原文档的生命周期解耦、同内容多 Agent 的 checksum 复用、文档更新后的批量重建、以及访问权限始终回到 Documents 自身。

企业 框架
进阶 开发者 4 分钟阅读
0 评论 0 点赞 0 收藏 6 阅读

很多人第一次看 Odoo 企业版 AI + Documents 的时候,会自然脑补成一个很简单的流程:

  • 在 Documents 里选一份文件;
  • 把它加到 AI Agent;
  • 系统切块、生成 embedding;
  • 以后问答时把这份文件喂给 RAG。

这个理解只说到了“功能结果”,但没有碰到 ai_documents_source 真正处理的几个难点:

  1. AI 源到底该不该直接复用原始 documents.document 的附件?
  2. 多个 Agent 同时引用同一份文档时,是重复建索引,还是按 checksum 复用?
  3. 原文档被替换后,为什么重建索引不是只改当前 source,而是按旧 checksum 扇出更新?
  4. AI 源可见不可见,为什么不靠附件 ACL,而是回到 Documents 的 user_permission
  5. 删除文档或删除 source 时,为什么两边都带有级联清理?

如果你直接读 /home/ubuntu/odoo-temp/enterprise/ai_documents_source,会发现官方设计思路很明确:

AI 文档源不是“文档本体”,而是“面向 RAG 的受控副本 + 与原文档保持同步的索引入口”。

这就是这个模块最值得研究的地方。


一、创建 AI 文档源时,Odoo 先复制附件,而不是直接挂原文件

先看 ai_documents_source/models/documents_document.pycreate_ai_agent_sources_from_documents()

当用户把若干 documents.document 加入某个 AI Agent 时,系统并没有把原文档附件直接塞给 ai.agent.source,而是先做了三件事:

  • 对每个文档的 attachment_id 执行 with_context(no_document=True).copy(...)
  • 新附件先写成 res_model=False, res_id=False
  • 再调用 ai.agent.source.create_from_attachments() 创建 source,最后把 source 标成 type='document',并回写 document_id

这几个动作合起来,表达的不是“复制一下更安全”这么简单,而是一条很重要的边界:

Documents 里的业务文档,和 AI 检索链路里使用的附件,不应共享同一个所有权对象。

为什么不能直接复用原附件?

因为在 Odoo 里,附件不是一个纯文件句柄。它还携带:

  • res_model / res_id 所属关系;
  • 文档模块的行为语义;
  • 删除、替换、权限判断时的引用链;
  • 后续 embedding 生成所依赖的 checksum 与附件记录。

如果 AI source 直接占用原附件,就会把两层语义绑死在一起:

  • 用户以为自己在管理 Documents;
  • 系统却在把同一附件同时当作 RAG source 的底层载体;
  • 一旦删除、替换、迁移,文档工作台和 AI 索引链路的责任边界就会混掉。

所以官方的做法是:

  • 文档继续做文档;
  • AI source 拿到一份可独立演化的附件副本;
  • 两者再通过 document_id 建立“同步关系”,而不是共用物理记录。

这是一种非常典型的框架级解耦设计。


二、首次建 source 时并不盲目重复建 embedding,而是先看 checksum + embedding model

ai_documents_source 本身不直接做 embedding 复用逻辑,这部分来自基类 ai/models/ai_agent_source.pycreate_from_attachments()

它的关键流程是:

  1. 先拿到待创建 source 的附件集合;
  2. 收集这些附件的 checksum
  3. 根据当前 Agent 使用的 embedding_model,去 ai.embedding 查有没有已存在的向量块;
  4. 如果某个 checksum 在当前模型下已经完成 embedding,就把新 source 直接标成: - status='indexed' - is_active=True
  5. 否则才保持 processing,等待 ai.ir_cron_generate_embedding

这意味着 Odoo 企业版对“同内容文件被多个 Agent 引用”这件事,默认不是每来一次都重算,而是:

按“文件内容 checksum + 当前 embedding 模型”判断能否复用索引结果。

这个点非常关键。

因为在真实企业环境里,同一份制度文件、合同模板、操作手册,经常会:

  • 被多个 AI Agent 同时引用;
  • 以相同内容出现在多个业务流程里;
  • 在不同时间重复加入新 Agent。

如果每次都重新切块和向量化,系统会在计算成本、处理时延、任务队列上白白付出代价。

而 Odoo 的策略是:

  • 内容相同,可以复用;
  • 模型不同,不能偷懒;
  • source 记录可以新增,但向量结果尽量共用。

这让 ai.agent.source 更像“索引入口记录”,而不是“embedding 的唯一所有者”。


三、权限判断不看 source 自己,而是回到文档权限

ai_documents_source/models/ai_agent_source.py 里,_compute_user_has_access() 被专门重写。

对于 type == 'document' 的 source,逻辑非常直接:

  • 如果 source.document_id.user_permission != 'none',则 user_has_access=True
  • 否则无权访问。

这个设计说明了一件很成熟的事:

AI source 的可访问性,最终不由 AI 模块自己决定,而由 Documents 对原文档的权限结果来裁决。

也就是说,虽然 AI source 持有的是“复制出来的附件”,但访问判定并不会因为“你能摸到这份副本附件”就默认放开。

官方明确把控制权交回给 documents.document

  • 文档对你可见,source 才可见;
  • 文档对你不可见,source 也不能借副本绕开授权;
  • AI 只是消费文档,不篡夺文档系统的权限主权。

这很像很多成熟系统里的原则:

  • 副本可以独立存储;
  • 授权不能独立漂移。

名称和跳转也回到文档语义

同一个文件里还有两个很小但很关键的重写:

  • _update_name()type='document' 时,source 名称会跟着 document_id.name 对齐;
  • action_access_source():如果有 document_id,打开的不是附件下载链接,而是 documents.document 的 kanban 视图。

这两个点共同说明:

  • AI source 的“展示身份”不是附件文件名,而是文档名;
  • 用户真正该回去操作的地方,是 Documents 工作台,不是底层附件。

所以这个模块从头到尾都在强调:source 是 AI 入口,不是新的文档中心。


四、重索引最有意思的地方,不是“重新生成向量”,而是按旧 checksum 扇出更新同一批 source

这个模块最值得深挖的是 action_reprocess_index()

对文档型 source 来说,它没有沿用基类“统一走 URL/source 处理”的逻辑,而是做了一套更精细的文档刷新流程。

核心步骤如下:

1)先按旧附件 checksum 找到所有相关 source

它会搜索:

self.env['ai.agent.source'].search([
    ('attachment_id.checksum', '=', self.attachment_id.checksum)
])

也就是说,用户在某一个文档型 source 上点击“重建索引”时,系统关注的不是“只处理当前这条记录”,而是:

凡是当前仍共享这份旧内容 checksum 的 source,都视为同一批待刷新的索引入口。

这一步直接决定了它天然支持“同文档被多个 Agent 引用”的扇出同步。

2)先同步名称,再判断文档内容是否真的变了

方法会先对这一批 source 执行 _update_name(),然后比较:

  • self.attachment_id.checksum
  • self.document_id.attachment_id.checksum

如果两者一致,说明 source 的副本附件与当前文档正文内容没变,这次重建就直接返回:

  • 不新建附件;
  • 不删除 embedding;
  • 不触发 cron。

这意味着在 Odoo 的语义里,“重建索引”并不等于无脑重算,而是先判断:

  • 只是名字改了?那同步名称即可;
  • 内容真变了?才进入附件重建和向量重算。

这个分层处理很节制。

3)内容变化后,先删旧 checksum 对应 embedding,再为每个 source 复制当前文档附件

如果 checksum 不同,_recreate_attachments_for_sources() 会做两件事:

  • 删除旧 checksum 对应的 ai.embedding
  • 遍历这批 source,用各自 document_id.attachment_id.with_context(no_document=True).copy_data(...) 生成新的附件值,并重新挂回对应 source。

这里有两个很值得注意的细节。

细节 A:删的是旧 checksum 的 embedding,不是简单改状态

这说明 Odoo 认为一旦源内容变了,旧切块/旧向量就不该继续残留在语义检索空间里。否则你会遇到很糟糕的结果:

  • source 页面看起来已经刷新;
  • 但 RAG 召回的还是上一版文档片段;
  • 用户会以为 AI“记忆错乱”,其实是索引污染。

所以官方直接把旧 checksum 的 embedding 清干净,再以新附件重新起跑。

细节 B:每个 source 都从自己的 document_id 复制附件

这一步非常妙。

即使一批 source 因为旧内容相同而被同一次搜索捞出来,真正重建附件时,系统仍然是:

  • source A 从 source A 的文档复制;
  • source B 从 source B 的文档复制。

也就是说,“按旧 checksum 分组”只是为了找出可能受影响的一批 source,不代表它们会被粗暴替换成同一份新附件。

这是一个很稳的框架设计:

  • 用 checksum 做批处理入口;
  • document_id 保证回写来源不串档。

五、重索引后不是所有 source 都重新排队,有些会被直接判定为已索引

附件重建完后,代码会调用基类的 _get_sources_indexing_state()

这个方法会检查:

  • 新 checksum 在哪些 embedding_model 下已经有现成 embedding;
  • 哪些 source 对应的 Agent 模型已经能直接复用;
  • 哪些 source 还需要继续进入 processing

然后 _update_sources_status() 会把这一批 source 分成两类:

  • indexed:对应模型已有新 checksum 的 embedding,可立即激活;
  • processing:还没有现成向量,需要重新生成。

最后,如果确实有未完成的 source,才触发 ai.ir_cron_generate_embedding

这背后的思想非常实用:

内容更新并不必然等于“所有 Agent 从零重跑”。如果某个模型对这份新内容已经有索引结果,那就直接复用。

也就是说,Odoo 在“文档变了”这个场景下,仍然坚持前面那条总原则:

  • 该失效的旧 checksum 立刻失效;
  • 能复用的新 checksum 结果尽量复用;
  • 真需要重算的部分再进入 cron。

这不是简单的刷新按钮,而是一次带去重与状态分流的索引编排。


六、测试用例说明:官方明确在乎“同一文档挂多个 Agent”的同步一致性

ai_documents_source/tests/test_ai_agent_source_document.py 里有两个测试,几乎把模块意图写在脸上。

测试 1:文档内容没变,不应该动附件,也不应触发 embedding cron

test_reprocess_document_source_with_unchanged_document() 做了这些断言:

  • source 原附件保持不变;
  • status 继续是 indexed
  • is_active 继续是 True
  • 不触发 ai.ir_cron_generate_embedding

这说明官方不接受“用户点了重建,就无论如何重跑一遍”的粗暴实现。

测试 2:同一文档被两个 Agent 引用时,重建一次,要把两边都更新

test_reprocess_document_source_refreshes_content_and_triggers_embeddings() 更关键。

它先:

  • 创建一个文档;
  • 给 Agent A 和 Agent B 都创建 source;
  • 手动造出旧 embedding;
  • 替换文档的附件内容;
  • 只对其中一个 source 调用 action_reprocess_index()

最终它验证:

  • 两个 source 的附件 checksum 都更新成文档当前 checksum;
  • 两个 source 都进入 processing
  • 两个 source 都暂时 is_active=False
  • 旧 checksum 的 embedding 被删掉;
  • source 名称跟随文档名保持一致;
  • embedding cron 被触发。

这几乎明牌告诉你:

在 Odoo 企业版的设计里,“重建文档型 source”默认就是面向同内容 source 集合的批处理动作,而不是单条记录自扫门前雪。

这正是 checksum 扇出策略的价值所在。


七、删除链路也做了双向清理,避免孤儿 source 和孤儿附件

这个模块还有一个很容易被忽略、但很像“企业版成熟度”体现的地方:删除时的收尾非常完整。

文档删除时,关联 source 会跟着删

documents_document.py 里,_unlink_sources() 使用:

  • 搜索 document_id in self.ids 的所有 ai.agent.source
  • 存在就统一 unlink()

source 删除时,自己的附件也会跟着删

基类 ai.agent.source 里又有 _unlink_attachments()

  • source 删除时,若有 attachment_id,直接删附件。

这形成了一条清晰的回收链:

  • 文档没了,source 不应继续悬空;
  • source 没了,它持有的 AI 副本附件也不应残留;
  • embedding 又是按 checksum 管理并在重建时主动失效。

整套设计组合起来,目标很明确:

不让 Documents、AI source、attachment、embedding 之间留下模糊引用和脏数据。


八、这套设计给实施团队什么启发?

如果你在做企业知识库、合同问答、制度问答、SOP 助手,这个模块非常值得借鉴。它给出的不是一个花哨的 AI UI,而是几个底层原则。

1. 原业务对象与 AI 索引载体要解耦

不要让 AI 直接“占有”业务文档本体。副本机制看似多一步,实际上能显著降低:

  • 生命周期耦合;
  • 权限串线;
  • 替换与回滚时的脏状态。

2. embedding 复用要以“内容 + 模型”为边界

只看文件名、记录 ID、上传时间都不够稳。真正可靠的是:

  • 内容 checksum;
  • 当前 embedding model。

3. 内容更新后的批处理入口可以按 checksum 找,但最终回写必须按来源对象分发

这是本文最值得抄的一条。批量发现问题对象和精确回写,不一定要用同一把锤子。

4. 权限主权必须留在原业务域

哪怕 AI 有自己的 source 记录、自己的附件副本、自己的处理任务,只要它服务的是 Documents,访问控制就应该优先服从 Documents。


结语

ai_documents_source 代码量不大,但很有“企业版框架设计”的味道。

它真正解决的,不是“把文档喂给 AI”这么表层的问题,而是下面这条更硬的工程链:

  • 文档如何复制成 AI 可消费的副本;
  • 多个 Agent 如何按 checksum 与模型复用已有 embedding;
  • 文档更新后如何批量失效旧索引并重建新附件;
  • 用户访问 AI source 时,权限如何始终回到 Documents;
  • 删除时怎样把 source、附件、embedding 的脏尾巴收干净。

所以如果你问:Odoo 企业版 AI 文档源模块最核心的设计点是什么?

我的答案会是:

它把“文档本体”和“AI 索引载体”明确拆开,再用 checksum、document_id 与状态编排把两者稳稳同步起来。

这才是 ai_documents_source 最值得学的地方。

DISCUSSION

评论区

想参与讨论?先 登录 再发表评论。
还没有评论,你可以成为第一个留言的人。