向量检索按相似度召回切片,并不会自动尊重「谁能看哪篇文档」。登录鉴权和租户隔离只能挡住跨系统、跨租户,挡不住同一空间里的越权召回。本文用 Node.js 把文档 ACL 建成可查询数据,在检索前做 ID 过滤、检索后做二次校验,并处理权限变更后的索引失效——权限边界写在查询路径里,而不是指望模型闭嘴。

相似不等于可见

多数 RAG 的检索路径很短:把问题做成向量,近邻搜索取出 TopK 切片,再塞进提示词。这一步优化的是距离,不是授权。切片进了上下文,模型就会读;你事后再加一句「不要引用无权内容」,既没有强制拒绝的接口,也留不下可审计的策略决策。

登录态只回答「你是谁」。租户隔离只回答「你在哪个 workspace」。它们解决的是跨系统、跨租户。真正出问题的场景更土:同一个知识空间里,制度汇编全员可读,董事会纪要只有少数人可读。两篇文档用词重叠、向量很近,若只按相似度召回,无权切片会稳定出现在上下文里。这不是模型「不够听话」,是检索根本没有权限边界。

所以不要把 ACL 寄托在提示词上,也不要假设「embedding 流水线已经按用户分开了」。同一集合、同一租户、不同文档,才是文档级权限要管的范围。下面把这件事收成三块可测试的逻辑:ACL 数据、查询时双门禁、权限变更后的失效。

把文档 ACL 建成可查询的数据

文档级 ACL 的最小模型只回答一件事:某个主体对某篇文档有没有 read。切片必须带稳定的 documentId,检索结果才能回到文档上去做授权判断。建议把「文档/切片」和「授权」拆开,不要让向量 payload 成为 ACL 的唯一副本——payload 适合给检索当加速条件,授权事实来源应能单独更新、单独审计。

主体往往不只是 userId,还有角色和用户组。查询时先在 ACL 服务里展开「用户 → 组/角色」,再求出可读 documentId 集合。展开逻辑集中放一处,避免每个检索入口自己拼条件。权限变更时先写 ACL 表;向量侧同步可以异步,但不能让异步同步成为唯一防线。

下面这个脚本只用 Node.js 标准库,不依赖外部向量服务。向量用固定数组代替真实 Embedding,目的是把权限路径写清楚,你事后把 embed() 换成现有流水线即可。保存为 acl-retrieval.js,执行 node acl-retrieval.js。

// acl-retrieval.js
// 运行: node acl-retrieval.js

function cosineSimilarity(a, b) {
  let dot = 0;
  let na = 0;
  let nb = 0;
  for (let i = 0; i < a.length; i++) {
    dot += a[i] * b[i];
    na += a[i] * a[i];
    nb += b[i] * b[i];
  }
  const denom = Math.sqrt(na) * Math.sqrt(nb);
  return denom === 0 ? 0 : dot / denom;
}

class DocumentAcl {
  constructor() {
    // documentId -> Set of userId
    this.grants = new Map();
    // documentId -> 单调递增,切片携带同一版本才能被预过滤命中
    this.epoch = new Map();
  }

  grant(documentId, userId) {
    if (!this.grants.has(documentId)) this.grants.set(documentId, new Set());
    this.grants.get(documentId).add(userId);
    this.epoch.set(documentId, (this.epoch.get(documentId) || 0) + 1);
  }

  revoke(documentId, userId) {
    const set = this.grants.get(documentId);
    if (set) set.delete(userId);
    this.epoch.set(documentId, (this.epoch.get(documentId) || 0) + 1);
  }

  canRead(userId, documentId) {
    const set = this.grants.get(documentId);
    return Boolean(set && set.has(userId));
  }

  allowedDocumentIds(userId) {
    const ids = [];
    for (const [documentId, users] of this.grants) {
      if (users.has(userId)) ids.push(documentId);
    }
    return ids;
  }

  currentEpoch(documentId) {
    return this.epoch.get(documentId) || 0;
  }
}

class ChunkIndex {
  constructor() {
    this.chunks = [];
  }

