25-知识库版本与权限隔离

O泡李华 4

知识库更新和撤权怎样生效?版本、索引与租户隔离

数据库已经删除文档,向量库仍能检索;用户已经撤权,缓存里仍有旧答案。知识库的可见性同时存在于业务元数据、索引、缓存、历史和下载入口,需要明确谁是权威来源。

Spring AI 接入片段使用 Java 17、Spring Boot 3.5.3 与 Spring AI 1.1.8;协议与第三方平台示例另行标明适用范围。

检索权限贯穿内容流转

1. 版本发布不能一半新一半旧

文档、版本和片段分别标识,索引写入与正式可见分开。

文档 ID、版本与片段 ID 分别负责什么?

docId 表示同一个逻辑文档,revision 表示一次内容版本,chunkId 表示该版本和切分配置下的一个片段。

信息 用途
docId 稳定业务身份,不随文件名变化
revision 区分新旧内容
contentHash 识别内容是否变化
chunkingProfile 记录切分规则版本
embeddingProfile 记录模型、维度与预处理
chunk manifest 精确知道该版本生成了哪些片段

chunkId 可以由这些稳定信息和片段位置组合生成。仅按文件名删除,可能误删同名文件;仅用随机片段 ID,又可能在重试时不断产生重复数据。

用状态明确“正在处理”和“已经生效”

下面 MySQL 结构展示权威文档记录与版本。使用独立演示数据库,实际租户和授权关系还需业务管理。

CREATE DATABASE IF NOT EXISTS blog_kb_version;
USE blog_kb_version;

CREATE TABLE kb_document (
  tenant_id BIGINT NOT NULL,
  doc_id VARCHAR(64) NOT NULL,
  generation BIGINT NOT NULL,
  active_revision VARCHAR(64),
  state VARCHAR(16) NOT NULL,
  PRIMARY KEY (tenant_id, doc_id)
) ENGINE=InnoDB;

CREATE TABLE kb_revision (
  tenant_id BIGINT NOT NULL,
  doc_id VARCHAR(64) NOT NULL,
  revision VARCHAR(64) NOT NULL,
  generation BIGINT NOT NULL,
  content_hash CHAR(64) NOT NULL,
  embedding_profile VARCHAR(64) NOT NULL,
  expected_chunks INT NOT NULL,
  state VARCHAR(16) NOT NULL,
  PRIMARY KEY (tenant_id, doc_id, revision)
) ENGINE=InnoDB;

INSERT INTO kb_document VALUES (3, 'policy', 2, 'v1', 'ACTIVE');
INSERT INTO kb_revision VALUES
(3, 'policy', 'v2', 2, REPEAT('b', 64), 'embedding-profile-A', 10, 'STAGED');

示例表示 v2 正在准备,读取仍使用 v1。generation 用于拒绝旧处理任务,不能只看任务开始时间判断先后。

状态命名由业务约定,数据库不会自动知道十个片段是否已写入向量库。工作器需要保存实际处理与核查结果。

更新流程怎样避免一半新、一半旧?

先将新版本作为 STAGED 处理:解析、切分、生成 Embedding,按稳定 ID 写入片段,核对数量、标识和模型配置,再发布为可读版本。旧版本在切换完成后按保留策略清理。

发布业务指针可以使用条件更新:

UPDATE kb_document
SET active_revision = 'v2'
WHERE tenant_id = 3 AND doc_id = 'policy'
  AND generation = 2 AND state = 'ACTIVE';

预期没有其他更新或删除时影响一行;generation 已变化时影响零行,旧任务不能继续宣称发布成功。应用检查行数,并让版本发布与业务事件处于对应事务边界。

这条 SQL 只切换业务指针,不与外部向量库形成原子事务。检索链必须遵守 active_revision 或对应索引快照,不能仍然无条件查询新旧所有片段。

可按全库构建新索引并切换别名,也可按文档版本过滤。选择取决于向量库能力、更新规模和权限要求,不能假设统一 VectorStore API 自动提供版本切换事务。

删除为什么要先停止可见?

用户删除文档时,先在权威记录中标记不可访问、增加 generation,并保存可靠清理任务;随后删除向量片段、关键词索引、缓存和原始对象。

外部删除失败不应让文档重新对用户可见。检索结果进入模型前,要核对权威状态和授权;旧上传任务恢复后也必须检查 generation,避免把删除的文档重新发布。

UPDATE kb_document
SET state = 'DELETED', active_revision = NULL, generation = generation + 1
WHERE tenant_id = 3 AND doc_id = 'policy' AND state <> 'DELETED';

真正的数据删除还涉及历史、日志、备份和保留政策。标记不可见不是物理擦除完成;两种状态应分别记录。

增量处理和重试如何控制?

内容 hash 不变时可以按规则复用处理结果,但切分或 Embedding 配置变化仍可能需要重建。hash 相同也不能跨租户合并授权对象。

