Demo 和生产之间的墙
前面十九篇的代码有一个共同特点:所有人共享同一个向量库,任何问题都能检索到任何文档。
这在 Demo 里没问题,但放到企业环境里会立刻崩溃:
- 公司 A 的数据库能被公司 B 的用户查到
- 财务数据能被普通员工检索到
- HR 政策能被外包人员拿到
- 一个用户疯狂发请求让系统崩溃,全公司都受影响
生产级企业 RAG 需要三层防护:
用户请求
↓ 限流检查 ------ 这个用户还在配额内吗?
↓ 缓存查询 ------ 这个问题有缓存过吗?
↓ 租户路由 ------ 去哪个知识库?
↓ 权限过滤 ------ 在这个知识库里,这个用户能看什么?
↓ 检索 + 生成 ------ 拿到被授权的内容后生成答案
↓ 写入缓存 ------ 存下来下次直接用
本文逐层实现这个架构。
第一层:多租户隔离
策略:每个租户一个 Qdrant Collection
每个客户/部门的知识库存在独立的 Collection 里,Collection 之间物理隔离------你无法在 acme_corp 里搜到 globex_corp 的内容,因为这两个 Collection 根本不在同一个向量空间里。
python
from langchain_qdrant import QdrantVectorStore
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams
qdrant_client = QdrantClient(":memory:") # 生产用 host="qdrant-server"
tenant_stores: dict[str, QdrantVectorStore] = {}
for tenant_id, docs in TENANT_DOCS.items():
qdrant_client.create_collection(
collection_name=tenant_id,
vectors_config=VectorParams(size=1024, distance=Distance.COSINE),
)
store = QdrantVectorStore(
client=qdrant_client,
collection_name=tenant_id,
embedding=embeddings,
)
store.add_documents(docs)
tenant_stores[tenant_id] = store
路由逻辑也很简单------用户请求带上 tenant_id,从 tenant_stores 里取对应的 Collection:
python
def get_retriever(tenant_id: str, role: str, k: int = 3):
if tenant_id not in tenant_stores:
raise ValueError(f"Unknown tenant: {tenant_id}")
store = tenant_stores[tenant_id]
# 下面加权限过滤(见第二层)
...
为什么不用同一个 Collection + tenant_id 字段过滤?
技术上可行,但有两个问题:
- 一旦过滤器出 bug,A 公司的数据就泄露给了 B 公司------一个 Collection 没有硬边界
- Collection 级别隔离还带来存储层面的独立(删除一个租户的数据只需要 drop 对应 Collection,干净彻底)
对于同一公司内不同部门这种"软隔离"场景,元数据过滤足够;对于不同客户这种"硬隔离"场景,独立 Collection 更安全。
第二层:权限控制
策略:文档携带 access_level,检索时注入 Qdrant 过滤器
每篇文档在 metadata 里声明自己的访问级别:
python
Document(
page_content="年终奖:S级3个月,A级2个月...",
metadata={"source": "hr-policy", "access_level": "hr_only"},
)
Document(
page_content="机器人控制系统:EtherCAT实时总线,延迟<1ms...",
metadata={"source": "robot-spec", "access_level": "engineering_only"},
)
角色到权限的映射:
python
ROLE_PERMISSIONS: dict[str, list[str]] = {
"admin": ["public", "engineering_only", "hr_only", "finance_only"],
"engineer": ["public", "engineering_only"],
"hr": ["public", "hr_only"],
"finance": ["public", "finance_only"],
"employee": ["public"],
}
检索时把角色允许的 access_level 列表转成 Qdrant 的 MatchAny 过滤器:
python
from qdrant_client.models import Filter, FieldCondition, MatchAny
def get_retriever(tenant_id: str, role: str, k: int = 3):
levels = ROLE_PERMISSIONS.get(role, ["public"])
access_filter = Filter(
must=[
FieldCondition(
key="metadata.access_level",
match=MatchAny(any=levels),
)
]
)
return tenant_stores[tenant_id].as_retriever(
search_kwargs={"k": k, "filter": access_filter}
)
这个过滤器在向量数据库层执行,不是应用层。 这意味着未授权的文档从来不会离开向量库------它们甚至不会被返回给应用,应用无从泄露。
第三层:缓存
策略:(tenant_id, role, question) 作为缓存键,TTL 300 秒
python
@dataclass
class CacheEntry:
answer: str
created_at: float = field(default_factory=time.time)
class QueryCache:
def __init__(self, ttl_seconds: int = 300):
self._store: dict[tuple, CacheEntry] = {}
self._ttl = ttl_seconds
def get(self, tenant_id, role, question) -> Optional[str]:
entry = self._store.get((tenant_id, role, question.strip().lower()))
if entry and (time.time() - entry.created_at) < self._ttl:
return entry.answer
return None
def set(self, tenant_id, role, question, answer) -> None:
self._store[(tenant_id, role, question.strip().lower())] = CacheEntry(answer)
缓存键包含 role 是重要设计------engineer 问同一个问题和 hr 问同一个问题,因为权限不同,能看到的上下文不同,答案也可能不同。不能复用。
第四层:限流
策略:滑动窗口,每用户 5 次/分钟
python
class RateLimiter:
def __init__(self, max_requests: int = 5, window_seconds: int = 60):
self._max = max_requests
self._window = window_seconds
self._log: dict[str, list[float]] = defaultdict(list)
def allow(self, user_id: str) -> bool:
now = time.time()
# 清理窗口外的记录
self._log[user_id] = [t for t in self._log[user_id]
if now - t < self._window]
if len(self._log[user_id]) >= self._max:
return False
self._log[user_id].append(now)
return True
滑动窗口相比固定窗口的优势:避免"窗口边界突刺"------固定窗口下,用户可以在第 59 秒发 5 次、第 61 秒再发 5 次,60 秒内实际发了 10 次。滑动窗口在任意 60 秒区间内都只允许 5 次。
实验结果
场景 A:正常检索
工程师 alice 查询公司信息和工程技术文档:
makefile
Q: ACME Corp 是一家什么类型的公司?
A: ACME Corp 是一家智能制造企业。
Sources: [company-intro, robot-spec] ← 公开文档 + 工程文档,符合预期
elapsed: 995ms
Q: ACME Corp 机器人控制系统用什么通信协议?
A: ACME Corp 机器人控制系统使用的通信协议是EtherCAT实时总线。
Sources: [company-intro, robot-spec] ← 工程技术文档正确命中
elapsed: 1709ms
场景 B:权限过滤生效
这里有一个需要仔细读懂的细节:
less
[B1] Engineer (alice) 问年终奖政策(hr_only 文档):
Sources: [company-intro, robot-spec] ← hr-policy 不在 sources 里
A: 参考资料中没有提供关于ACME Corp年终奖政策的信息。
[B2] HR (bob) 问净利润(finance_only 文档):
Sources: [company-intro, hr-policy] ← finance doc 不在 sources 里
A: 参考资料中没有提供ACME Corp 2025年的净利润信息。
[B3] HR (bob) 问年假政策(hr_only 文档):
Sources: [company-intro, hr-policy] ← hr-policy 正确出现了
A: 入职第一年12天,每满一年增加2天,上限20天...
权限控制的实际表现 :engineer 的 sources 里从来没有出现过 hr-policy,HR 的 sources 里从来没有出现过 financial-report。过滤器在向量数据库层拦截了这些文档,LLM 拿不到它们,自然无法回答相关问题。
这是正确的行为:用户仍然能检索到自己有权访问的文档(公开文档 + 各自角色的专属文档),只是被限制的文档从来不出现。
场景 C:租户隔离
less
[C1] Globex 用户 charlie 问 ACME Corp 的员工数:
Tenant: globex_corp
Sources: [products, company-intro] ← 返回的是 Globex 自己的文档
A: 参考资料中没有提供ACME Corp员工人数的信息。
[C2] Globex 用户查自己的产品线:
Sources: [company-intro, products] ← Globex 自己的文档正确返回
A: GlexCloud、GlexAnalytics、GlexAI...
charlie 在 globex_corp Collection 里查 ACME Corp 的信息,当然找不到------两个 Collection 互不干扰,ACME 的内容物理上就不在 Globex 的向量库里。
场景 D:缓存命中
ini
首次请求(A1):995ms,cache_hit=false
重复相同问题: 0ms, cache_hit=true
0ms 意味着缓存命中时完全跳过了检索和 LLM 调用。对于企业内频繁被重复提问的内容(公司政策、常见流程),缓存能大幅降低延迟和 API 成本。
场景 E:限流生效
yaml
配置:5 req / 60s / user
Request 1: allowed
Request 2: allowed
Request 3: allowed
Request 4: allowed
Request 5: allowed
Request 6: RATE LIMITED ← 触发限流
Request 7: RATE LIMITED
5 次配额用完后,第 6、7 次请求被拒绝,防止单用户耗尽系统资源。
FastAPI 服务化
上述逻辑通过 FastAPI 暴露为 HTTP API:
python
from fastapi import FastAPI, HTTPException, Header
from pydantic import BaseModel
app = FastAPI(title="Enterprise RAG Service")
class QueryRequest(BaseModel):
tenant_id: str
question: str
@app.post("/query")
async def query_endpoint(
req: QueryRequest,
x_user_id: str = Header(...), # 从 Header 获取用户身份
x_user_role: str = Header(...), # 从 Header 获取用户角色
):
result = query(
tenant_id=req.tenant_id,
user_id=x_user_id,
role=x_user_role,
question=req.question,
)
if result.rate_limited:
raise HTTPException(status_code=429, detail="Too many requests")
return {
"answer": result.answer,
"sources": result.sources,
"cache_hit": result.cache_hit,
}
启动:uvicorn enterprise_rag:app --host 0.0.0.0 --port 8080
实际生产中,x_user_id 和 x_user_role 应该来自 JWT token 解码,而不是直接由客户端传入。
生产建议
| 组件 | Demo 实现 | 生产替换方案 |
|---|---|---|
| Qdrant | :memory: |
host="qdrant-server" 独立部署 |
| 缓存 | 进程内 dict | Redis(支持分布式、持久化) |
| 限流 | 进程内计数器 | Redis + 滑动窗口脚本(分布式安全) |
| 用户身份 | Header 参数 | JWT token 解码验证 |
| 日志 | 结构化日志 + 告警(LLM 调用量/延迟/错误率) |
完整代码
代码已开源:
核心文件:
enterprise_rag.py--- 完整实现:多租户 + 权限控制 + 缓存 + 限流 + FastAPI + 场景验证
运行方式:
bash
git clone https://github.com/chendongqi/llm-in-action
cd 20-enterprise-rag
cp .env.example .env
pip install -r requirements.txt
python enterprise_rag.py
小结
本文实现了企业级 RAG 的四层架构,核心发现:
- 多租户用 Collection 隔离:不同客户各自独立的 Qdrant Collection,物理上互不干扰,比 metadata 过滤更安全
- 权限在向量数据库层执行 :Qdrant
MatchAnyfilter 让未授权文档在 DB 层就被拦截,应用层拿不到,无从泄露 - 缓存键要包含 role:同一个问题,不同角色的答案可能不同,缓存键必须包含 role 才能保证缓存正确性
- 限流用滑动窗口:比固定窗口更精确,避免窗口边界突刺问题
- 权限控制的正确理解:用户仍然会检索到他们有权访问的文档,只是无权访问的文档从来不出现在 sources 里
Demo 和生产之间的距离,主要不在算法,而在这些工程细节。