  upsert(chunk) {
    this.chunks = this.chunks.filter((c) => c.chunkId !== chunk.chunkId);
    this.chunks.push(chunk);
  }

  // 模拟「向量库只按距离排、元数据可能过期」
  searchRaw(queryVector, topK) {
    return this.chunks
      .map((c) => ({ ...c, score: cosineSimilarity(queryVector, c.vector) }))
      .sort((a, b) => b.score - a.score)
      .slice(0, topK);
  }
}

function retrieve({ acl, index, userId, queryVector, topK }) {
  const allowed = new Set(acl.allowedDocumentIds(userId));

  // 门禁一:检索前用 ID + epoch 收窄,避免无权切片占满 TopK
  const prefiltered = index.chunks.filter((c) => {
    return (
      allowed.has(c.documentId) &&
      c.aclEpoch === acl.currentEpoch(c.documentId)
    );
  });

  const ranked = prefiltered
    .map((c) => ({ ...c, score: cosineSimilarity(queryVector, c.vector) }))
    .sort((a, b) => b.score - a.score)
    .slice(0, topK);

  // 门禁二:对最终条目再校验,堵住过期元数据与竞态
  const verified = ranked.filter((c) => acl.canRead(userId, c.documentId));
  return verified.map(({ vector, ...rest }) => rest);
}

function assert(cond, msg) {
  if (!cond) throw new Error(msg);
}

const acl = new DocumentAcl();
acl.grant("doc-handbook", "alice");
acl.grant("doc-handbook", "bob");
acl.grant("doc-board", "alice");

const index = new ChunkIndex();
index.upsert({
  chunkId: "c1",
  documentId: "doc-handbook",
  content: "差旅报销需在 30 日内提交。",
  vector: [0.9, 0.1, 0.0],
  aclEpoch: acl.currentEpoch("doc-handbook"),
});
index.upsert({
  chunkId: "c2",
  documentId: "doc-board",
  content: "本次董事会讨论了竞品报价与并购条款。",
  vector: [0.88, 0.12, 0.0],
  aclEpoch: acl.currentEpoch("doc-board"),
});

const query = [0.91, 0.09, 0.0];

const bobHits = retrieve({
  acl,
  index,
  userId: "bob",
  queryVector: query,
  topK: 5,
});
assert(
  bobHits.every((h) => h.documentId !== "doc-board"),
  "bob 不得召回董事会文档"
);
assert(bobHits.length === 1 && bobHits[0].chunkId === "c1", "bob 只能看到手册");

const aliceBefore = retrieve({
  acl,
  index,
  userId: "alice",
  queryVector: query,
  topK: 5,
});
assert(
  aliceBefore.some((h) => h.documentId === "doc-board"),
  "撤销前 alice 可以读董事会文档"
);

// 权限已撤、索引尚未重写:事后校验与 epoch 必须让结果变空
acl.revoke("doc-board", "alice");
const aliceAfter = retrieve({
  acl,
  index,
  userId: "alice",
  queryVector: query,
  topK: 5,
});
assert(
  aliceAfter.every((h) => h.documentId !== "doc-board"),
  "撤销后即使旧切片仍在库中也不得返回"
);

const leakedIfRaw = index.searchRaw(query, 5);
assert(
  leakedIfRaw.some((h) => h.documentId === "doc-board"),
  "对照:无门禁的原始近邻仍会命中董事会切片"
);

console.log("ok");
console.log("bob", bobHits);
console.log("alice after revoke", aliceAfter);

这段代码刻意保留了 searchRaw 作为对照:没有门禁时,董事会切片会因为距离更近被召回。接上 ACL 之后,bob 全程看不到 doc-board;alice 被撤销后,即便旧切片还躺在索引里,结果也必须为空。这就是可回归的权限边界:用断言锁住「不得出现的 documentId」,而不是看模型怎么回答。

查询时双门禁怎么接到真实向量库

门禁一是检索前过滤:先取 allowedDocumentIds,再让近邻搜索带上 document_id IN (...)。无权切片不要进入 TopK 竞争,否则你会先召回再丢弃,把有权文档的名额挤掉。门禁二是检索后二次校验:对返回的每条切片再执行 canRead,不通过就丢。TopK 很小,这次检查便宜,用来堵三类问题:过滤条件写漏、payload 里的授权字段过期、ACL 已改但索引未改的窗口期。

