很多人第一次看 Odoo 企业版 AI + Documents 的时候,会自然脑补成一个很简单的流程:
- 在 Documents 里选一份文件;
- 把它加到 AI Agent;
- 系统切块、生成 embedding;
- 以后问答时把这份文件喂给 RAG。
这个理解只说到了“功能结果”,但没有碰到 ai_documents_source 真正处理的几个难点:
- AI 源到底该不该直接复用原始
documents.document的附件? - 多个 Agent 同时引用同一份文档时,是重复建索引,还是按 checksum 复用?
- 原文档被替换后,为什么重建索引不是只改当前 source,而是按旧 checksum 扇出更新?
- AI 源可见不可见,为什么不靠附件 ACL,而是回到 Documents 的
user_permission? - 删除文档或删除 source 时,为什么两边都带有级联清理?
如果你直接读 /home/ubuntu/odoo-temp/enterprise/ai_documents_source,会发现官方设计思路很明确:
AI 文档源不是“文档本体”,而是“面向 RAG 的受控副本 + 与原文档保持同步的索引入口”。
这就是这个模块最值得研究的地方。
一、创建 AI 文档源时,Odoo 先复制附件,而不是直接挂原文件
先看 ai_documents_source/models/documents_document.py 的 create_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.py 的 create_from_attachments()。
它的关键流程是:
- 先拿到待创建 source 的附件集合;
- 收集这些附件的
checksum; - 根据当前 Agent 使用的
embedding_model,去ai.embedding查有没有已存在的向量块; - 如果某个 checksum 在当前模型下已经完成 embedding,就把新 source 直接标成:
-
status='indexed'-is_active=True - 否则才保持
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.checksumself.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
评论区