大规模文档管理系统中的 RAG 架构:权限、版本和检索的三位一体设计

发布时间:2026/7/24 15:34:42

大规模文档管理系统中的 RAG 架构:权限、版本和检索的三位一体设计 大规模文档管理系统中的 RAG 架构权限、版本和检索的三位一体设计一、深度引言与场景痛点给企业内部文档系统加 RAG 能力时大多数人一开始只关心检索效果好不好等上线了才发现三个大坑在等着最头疼的是权限问题。企业文档有严格的访问控制——研发部的设计文档不能让市场部搜到A 项目的技术方案 B 项目的人不应该看到。但 RAG 的向量检索是你把文档 embedding 入库之后向量空间里的相似度可不管什么 ACL。搜Q3 营收目标Top-5 结果里可能有 3 条是隔壁部门的机密文档——不是模型的问题是权限没有下沉到检索层。然后是版本问题。一个产品的 PRD 文档可能已经迭代了十几个版本用户搜索时应该看到最新版本还是所有版本如果只看最新版那Q1 的需求变更在 Q2 版本里被覆盖掉了就搜不到了如果所有版本都入库那 10 个版本乘 1 万份文档就是 10 万个 chunk检索精度直线下降。还一个被忽略的是检索结果的时效性治理。企业文档不是静态的——今天发布的新规范明天就要能被搜到过期的废止文档必须下降权重甚至不在结果中出现。如果全靠人工打标签维护状态那文档管理员就得忙死。这三个维度权限、版本、检索不是独立的——权限过滤可能要先于向量检索执行版本策略决定了什么 chunk 该入库而检索结果的排序规则又受到前面两者的约束。二、底层机制与原理深度剖析三位一体的架构设计思路是把权限和版本作为检索的前置过滤层而非后置修正层。也就是说在执行向量检索之前就已经确定了当前用户有权看到和应该看到的文档集合检索本身只在这个集合上运行。关键改动是搜索空间的预计算。对于大规模场景百万级文档不能每次检索都在向量空间里实时做权限过滤——那意味着每个查询都要扫描 ACL 表再做集合运算。更好的方案是维护一个有效搜索空间的缓存把每个用户或角色组的可访问文档 ID 集合预计算好检索时直接用 Milvus 的expr参数做标量过滤。三、生产级代码实现import asyncio import hashlib import logging import time from dataclasses import dataclass, field from enum import Enum from functools import lru_cache from typing import Optional import aioredis from pydantic import BaseModel, Field, ValidationError from pymilvus import Collection, connections, utility logging.basicConfig(levellogging.INFO) logger logging.getLogger(__name__) # ── 领域模型 ───────────────────────────────────────────── class DocStatus(str, Enum): ACTIVE active # 生效中 DEPRECATING deprecating # 即将过期 DEPRECATED deprecated # 已废止 class VersionPolicy(str, Enum): LATEST_ONLY latest_only TIME_RANGE time_range ALL_ACTIVE all_active class Document(BaseModel): doc_id: str title: str content: str version: int status: DocStatus DocStatus.ACTIVE owner: str project_id: str access_level: str internal # public / internal / confidential / secret created_at: float Field(default_factorytime.time) class UserContext(BaseModel): user_id: str role: str viewer project_ids: list[str] Field(default_factorylist) version_policy: VersionPolicy VersionPolicy.LATEST_ONLY # ── 权限管理 ───────────────────────────────────────────── class ACLManager: 访问控制管理器 def __init__(self, redis_url: str redis://localhost:6379): self._redis: Optional[aioredis.Redis] None self.redis_url redis_url async def _get_redis(self) - aioredis.Redis: if self._redis is None: self._redis await aioredis.from_url( self.redis_url, decode_responsesTrue ) return self._redis async def get_accessible_docs( self, user: UserContext, cache_ttl: int 300 ) - set[str]: 获取用户有权限访问的文档 ID 集合带缓存 redis await self._get_redis() cache_key facl:{user.user_id}:{user.role} cached await redis.get(cache_key) if cached: return set(cached.split(,)) # 模拟从 DB/RBAC 服务查询权限实际应查数据库 # roleadmin 看所有roleviewer 看 internal 及以下 accessible set() if user.role admin: # 查所有文档 ID accessible {doc-001, doc-002, doc-003, doc-004, doc-005} elif user.role viewer: # 只查 access_levelinternal 或 public 的 accessible {doc-001, doc-002, doc-003} else: accessible set() # 项目级过滤 if user.project_ids: accessible { did for did in accessible if any(pid in did for pid in user.project_ids) } await redis.setex(cache_key, cache_ttl, ,.join(accessible)) return accessible # ── 版本管理 ───────────────────────────────────────────── class VersionManager: 文档版本管理器 # 模拟的版本数据库 _doc_versions: dict[str, dict] { doc-001: {latest: 3, active_versions: [1, 2, 3]}, doc-002: {latest: 5, active_versions: [3, 4, 5]}, doc-003: {latest: 1, active_versions: [1]}, } async def resolve_chunk_ids( self, doc_ids: set[str], policy: VersionPolicy ) - list[str]: 根据版本策略解析需要检索的 chunk ID 列表 chunks [] for did in doc_ids: vinfo self._doc_versions.get(did) if vinfo is None: continue if policy VersionPolicy.LATEST_ONLY: chunks.append(f{did}-v{vinfo[latest]}) elif policy VersionPolicy.TIME_RANGE: # 取最近两个活跃版本 versions sorted(vinfo[active_versions])[-2:] chunks.extend(f{did}-v{v} for v in versions) elif policy VersionPolicy.ALL_ACTIVE: chunks.extend(f{did}-v{v} for v in vinfo[active_versions]) return chunks # ── 检索编排器 ─────────────────────────────────────────── class DocumentSearchEngine: 三位一体的文档检索引擎 def __init__( self, milvus_host: str localhost, milvus_port: int 19530, redis_url: str redis://localhost:6379, ): self.acl ACLManager(redis_url) self.version_mgr VersionManager() self.collection_name enterprise_docs self._init_milvus(milvus_host, milvus_port) def _init_milvus(self, host: str, port: int): connections.connect(default, hosthost, portport) if not utility.has_collection(self.collection_name): logger.error(f集合 {self.collection_name} 不存在请先创建) async def search( self, query_embedding: list[float], user: UserContext, top_k: int 10 ) - list[dict]: 执行带权限和版本过滤的向量检索 # Step 1: ACL 过滤 - 获取可访问文档 try: accessible_docs await self.acl.get_accessible_docs(user) except aioredis.ConnectionError as e: logger.error(fACL 服务不可用: {e}) raise RuntimeError(权限校验服务不可用) if not accessible_docs: logger.warning(f用户 {user.user_id} 无任何文档访问权限) return [] # Step 2: 版本解析 - 确定要检索的 chunk chunk_ids await self.version_mgr.resolve_chunk_ids( accessible_docs, user.version_policy ) if not chunk_ids: return [] # Step 3: 构建 Milvus 标量过滤表达式 expr_parts [fchunk_id in {json.dumps(chunk_ids[:1000])}] # 限制 expr 长度 search_expr and .join(expr_parts) # Step 4: 向量检索 标量过滤 try: collection Collection(self.collection_name) await asyncio.to_thread(collection.load) results await asyncio.to_thread( collection.search, data[query_embedding], anns_fieldembedding, param{metric_type: IP, params: {nprobe: 16}}, limittop_k, exprsearch_expr, output_fields[doc_id, title, status, version, chunk_text], ) except Exception as e: logger.error(fMilvus 检索失败: {e}) raise RuntimeError(f检索服务异常: {e}) # Step 5: 时效性权重调整 scored_results [] for hits in results: for hit in hits: status hit.entity.get(status, active) weight {active: 1.0, deprecating: 0.8, deprecated: 0.3} adjusted_score hit.score * weight.get(status, 1.0) scored_results.append({ doc_id: hit.entity.get(doc_id), title: hit.entity.get(title), chunk_text: hit.entity.get(chunk_text), version: hit.entity.get(version), status: status, raw_score: hit.score, adjusted_score: adjusted_score, }) scored_results.sort(keylambda x: x[adjusted_score], reverseTrue) return scored_results[:top_k] # ── 安全日志访问审计 ───────────────────────────────── async def audit_log(user: UserContext, query: str, results: list[dict]): 记录检索审计日志 audit_entry { timestamp: time.time(), user_id: user.user_id, role: user.role, query_hash: hashlib.sha256(query.encode()).hexdigest()[:16], result_count: len(results), accessed_docs: [r[doc_id] for r in results[:3]], max_status: max((r.get(status, ) for r in results), keylen, default), } logger.info(fAUDIT: {audit_entry}) async def main(): engine DocumentSearchEngine() user UserContext( user_idzhangsan, roleviewer, project_ids[proj-a], version_policyVersionPolicy.LATEST_ONLY, ) # 模拟的 query embedding query_emb [0.1] * 768 try: results await engine.search(query_emb, user, top_k5) for i, r in enumerate(results): logger.info( f#{i1} {r[doc_id]} v{r[version]} fscore{r[adjusted_score]:.3f} [{r[status]}] {r[title]} ) await audit_log(user, 检索测试, results) except RuntimeError as e: logger.error(f检索失败降级处理: {e}) except Exception as e: logger.exception(f未预期错误: {e}) if __name__ __main__: asyncio.run(main())四、边界分析与架构权衡权限过滤前置 vs 后置前置过滤检索前确定可访问文档集合性能好但粒度粗——如果某个文档只有第一章对外公开、第二章保密前置过滤会全丢或全保留。后置过滤检索后按 chunk 级别过滤粒度细但需要额外的元数据存储和过滤逻辑。大规模场景建议前置文档数 10 万且权限粒度到段落级别的场景可以后置。ACL 缓存的失效时间权限变更是低频事件但变更的影响是高压力的——一个用户的权限突然被撤销如果缓存里还能访问 5 分钟这 5 分钟里的所有检索结果都是越权的。建议把 TTL 设得短1-3 分钟同时监听权限变更事件主动刷新缓存。版本膨胀的治理PRD 迭代到 v20 之后10 个活跃版本各存一份 embedding 就是 10 倍的存储成本。建议对非最新版本做diff embedding——只存储和最新版不同的句子块检索时动态拼接。时效性权重的可解释性用户搜到结果后如果看到一份被降至 0.3 权重的文档排在后面他们应该知道为什么排在后面。在检索结果 UI 上标注状态如已废止标签比后台悄悄降权更让人信任。五、总结企业 RAG 的三位一体说白了就是权限告诉你能不能看版本告诉你该看哪个检索告诉你哪个最相关。三者不是独立的 pipeline 阶段而是交织在一起的约束条件。架构上做对一件事就能避免大部分坑让权限和版本成为检索的前置过滤器而不是结果阶段的掩码。上线三个月后安全团队没再找过我们——这对做过企业系统的工程师来说就是最好的 KPI 了。

相关新闻