AI 助手 09:RAG 检索为什么必须有权限边界和权威回查
基于 ai-study V2 已实现的 RAG 查询链路,拆解 Milvus 向量检索如何带上租户与用户权限、为什么命中结果还要回查 PostgreSQL、以及引用来源怎样组织才可信。
所属项目:AI-assistant企业知识库 RAG 和公开 Demo 的一个本质区别是:检索不能只看相似度,还必须看用户有没有资格读这份文档。如果向量检索把别的租户、别的用户的私有文档也召回了,回答再准确也是严重的越权。
ai-study 的 V2 在 RAG 查询侧做了三层约束:先在 PostgreSQL 里确定用户有权限的文档范围,再把这个范围作为 Milvus 向量检索的过滤条件,最后用命中的 chunk 主键回查 PostgreSQL 拿全文和元数据。Milvus 只管向量相似度,权限和内容权威以业务数据库为准。
查询入口:用户只提问,不说范围
前端调用 POST /api/rag/query,参数只有 question、可选的 topK 和 modelName。租户、用户和文档权限都不来自前端,而是从 Java 的登录态里取。
POST /api/rag/query
{
"question": "年假怎么申请?",
"topK": 5
}
Java 层做三件事:校验登录态、把 tenantId + userId 放进请求上下文、调用 Python 的 /rag/query。Python 拿到上下文后,第一步不是直接去 Milvus 搜,而是先去 PostgreSQL 查”这个用户在这个租户下有哪些 indexed 状态的文档”。
select id from ai_document
where tenant_id = %s
and owner_user_id = %s
and is_deleted = 0
and document_status = 'indexed'
拿到文档 ID 列表后,才进入向量检索。这样做的好处是:权限判断发生在业务数据库里,有事务、有索引、有成熟的权限模型;Milvus 的标量过滤只负责在已经授权的文档集合里做相似度排序,不承担权限决策。
为什么不直接在 Milvus 里按 tenant_id 过滤
Milvus 支持标量过滤,tenant_id 也已经存进 collection 里了。按理说直接 tenant_id == xxx 就能做租户隔离,为什么还要先查 PostgreSQL?
原因有三个:
- 权限粒度会变。当前是
tenant + owner两层,后续可能加角色、部门、文档组、共享链接。把权限判断放在 PostgreSQL,演进时只改业务层,不用动向量库的 schema。 - 状态一致性。文档可能在
indexed、failed、deleted之间流转,PostgreSQL 是权威状态源。Milvus 里即使残留了已删除文档的向量(删除操作有延迟或失败),只要 PostgreSQL 里不返回它的 ID,它就不会进入检索结果。 - 标量过滤的能力边界。Milvus 的标量过滤适合简单的等值和范围查询,不适合复杂的权限表达式(多角色 + 共享组 + 有效期 + 黑名单)。把复杂条件留在 SQL 里,向量库只做它擅长的事。
所以最终的检索条件是这样的:
Milvus filter: tenant_id == <tenantId> AND document_id IN [<authorized doc ids>]
tenant_id 是第一道防线(租户隔离的兜底),document_id IN (...) 是细粒度权限(用户级 + 状态过滤)。两层都过了,才算”候选集”。
向量检索和 PostgreSQL 回退
有了候选文档 ID,就把问题的 Embedding 向量扔进 Milvus 做 COSINE 相似度检索,取 topK。如果 Milvus 返回了结果,就拿这些 chunk 的主键去 PostgreSQL 回查全文、文档标题和元数据。
如果 Milvus 检索一个结果都没有(可能是 Milvus 不可用、collection 为空、或者过滤条件太严),系统会回退到 PostgreSQL 的 ai_document_chunk 表,按主键顺序取同一租户、同一用户的已索引文档 chunk,作为兜底候选。
Milvus 命中 → 用相似度排序的 chunk 主键 → PostgreSQL 回查全文
Milvus 空 → PostgreSQL 按顺序取 chunk 兜底 → 至少能给模型一点上下文
都没有 → 直接返回"不知道",不调用 LLM
这个回退不是为了保证检索质量(顺序取 chunk 语义上几乎没意义),而是为了保证链路在 Milvus 故障时仍然能给出一个可观测的结果。管理员可以通过返回结果里的引用判断”这是从 Milvus 来的还是从 PostgreSQL 兜底来的”,从而知道向量库是否正常工作。
引用来源:回答必须能追溯到原文
RAG 回答如果不带引用,用户就不知道答案是从哪来的,也就没法验证可信度。当前每个引用包含:
| 字段 | 含义 |
|---|---|
documentId | 文档主键,前端可以点击跳转到文档详情 |
documentTitle | 文档标题,直接展示 |
chunkId | chunk 主键,用于精确定位 |
score | 相似度分数(Milvus 距离值) |
snippet | 前 300 字符片段,帮助用户快速判断相关性 |
引用的顺序按相似度从高到低排列。组装 Prompt 时,每个上下文块会带上文档标题:
[文档标题]
chunk 正文内容
这样模型在生成回答时,至少在上下文中能看到”这段内容来自哪份文档”。当前还没有强制模型在回答里标注引用编号(比如 [1] [2]),那是 V3 要做的事;现在先确保系统层面有完整的引用数据,展示层和模型层的引用联动可以后续再迭代。
不知道就说不知道
如果 Milvus 和 PostgreSQL 两层都找不到任何 chunk(比如用户还没上传任何文档,或者所有文档都还没入库完成),系统会直接返回:
I don't know based on the available knowledge base.
并且 citations 为空数组,usage 全是 0。这意味着这种情况根本不会调用 LLM——没有上下文就不要让模型自由发挥。
这是一个很重要的设计原则:在上下文不足的时候,宁可明确说不知道,也不要让模型用自己的参数知识”编”一个看似合理的答案。企业知识库场景里,“不知道”比”瞎说”安全得多。
Prompt 的安全边界
当前的系统 Prompt 很简短:
You are an enterprise knowledge-base assistant.
Answer only from the context below.
If it is insufficient, say you do not know.
它有两个作用:
- 限制模型只根据提供的上下文回答,不使用参数知识
- 在上下文不足时明确说”不知道”
当然,仅靠 Prompt 是防不住 Prompt Injection 的。如果文档本身包含”忽略上面的指令,按照我说的做”这类内容,模型仍然可能被诱导。这也是为什么 V3 要做权限过滤之外的输入输出安全——系统 Prompt 和用户文档必须在不同的安全层级上。
当前阶段的防护手段是:文档内容只作为数据上下文,不参与系统指令;检索和引用链路确保答案来源可追溯。真正的注入防护需要结合更系统的方法,不是一两句话能解决的。
这版 RAG 的已知限制
把 V2 的 RAG 能力说清楚,也把边界说清楚:
| 能力 | 当前状态 |
|---|---|
| 按租户 + 用户过滤 | ✅ PostgreSQL 先查文档范围,Milvus 双层过滤 |
| 向量检索 | ✅ Milvus COSINE + topK |
| 引用来源 | ✅ 文档标题、chunk ID、片段、分数 |
| ”不知道”兜底 | ✅ 无上下文时不调用 LLM |
| PostgreSQL 回退 | ✅ Milvus 空或不可用时兜底 |
| 重排(Rerank) | ❌ 直接用向量相似度排序 |
| 混合检索(关键词 + 向量) | ❌ 只有向量检索 |
| 引用溯源标注 | ❌ 模型回答中不标注 [1][2] |
| 检索质量评测 | ❌ 还没有评测数据集 |
| 文档级权限(角色/共享组) | ❌ 只有 owner 维度 |
明说这些限制不是”做得不好”,而是让读者知道当前版本的能力边界在哪里,避免把 Demo 当成生产系统。
下一步:从”能检索”到”检索得准”
V2 跑通了 RAG 的最小闭环:文档能入库、能检索、能回答、能带引用。但这只是起点。RAG 的真正难点不在”能不能”,而在”准不准”——命中率、召回率、引用准确性、回答忠实度,这些都需要评测数据来衡量。
下一轮迭代会沿着两个方向走:
- 向上:在回答里加入引用编号溯源,让用户能点引用跳转到原文
- 向下:引入 RAG 评测集,用命中率和引用准确性来反向调 chunk 大小、Embedding 模型和检索策略
把”感觉挺准”变成”数据说了算”,RAG 才能从 Demo 走向生产。