25-知识库版本与权限隔离
知识库更新和撤权怎样生效?版本、索引与租户隔离
数据库已经删除文档,向量库仍能检索;用户已经撤权,缓存里仍有旧答案。知识库的可见性同时存在于业务元数据、索引、缓存、历史和下载入口,需要明确谁是权威来源。
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 一次就结束了吗?
答:还要控制稳定标识、版本可见性、旧片段清理和重复任务。
问:文件删了,向量库迟点删可以吗?
答:外部清理可补偿,但读侧应立即遵守不可访问状态,并处理缓存与在途请求。
问:提示词写“不泄露其他租户”够吗?
答:不够。资料进入模型前就要按可信身份限制。
问:最终删掉引用,就没有泄漏吗?
答:正文、日志和外部重排请求仍可能包含内容,应检查完整链路。
问:模型被要求“不泄漏”够吗?
答:不够,未授权数据应在进入模型之前阻断。
问:检索后过滤可以替代前置授权吗?
答:不可以,内容可能已经进入其他处理与外部服务。