片段写入是否覆盖同 ID、删除是否可重试、批量写入是否部分成功,都要按具体向量库确认。保存 manifest 和进度,才能精确补齐或清理,不能只记录“本次上传成功”。

多个工作器同一文档竞争时,通过任务租约与 generation 防止旧结果覆盖新状态。租约不能阻止旧工作器继续发外部请求,因此读侧版本门槛与清理补偿仍然需要。

怎么检查最终一致性?

比较业务 active_revision、实际片段集合、索引中的 metadata 和可读取结果。发现缺片、孤立片段、旧版本残留和删除任务积压时,分别修复。

缓存键应包含影响回答的资料版本或有明确失效机制。文档已经删除但问答缓存继续返回旧内容,同样会泄漏。

在途请求也需要定义时效:删除之后是否允许已获得授权的生成继续完成,还是必须再次检查并停止输出。边界需要业务明确,不能默认为强实时撤销。

2. 授权过滤必须发生在内容流转之前

可信身份、适配器过滤与再次核查共同限制范围。

身份、租户和文档范围如何确定?

从认证结果获取可信用户,验证当前租户选择,再从业务权限系统取得允许的文档或集合。客户端请求体里的 tenantId 不能直接成为检索授权条件。

环节 需要限制的内容
文件读取与入库 上传归属、处理权限与元数据来源
关键词和向量召回 租户、允许文档与有效版本
重排、查询扩展 不能发送未授权资料
模型生成 只提供允许的证据与记忆
引用和下载 重新检查当前访问资格
缓存与日志 避免跨范围复用和泄漏

同租户内也可能有不同部门、项目与个人文档,tenantId 过滤不是全部授权。

隔离可以有哪些层次?

独立实例或数据库提供较强基础设施边界,但管理成本高;独立索引或集合便于隔离配置;共享索引使用 metadata 过滤,则要求每条数据和每次查询严格携带范围。

选择需要看数据敏感度、租户数量、规模和具体向量库能力。无论哪种方案,业务权限和可信身份都不能省略。

权限字段应由后端入库流程设置,不允许用户上传的 metadata 覆盖 tenantId、docId 或访问级别。否则查询过滤正确,数据归属已经被伪造。

一个显式范围过滤的 Spring AI 示例

以下类对应 Spring AI 1.1.8,使用Java 服务变慢怎样定位?从 CPU、GC 到内存保留链 VectorStore 依赖。Scope 必须由服务端权限服务构建;每个 Document 的 tenantId、docId 要采用一致的类型。

import java.util.List;
import java.util.Set;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder;

public final class AuthorizedRetriever {
    public record Scope(String tenantId, Set<String> documentIds) {
        public Scope {
            if (tenantId == null || tenantId.isBlank() || documentIds == null) {
                throw new IllegalArgumentException("trusted scope required");
            }
            documentIds = Set.copyOf(documentIds);
        }
    }

    private final VectorStore store;

    public AuthorizedRetriever(VectorStore store) {
        this.store = store;
    }

    public List<Document> search(String question, Scope scope) {
        if (question == null || question.isBlank() || question.length() > 2000) {
            throw new IllegalArgumentException("invalid question");
        }
        if (scope.documentIds().isEmpty()) return List.of();
        if (scope.documentIds().size() > 100) {
            throw new IllegalArgumentException("use a supported collection permission strategy");
        }
        var b = new FilterExpressionBuilder();
        var filter = b.and(b.eq("tenantId", scope.tenantId()),
                b.in("docId", scope.documentIds().toArray())).build();
        List<Document> hits = store.similaritySearch(SearchRequest.builder()
                .query(question).topK(5).filterExpression(filter).build());
        for (Document hit : hits) {
            Object tenant = hit.getMetadata().get("tenantId");
            Object doc = hit.getMetadata().get("docId");
            if (!scope.tenantId().equals(tenant) || !(doc instanceof String id)
                    || !scope.documentIds().contains(id)) {
                throw new IllegalStateException("retrieval authorization mismatch");
            }
        }
        return hits;
    }
}

预期没有允许文档时返回空,不退化成全库查询;若适配器意外返回范围外内容,直接拒绝,不交给模型。这个示例限制小范围文档集合,实际大量权限需要适合数据库的集合或权限模型。

它没有实现当前版本与删除状态核对,正式系统要根据一次 HTTP 请求怎样穿过 Spring MVC?从入口到 JSON 响应的权威记录继续检查。Scope 是一次授权快照,撤权的生效窗口也需明确。

过滤能力不能只看统一接口

某个向量库是否支持对应 metadata 运算、过滤与近似搜索怎样组合、字段索引是否配置,都需要核对。过滤不支持或翻译失败时不能静默忽略条件。

检索后检查是补充,不能把“先召回全库再在 Java 丢掉不允许结果”当作完整隔离:未授权内容可能已进入外部重排、日志或其他处理,还可能占满 topK 影响合法结果。

权限条件应在召回入口生效,并在发送给模型前检查权威状态。读侧权限不可用时,不应默认查全部资料。

