主题
14.16-知识库权限控制
要点
- 普通员工问了一句「公司明年战略方向是什么」,RAG 返回了管理层才能看到的战略规划——这就是权限泄漏,一次就构成安全事故
- 权限控制必须在向量数据库的 filter 层强制执行,不能只在应用层后过滤——否则 topK 结果可能被权限过滤吃光
- 三层权限模型:知识库级隔离 → 文档级权限 → 字段级脱敏,逐层收窄可见范围
- 权限变更同步有三种策略:payload 原地更新、删除重建、权限与向量分离存储,选择取决于变更频率和数据规模
- 缓存 key 必须包含用户身份或权限组合,否则用户 A 的缓存会被用户 B 直接命中——权限隔离从写入层穿透到缓存层
- 审计日志记录每次检索的权限过滤条件,是安全合规的最低要求
一、权限泄漏:RAG 最隐蔽的安全事故
企业知识库里有一份管理层会议纪要,内容涉及下季度的组织架构调整。一个普通员工在 RAG 系统里问:「最近有什么组织变动吗?」LLM 回答:
根据管理层会议纪要(2026-07-15),技术部和产品部将合并为「产品技术部」,
原技术总监张明将担任新部门副负责人。这个回答泄露了管理层才能看到的信息。 员工不应该知道这件事——但 RAG 系统在检索阶段没有检查权限,把管理层文档的 chunk 直接塞进了上下文。
这不是假设场景。任何没有在向量检索层做权限过滤的 RAG 系统,都存在这个风险。问题出在哪里?检索 pipeline 从用户问题到 topK 结果之间,少了一道权限关卡:
用户提问 → embedding → 向量检索 → [权限过滤缺失] → 上下文拼接 → LLM 生成如果只在应用层对最终结果做过滤,向量检索可能已经返回了无权文档的 chunk,而 LLM 已经基于这些 chunk 生成了回答——信息已经进入模型上下文,泄漏已经发生。 权限控制必须卡在向量检索这一步,在数据库返回结果之前就把无权文档排除。
这一篇解决三个问题:权限怎么分层、filter 怎么执行、权限变了怎么同步。
二、三层权限模型
2.1 知识库级隔离
最基本的隔离单位是知识库本身。不同租户或部门拥有独立的知识库,用户只能查询自己有权限的知识库:
typescript
// 用户可访问的知识库列表
const userAccessibleKBs = await getUserKnowledgeBases(user.id)
// 检索时限定知识库范围
const results = await vectorDB.search(queryVector, {
filter: {
knowledge_base_id: { $in: userAccessibleKBs },
},
topK: 5,
})知识库级隔离实现简单,适合部门间信息完全独立的场景。但如果一个知识库内部还有「全员可见」和「管理层可见」的区分,就需要更细的粒度。
2.2 文档级权限
每个文档有自己的访问控制列表。写入向量数据库时,把权限信息存进 payload:
typescript
// 文档权限模型
type DocumentPermissions = {
documentId: string
accessUserIds: string[] // 可访问的用户 ID
accessRoles: string[] // 可访问的角色
visibility: 'public' | 'internal' | 'private'
}
// 写入时携带权限
await vectorDB.upsert({
id: chunkId,
vector: embedding,
payload: {
text: chunk.content,
document_id: doc.id,
tenant_id: doc.tenantId,
knowledge_base_id: doc.knowledgeBaseId,
// 权限字段
access_user_ids: doc.permissions.accessUserIds,
access_roles: doc.permissions.accessRoles,
visibility: doc.permissions.visibility,
},
})查询时,构造一个权限 filter:文档对当前用户可见,当且仅当它是 public、或者用户在 access_user_ids 中、或者用户的某个角色在 access_roles 中:
typescript
function buildPermissionFilter(user: User): Filter {
return {
must: [
// 租户隔离——必须匹配
{ key: 'tenant_id', match: { value: user.tenantId } },
// 知识库权限——必须在可访问范围内
{ key: 'knowledge_base_id', match: { any: user.accessibleKBs } },
// 文档权限——满足任一条件即可见
{
should: [
{ key: 'visibility', match: { value: 'public' } },
{ key: 'access_user_ids', match: { any: [user.id] } },
...user.roles.map((role) => ({
key: 'access_roles',
match: { value: role },
})),
],
},
],
}
}这个 filter 和向量检索在同一次查询中执行——不是先检索再过滤,而是只检索有权限的结果。 这是权限控制正确执行的关键。
2.3 字段级脱敏
某些场景下,用户能看到文档,但文档中的特定字段需要脱敏——比如员工能看到薪资制度文档,但看不到具体金额。字段级脱敏通常在上下文拼接阶段处理,从 chunk payload 中移除敏感字段后再传给 LLM:
typescript
function sanitizeChunk(chunk: Chunk, user: User): string {
const payload = chunk.payload
// 非 HR 角色隐藏薪资字段
if (!user.roles.includes('hr') && payload.salary_info) {
return payload.text.replace(/薪资[^。,]*/g, '[已脱敏]')
}
return payload.text
}三层权限逐层收窄:知识库级决定能不能查、文档级决定能不能看、字段级决定能看到什么。三层组合起来,覆盖了企业知识库绝大多数权限需求。
三、向量 filter:权限的执行层
3.1 为什么不能在应用层过滤
直觉上最简单的做法:先检索 topK 条结果,再在代码里过滤掉无权限的文档。
typescript
// ❌ 错误:应用层后过滤
const results = await vectorDB.search(queryVector, { topK: 20 })
const filtered = results.filter((r) => hasPermission(user, r.metadata.documentId))
return filtered.slice(0, 5)这行不通,原因有三个:
- 结果数量不可控——检索 20 条,过滤后可能只剩 2 条,LLM 拿到的上下文远远不够。如果 20 条全没权限,返回空——但有权限的文档排在第 21 条之后
- 性能浪费——检索了无权文档的向量距离,做了无用的排序计算
- 安全窗口暴露——虽然最终返回给用户的结果被过滤了,但无权文档的内容已经加载到应用内存中,如果上下文拼接发生在这一步,信息已经进入 LLM
正确做法是把权限条件下沉到向量数据库的查询中:
typescript
// ✅ 正确:向量数据库层 filter
const results = await vectorDB.search(queryVector, {
topK: 5,
filter: buildPermissionFilter(user),
})向量数据库在执行 ANN(近似最近邻)搜索时,先根据 filter 条件排除不匹配的向量,只在满足条件的向量集合中搜索最近邻。topK 返回的 5 条结果,全部是用户有权限的文档。 不存在「检索了但被过滤掉」的浪费。
3.2 filter 的执行机制
以 Qdrant 为例,payload index 上的 filter 在搜索时的执行流程是:
查询进入 → filter 条件解析 → HNSW 图遍历时跳过不匹配的向量 → 返回 topKfilter 不是检索完再过滤,而是在 HNSW 图遍历过程中就跳过不满足条件的节点。这意味着:
- filter 条件的复杂度直接影响检索延迟——字段越多、条件越复杂,每次节点判断的开销越大
- payload 字段需要建索引——没有索引的字段,filter 退化为全量扫描
should条件(OR 逻辑)比must条件(AND 逻辑)开销更大——每个节点需要检查多个分支
实际部署中,建议为 tenant_id、knowledge_base_id、visibility 这些高频 filter 字段建立 payload index:
typescript
// Qdrant 建索引
await qdrant.createPayloadIndex('documents', {
field_name: 'tenant_id',
field_schema: 'keyword',
})
await qdrant.createPayloadIndex('documents', {
field_name: 'knowledge_base_id',
field_schema: 'keyword',
})
await qdrant.createPayloadIndex('documents', {
field_name: 'visibility',
field_schema: 'keyword',
})3.3 Hono 中间件统一注入
每个检索接口都手动调用 buildPermissionFilter 容易遗漏。用 Hono 中间件统一注入,确保权限过滤不会丢失:
typescript
// src/middleware/rag-permission.ts
import { createMiddleware } from 'hono/factory'
declare module 'hono' {
interface ContextVariableMap {
ragPermissionFilter: Filter
}
}
export const ragPermissionMiddleware = createMiddleware(async (c, next) => {
const user = c.get('user')
if (!user) {
return c.json({ error: 'Unauthorized' }, 401)
}
// 从缓存或数据库获取用户权限
const permissions = await getUserPermissions(user.id)
const filter = buildPermissionFilter({ ...user, ...permissions })
c.set('ragPermissionFilter', filter)
await next()
})
// 路由使用
app.post('/api/rag/search', ragPermissionMiddleware, async (c) => {
const filter = c.get('ragPermissionFilter')
const { query, topK } = await c.req.json()
const queryVector = await embed(query)
const results = await vectorDB.search(queryVector, {
topK: topK ?? 5,
filter,
})
return c.json({ results })
})中间件的价值在于:权限过滤变成了基础设施,而不是每个业务接口都需要记得处理的逻辑。 新增检索接口时,只要挂载中间件,权限隔离就自动生效。
四、权限同步:三种策略的取舍
权限模型和 filter 执行解决了「查询时怎么过滤」的问题。但权限不是静态的——文档的访问列表会变,员工会调岗,知识库会重新划分。当文档权限发生变化时,向量数据库里的 payload 需要同步更新。
4.1 策略一:payload 原地更新
权限变更时,找到该文档的所有 chunk,直接更新 payload 中的权限字段:
typescript
async function updateDocumentPermissions(
documentId: string,
newPermissions: DocumentPermissions,
): Promise<void> {
// 批量更新所有 chunk 的权限字段
await qdrant.overwritePayload('documents', {
filter: { must: [{ key: 'document_id', match: { value: documentId } }] },
payload: {
access_user_ids: newPermissions.accessUserIds,
access_roles: newPermissions.accessRoles,
visibility: newPermissions.visibility,
},
})
}适用场景:权限变更频率低(每天几次以内),文档 chunk 数量适中。
优势:不需要重新生成 embedding,操作轻量,延迟低。
局限:如果权限频繁变更(比如实时协作场景),每次变更都要触发 payload 更新,写入压力不可忽视。
4.2 策略二:删除重建
权限变更幅度大(比如整个知识库的权限重组),或者 chunk 内容也需要更新时,直接删除旧的 chunk 重新生成:
typescript
async function rebuildDocument(documentId: string): Promise<void> {
// 1. 删除该文档的所有 chunk
await qdrant.delete('documents', {
must: [{ key: 'document_id', match: { value: documentId } }],
})
// 2. 重新获取文档内容
const doc = await getDocumentFromDB(documentId)
// 3. 重新切分、嵌入、写入
const chunks = chunkDocument(doc.content)
const embeddings = await embedChunks(chunks.map((c) => c.text))
await qdrant.upsert('documents',
chunks.map((chunk, i) => ({
id: `${documentId}-chunk-${i}`,
vector: embeddings[i],
payload: {
text: chunk.text,
document_id: documentId,
tenant_id: doc.tenantId,
knowledge_base_id: doc.knowledgeBaseId,
...buildPermissionPayload(doc.permissions),
},
})),
)
}适用场景:权限和内容同时需要更新,或权限结构发生了根本变化。
局限:需要重新生成 embedding,成本高。大文档的重建可能需要数秒到数十秒。
4.3 策略三:权限与向量分离
不把权限存在向量数据库的 payload 里,而是存在关系数据库中。查询时先从关系数据库查出用户可访问的文档 ID 列表,再用作向量检索的 filter:
typescript
async function searchWithPermissions(
queryVector: number[],
user: User,
): Promise<SearchResult[]> {
// 1. 从关系数据库查用户可访问的文档 ID
const accessibleDocIds = await db
.select({ id: documents.id })
.from(documents)
.innerJoin(documentPermissions, eq(documents.id, documentPermissions.documentId))
.where(
or(
eq(documentPermissions.visibility, 'public'),
inArray(documentPermissions.userId, [user.id]),
inArray(documentPermissions.role, user.roles),
),
)
// 2. 用文档 ID 列表作为向量 filter
if (accessibleDocIds.length === 0) return []
return vectorDB.search(queryVector, {
filter: {
document_id: { $in: accessibleDocIds.map((d) => d.id) },
},
topK: 5,
})
}适用场景:权限变更非常频繁、权限逻辑复杂(涉及组织架构、继承关系等),或需要强一致性保证。
局限:如果用户可访问的文档数量很大(几千以上),$in filter 会很长,向量检索性能下降。可以通过先查知识库级权限缩小范围来缓解。
4.4 选择策略的判断标准
| 维度 | payload 原地更新 | 删除重建 | 权限与向量分离 |
|---|---|---|---|
| 权限变更频率 | 低(每天几次) | 极低(结构变更时) | 高(实时/分钟级) |
| 实现复杂度 | 低 | 中 | 高 |
| 性能影响 | 写入开销小 | embedding 重建开销大 | 查询时额外一次 DB 查询 |
| 一致性 | 最终一致(有延迟) | 最终一致 | 强一致(权限变更立即生效) |
| 适用数据规模 | 任意 | 中小文档 | 文档量大时 $in 可能过长 |
大多数团队从 payload 原地更新开始,遇到性能或一致性问题时再迁移到权限分离方案。
五、缓存隔离与审计
5.1 缓存必须按权限隔离
检索结果缓存如果用查询文本做 key,不同用户会共享同一份缓存:
typescript
// ❌ 危险:共享缓存导致权限泄漏
const cacheKey = `rag:${hash(queryText)}`
const cached = await redis.get(cacheKey)
if (cached) return JSON.parse(cached)用户 A 查了「公司战略」,结果缓存了包含管理层文档的检索结果。用户 B 问同样的问题,直接命中缓存——拿到了用户 A 的权限范围内的结果,包括管理层文档。
修复方式是把用户身份或权限组合编入缓存 key:
typescript
// ✅ 按用户 ID 隔离
const cacheKey = `rag:${user.id}:${hash(queryText)}`或者按权限组合隔离,相同权限的用户可以共享缓存:
typescript
// ✅ 按权限组合隔离(缓存命中率更高)
const permissionKey = hash(
JSON.stringify(user.accessibleKBs.sort()),
)
const cacheKey = `rag:${permissionKey}:${hash(queryText)}`权限组合 key 的缓存命中率更高——同一个角色的所有用户共享一份缓存,而不是每个用户各自一份。但当权限变更时,需要主动清除相关权限组合的缓存,否则用户会看到旧权限下的结果。
5.2 权限变更后的缓存失效
权限同步和缓存失效是联动的。如果权限变更了但缓存没清,用户会在缓存有效期内看到不该看的文档(或看不到该看的文档)。
两种处理方式:
方式一:权限变更时主动清除缓存。 在 updateDocumentPermissions 函数里,同步删除相关缓存 key:
typescript
async function updateDocumentPermissions(
documentId: string,
newPermissions: DocumentPermissions,
): Promise<void> {
// 更新向量数据库 payload
await qdrant.overwritePayload(...)
// 清除该文档相关的检索缓存
const cachePattern = `rag:*`
// 实际实现中用 Redis SCAN 或按 documentId 建立反向索引
await invalidateDocumentCache(documentId)
}方式二:给缓存设置较短的 TTL。 比如 5 分钟过期,权限变更最多延迟 5 分钟生效。适合对一致性要求不极端的场景,实现简单但有窗口期。
5.3 审计日志
记录每次检索的权限过滤条件,是安全合规的最低要求:
typescript
async function searchWithAudit(
queryVector: number[],
user: User,
filter: Filter,
): Promise<SearchResult[]> {
const startTime = performance.now()
try {
const results = await vectorDB.search(queryVector, {
topK: 5,
filter,
})
await auditLog.record({
event: 'rag_search',
userId: user.id,
tenantId: user.tenantId,
filterApplied: filter,
resultCount: results.length,
latency: Math.round(performance.now() - startTime),
})
return results
} catch (err) {
await auditLog.record({
event: 'rag_search_error',
userId: user.id,
tenantId: user.tenantId,
error: String(err),
})
throw err
}
}审计日志的价值不在实时拦截,而在事后追溯——当发生权限泄漏事故时,你能查到谁在什么时候、用什么 filter 检索到了什么结果。
六、从权限控制到质量评估
权限控制解决了「用户能不能看到」的问题,但还有一个问题没有回答:RAG 系统检索到的文档质量到底怎么样? topK 里的文档是否真的和用户问题相关?上下文拼接有没有引入噪声?
下一篇 RAG 评估指标会回答这个问题——怎么衡量检索质量和生成质量,怎么建立持续评估机制,怎么在参数调整时判断系统是在改进还是在退化。