我的副驾驶会持久化它引用的来源卡片——哪些文档支撑了每个答案、评分、名称,一应俱全。对一个可信的RAG产品来说,这是基本门槛:没有证据的答案只是感觉。
但有个问题改变了我交付功能的方式:六个月后,用户打开那段旧对话,卡片再次渲染。第二次是谁授权了它们?
舒服的答案是“没人需要授权——这是用户自己的历史,他们已经看过了。”我交付的是不舒服的答案,并且想为它辩护:
持久化不等于权限。一个回合在写入时被允许展示什么,证明不了它在读取时可以展示什么。
写入时与读取时的权限断层
用一句话交代背景:我花了两周给一个流式优先的文档搜索副驾驶加上持久会话历史——持久化每个回合,刷新后恢复整个对话。其中一部分工作就是持久化每个回合输出的来源,钉在产生它们的审计事件上,这样记录端点就能原样重放它们。陷阱恰恰在这里。
实时搜索路径守得像堡垒——权限开关、默认拒绝的逐文档查看门、只从JWT取身份。新的历史端点从普通数据库行里读取文档衍生数据,而数据库对这些一无所知。天真地交付它,你就在自己花了几个月设门的数据上开了一扇没有守卫的侧门。
这一点都不稀奇。每个持久化检索结果的RAG产品都有这扇门。唯一的问题是是否有人给它上了锁。
两周后开关翻转,行数据不会在乎
在这个产品里,租户管理员用开关控制AI权限:副驾驶本身,以及单独产生来源卡片的文档搜索能力。从一个回合被写入到它被重放的那天,几周过去了。开关翻转。合同改变。行数据不在乎。
所以我让重放从今天的权限重新推导它的答案,而不是从“行存在”这个事实推导。
两种不同的拒绝形态,是故意的。这是本文其余部分要拆解的设计决策。
粗粒度门位于每次历史读取的顶部。它加载租户的权限,在触碰任何会话行之前就拒绝:
历史读取遵循与实时搜索相同的服务内权限检查。如果副驾驶权限被关闭,整个历史端点直接返回拒绝,连一行会话数据都不会被读取。
细粒度门:逐卡片重新过查看权限
粗粒度门只回答“这个租户现在还能用副驾驶吗”。它不回答更细的问题:这条历史里引用的每一份文档,现在还能被这个用户查看吗?
写入时,每份文档都经过逐文档查看门,通过了的才进入来源卡片。读取时,如果只是把持久化的卡片原样吐出来,就等于把几周前的查看结论当成永久有效。
所以重放路径对每张来源卡片重新执行逐文档查看门。文档现在不可见了,卡片就不渲染,或者渲染成“来源已不可用”的占位。答案文本本身保留,因为那是用户自己的对话历史;但证据链不再指向用户现在无权查看的文档。
这带来一个可见的行为差异:用户打开旧对话,可能发现当时看到的来源卡片少了几张。这不是bug,是权限在读取时重新生效。
为什么不能只靠“用户已经看过”
“用户已经看过”这个理由假设权限是单向的:一旦看过,就永远有权再看。但企业权限不是这样工作的。合同到期、项目关闭、离职交接,都会让一个用户对某份文档的查看权从有变无。
如果历史端点不重新检查,它就变成了一个绕过实时权限的通道:用户只要在权限还在的时候问过一次,就能在权限消失后反复打开旧对话,看到当时引用的文档内容摘要和元数据。
这不是假设的漏洞。任何把检索结果持久化到会话记录里的RAG产品,只要历史读取不重新过权限门,就都有这个通道。区别只在于有没有人把锁装上。
实现上的两个关键点
第一,粗粒度门必须在读取任何会话行之前执行。顺序很重要:先加载租户权限,拒绝就直接返回,不碰数据库里的会话数据。这样即使历史端点本身有缺陷,也不会泄露行数据。
第二,细粒度门需要逐卡片执行,而不是批量执行。批量执行的问题是,只要有一张卡片通过,就可能连带渲染其他卡片。逐卡片执行才能保证每一张来源卡片的可见性都独立判断。
这两层门合起来,让历史读取的权限语义与实时搜索保持一致:写入时能看的,读取时不一定能看;读取时能看的,必须是现在还能看的。
持久化是产品功能,重新授权是安全义务
把来源卡片持久化,是为了让用户能回到旧对话,看到当时答案的依据。这是产品功能。但持久化本身不授予任何权限,权限永远属于读取发生的那一刻。
一个回合在写入时通过了所有检查,只说明它在那个时间点合法。几周后重放,它需要重新证明自己合法。做不到这一点的历史功能,就是把过去的一次授权当成了永久通行证。
每个持久化检索结果的RAG产品都有这扇门。唯一的问题是,你是否在它上面装了锁。
热门跟贴