3. 缓存、历史、Worker 与引用继续守住边界

最终回答没有专属标记,也不能证明未授权内容从未被处理。

缓存如何避免跨用户串线?

同一问题在不同租户、不同文档范围和版本下,答案可能不同。缓存键需要体现影响结果的范围、资料版本与配置,或采用可证明安全的共享策略。

只用“问题 hash”作为缓存键,可能把管理员得到的内容返回普通用户。会话记忆也要隔离,不能检索范围正确,却在历史中带入另一个用户的答案。

引用文件的下载地址应经过授权入口或合法短期凭证,不能把永久公开链接当作默认引用方式。

泄漏检查应怎样设计?

准备两个租户的专属标记和相似文档,验证向量路、关键词路、重排输入、模型 Prompt、最终文本、来源列表、缓存和下载。还要检查空权限、伪造租户、撤权、删除和旧版本。

只在最终输出搜索专属标记,可能漏掉改写后的泄漏。应同时检查内容究竟有没有进入不允许的处理阶段。本文列出检查方案,不宣称已执行测试。

上传与后台任务如何保持隔离?

上传验证当前用户是否能向目标知识库写入。文件对象路径可含内部租户与文档标识,但路径本身不是权限保证,下载仍需要校验。

导入任务保存租户和文档版本,Worker 不能用自己拥有的全库权限忽略任务范围。发布前重新检查文档状态和权限相关条件,防止任务期间撤销后仍发布。

后台任务不依赖已经结束的 HTTP ThreadLocal。它从持久化任务记录获取范围,结合受控服务身份执行,并记录实际发起者。

引用、历史和工具同样需要授权

引用链接可能泄漏文档名称或文件位置。回答前验证来源可见,打开文件时再验证;短期签名链接按对象和期限限制,不能当作永久公共链接。

用户权限撤销后,历史消息可能仍保存旧内容。项目需要定义历史访问与撤权策略,不能只清向量库就认为所有展示渠道都已消除。

工具查询使用服务器用户身份,订单编号来自用户内容。SQL 同时约束租户和所有者;是否有管理员查询入口,应独立授权和审计,不让普通助手自动切换到管理员模式。

MCP 或其他远程工具需要验证可信用户委托。仅传递一个字符串 userId,不足以防止客户端伪造身份。

用矩阵检查每个入口

准备租户 A、B,各有普通用户和管理员,使用合成文档和订单。以下是预期用例,不是已完成测试报告:

场景 预期行为 检查渠道
A 用户请求 B 文档 拒绝且不泄漏存在性 API、日志、下载
A 问题与 B 文档高度相似 B 内容不进入候选或模型 检索、重排、上下文
猜测他人会话编号 拒绝读取与停止 会话、消息、取消
同问题命中缓存 按权限范围使用缓存 Key、答案和来源
导入期间撤销文档 不发布已撤销版本 Worker 与索引
工具参数伪造身份 不影响可信身份 工具、SQL、审计

授权测试不能只检查页面显示,还要尝试直接接口、旧引用链接、历史会话和后台恢复。越权阻断应有内部记录,但避免日志保存受限内容全文。

评估泄漏与管理权限变化

合成文档中可以加入不同租户的唯一标记,检查它是否出现在候选、模型请求和回答。这个方法帮助定位泄漏位置,但不覆盖所有权限问题。

角色、成员关系或文档状态变化时,失效相关缓存与可见索引,或在读取时校验权限修订。明确撤权生效窗口,不宣称异步清理一定瞬间完成。

权限范围很大时,不能无限拼接 ID 列表。应评估数据库和索引过滤方案的规模边界,并保持可信权限来源与检索条件一致。

4. 设计判断与适用边界

权威元数据控制有效版本与权限,向量记录存在不等于可见。删除先撤销可见性,再异步清理物理索引;Worker 发布前重新核查,避免迟到任务复活文档。

权限为空时返回空或拒绝,不退化成全库查询。撤权生效窗口、缓存修订和历史访问策略都需要明确。检索后检查用于防御陈旧数据,不能替代前置过滤,因为重排器、日志和外部模型也可能已经接触内容。

5. 问答

问:更新文档再 add 一次就结束了吗?

答:还要控制稳定标识、版本可见性、旧片段清理和重复任务。

问:文件删了,向量库迟点删可以吗?

答:外部清理可补偿,但读侧应立即遵守不可访问状态,并处理缓存与在途请求。

问:提示词写“不泄露其他租户”够吗?

答:不够。资料进入模型前就要按可信身份限制。

问:最终删掉引用,就没有泄漏吗?

答:正文、日志和外部重排请求仍可能包含内容,应检查完整链路。

问:模型被要求“不泄漏”够吗?

答:不够,未授权数据应在进入模型之前阻断。

问:检索后过滤可以替代前置授权吗?

答:不可以,内容可能已经进入其他处理与外部服务。

参考资料