两道门都要留。只做预过滤,正确性就绑死在向量库 filter 和元数据新鲜度上;只做事后过滤,召回质量会掉。可读文档极多、不能把百万级 ID 塞进 IN 时,优先让切片和 ACL 待在同一套可 JOIN 的存储里(例如 PostgreSQL + pgvector 按 document_id 关联授权表),或按空间/分组拆集合。无论存储怎么选,应用层对最终 TopK 的二次校验都应保留。

如果你已经在用 pgvector,预过滤就是普通 SQL,不必发明新 API。<=> 是 pgvector 的余弦距离操作符,ANY($1) 绑定可读 ID 列表,和上面脚本里的门禁一是同一件事:

// 需已安装 pg,且 PostgreSQL 启用 vector 扩展
// npm install pg
import pg from "pg";

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });

export async function searchChunks({ userId, queryEmbedding, topK }) {
  const client = await pool.connect();
  try {
    const { rows } = await client.query(
      `
      WITH allowed AS (
        SELECT document_id
        FROM document_acl
        WHERE user_id = $1 AND perm = 'read'
      )
      SELECT c.chunk_id, c.document_id, c.content
      FROM chunks c
      INNER JOIN allowed a ON a.document_id = c.document_id
      WHERE c.acl_epoch = (
        SELECT epoch FROM documents d WHERE d.id = c.document_id
      )
      ORDER BY c.embedding <=> $2::vector
      LIMIT $3
      `,
      [userId, `[${queryEmbedding.join(",")}]`, topK]
    );

    // 门禁二:不信任 JOIN 结果以外的任何缓存
    return rows.filter((row) => row.document_id); // 真实项目里再查一次 ACL 服务
  } finally {
    client.release();
  }
}

这里的要点不是「用了向量库就安全」,而是过滤条件来自 ACL 表,而不是来自模型。JOIN 能减少超长 IN 列表;acl_epoch 让「权限已变、切片未重写」的旧行直接掉出结果集。应用层仍应对返回行做一次 canRead,尤其当你前面还有缓存、代理或多集合查询时。

权限一变,旧切片必须失效

撤销权限时,顺序比「立刻物理删向量」更重要。先写 ACL,让事实来源立刻拒绝;检索路径上的二次校验和 epoch 比对应马上生效,即使向量还在。然后再处理索引:删除切片、更新 payload 里的授权字段,或抬升文档 aclEpoch 并重写仍有效的切片。回归测试要覆盖「ACL 已改、索引未改」这一档,断言结果为空。只测「重索引完成之后」会漏掉线上最常见的窗口。

预过滤如果写在向量 payload 的 allowedUserIds 上,撤销时必须同步改元数据,否则门禁一会放行。这正是不把 payload 当唯一 ACL 的原因:payload 可以加速,不能单独充当授权真相。过期副本、备份 collection、调试用的全量索引,只要还挂在线上查询入口,就是绕过。权限变更的发布清单里应包含:哪些查询入口、是否走过滤、旧集合何时下线。

接到现有 Embedding 流水线时,改动通常很小:切片写入继续走原来的 embed 与 upsert,只多带 documentId 和 aclEpoch;查询侧在调用向量库之前向 ACL 要可读 ID(或改成 JOIN),回来后再校验一遍。不要为了权限单独训练或「让模型学会保密」。能写成 node acl-retrieval.js 那样稳定失败的断言,这层边界才算接上。

总结

向量检索泄漏无权文档,多半不是生成模型的问题,而是召回从未做文档级授权。登录和租户隔离不够;同一空间内的可见性必须是可查询的 ACL 数据。查询路径用双门禁:检索前按可读 ID(以及 epoch)收窄,检索后对 TopK 再校验。权限变更先写 ACL,再让旧切片因校验或版本不一致而失效,而不是等索引慢慢追上。把「某用户不得看到某 documentId」写成自动测试,这套边界才能跟 Embedding 流水线一起回归,而不是靠提示词碰运气。