--- id: ai.rag.scenario.permission-aware-kb title: 带权限的企业知识库问答:不同部门看到不同内容 type: scenario domain: ai stack: [rag, vector-db, elasticsearch, fastapi] tags: [企业知识库, 权限过滤, 文档解析, 混合检索, 重排, 拒答, 增量更新] level: advanced prerequisites: [ai.rag.generation-eval] est_hours: 3 related: [ai.rag.interview, ai.vector-db.scenario.multi-tenant-index, backend.elasticsearch.index] week: [5, 6] --- # 带权限的企业知识库问答:不同部门看到不同内容 ## 业务背景与问题 公司把员工手册、IT 指南、财务制度、各部门内部文档都导入知识库,做一个“制度问答助手”。试运行时发现: 1. 研发同事问“A 级调薪幅度多少”,助手根据 HR 内部的薪酬文档给出了答案——**越权**; 2. PDF 里的表格(差旅标准)被解析成一串数字,问“一线城市住宿标准”答错; 3. 问“报错 0x80070005 怎么办”,检索不到包含这个错误码的文档; 4. 制度更新后,新旧两个版本都被检索到,答案自相矛盾; 5. 知识库里没有的问题(股票期权),助手也一本正经地回答。 ## 约束 - 权限以现有的组织架构(部门、角色)为准,文档有“全员 / 指定部门 / 指定角色”三种可见范围。 - 文档格式:PDF(含表格、扫描件)、Word、Confluence 类网页。 - 答案必须给出引用来源,方便员工核对原文。 - 回答延迟目标:首字 2 秒以内(流式)。 ## 方案设计 ```mermaid flowchart LR subgraph 入库(异步任务) S[文档源] --> P[解析:PDF/Word/HTML → Markdown
表格转 Markdown 表格,扫描件 OCR] --> C[按标题切分 + 标题路径前缀] C --> E[嵌入] --> V[(向量库
块 + acl + doc_id + version)] C --> K[(Elasticsearch
中文分词 BM25)] end subgraph 问答 U[用户] --> A[鉴权:取部门 / 角色] --> R1[向量检索 + acl 过滤] A --> R2[ES 检索 + acl 过滤] R1 & R2 --> F[RRF 融合 → 重排 top 5] --> G{最高分 ≥ 阈值?} G -->|否| N[拒答 + 转人工入口] G -->|是| L[带编号资料的提示词 → 流式生成] --> O[答案 + 引用] end ``` 1. **权限在检索阶段过滤**:每个块带 `acl`(可见的部门 / 角色列表),检索时作为过滤条件,**无权限的内容根本不进入提示词**。不要“先检索再让模型别说”。 2. **文档解析**:PDF 先转结构化文本,表格转成 Markdown 表格整体作为一个块;扫描件走 OCR;去掉页眉页脚。解析结果抽样人工检查。 3. **混合检索**:向量检索负责语义,Elasticsearch(IK 等中文分词)负责错误码、编号等精确词;RRF 融合后用重排模型取前 5。 4. **版本和增量更新**:块带 `doc_id` 和 `version`;文档更新时按 `doc_id` 删除旧块再写入新块(或写入新版本后切换),避免新旧共存。 5. **拒答**:重排后最高分低于阈值直接拒答,给出“提交工单 / 联系 HR”入口。 6. **评测和回归**:评测集包含有权限 / 无权限 / 无答案 / 精确词 / 表格类问题,每次改动跑一遍。 ## 关键代码 权限过滤和拒答的最小实现(完整代码 [examples/mini-rag/mini_rag.py](../examples/mini-rag/mini_rag.py)): ```python def allowed(self, user_depts: set[str]) -> np.ndarray: return np.array([("all" in c.acl) or bool(c.acl & user_depts) for c in self.chunks]) def vector_search(self, q: str, mask: np.ndarray, k: int): scores = self.matrix @ embed(q) scores[~mask] = -np.inf # 无权限的块直接排除,不会进入提示词 ... ``` 示例里验证了:HR 部门问调薪,命中薪酬制度;研发部门问同一个问题,薪酬制度不会出现在候选中(结果为空 → 拒答);问知识库中没有的“股票期权”→ 拒答。 真实向量库的过滤写法(以 Chroma 为例,`where` 条件;其他向量库语法不同,以官方文档为准): ```python results = collection.query( query_embeddings=[q_vec], n_results=20, where={"$or": [{"acl_all": True}, {"dept": {"$in": list(user_depts)}}]}, ) ``` ## 取舍 | 选择 | 优点 | 缺点 | | --- | --- | --- | | **检索时按 acl 过滤(本方案)** | 安全,无权限内容不会进入模型 | 权限变化时要更新块的 acl | | 按部门分别建索引 | 隔离彻底 | 跨部门文档要重复存储;部门多时管理困难 | | 检索后再过滤 | 实现简单 | top-k 被无权限内容占满后,有权限的内容反而召回不到 | | 只靠提示词让模型不说 | —— | **不可接受**,模型可能泄露 | | 向量库 + ES 两套存储 | 混合检索效果好 | 两边数据同步、一致性要处理 | | 只用支持混合检索的单一引擎 | 运维简单 | 依赖具体产品的能力(ES、部分向量库已支持向量 + 关键词),以官方文档为准 | ## 易错点 - 权限只在前端或网关判断,检索层不过滤。 - 先取 top-5 再过滤权限:结果可能被过滤成空,实际上还有相关的有权限内容。先过滤再取 top-k。 - 用户换部门后缓存的答案仍可见:缓存 key 要包含权限维度,或不缓存敏感问答。 - 表格被拆成多块:一行数据和表头分离,数字失去含义。 - 文档删除 / 下线后向量库里还有残留。 - 日志里记录了完整的提示词(包含敏感文档内容),日志本身成为泄露渠道。 ## 面试怎么讲 > “企业知识库最大的问题是权限。我在每个块上带了 acl 元数据,检索阶段就过滤,无权限的内容不会进入提示词,而不是靠提示词让模型别说;检索用向量加 ES 的混合检索再重排,解决错误码这类精确查询;文档解析时把表格转成 Markdown 整体入库;文档按 doc_id 和版本增量更新;最高分低于阈值就拒答。评测集专门包含无权限和无答案的问题,每次改动都跑回归。”