AI 时代的数据架构:RAG 知识库的高可用治理
千万级企业文档的检索治理、混合召回与可观测闭环——从 0 到 1 的 RAG 工程化落地全链路
📋 目录
一、业务全景:企业 RAG 落地的 4 类典型问题
1.1 业务驱动力:从 Demo 到生产的鸿沟
过去 18 个月,我作为架构师主导了集团级 RAG 知识库从 PoC 到全量生产的全过程。最初两个月,我们用 LangChain + Chroma 搭了一个能跑的 Demo,效果惊艳;但当我们把 3 个业务线、27 个知识源、1800 万份文档接入后,整个系统几乎崩溃。
问题不在算法,而在工程化治理。企业场景下 RAG 不是"接个 LLM + 做个向量检索"那么简单,它是一个长期运行的复杂数据系统——有数据接入、有索引更新、有检索路由、有质量反馈、有权限边界、有降级兜底。任何一个环节失控,都会让 LLM 从"智能助手"退化成"幻觉生成器"。
1.2 4 类典型生产问题
在和 6 个业务方共 4 轮深度访谈后,我把企业 RAG 落地中最棘手的问题归纳为四类。这些问题在 Demo 阶段几乎不会出现,但一旦进入生产就会集中爆发:
| 问题类别 | 典型表现 | 业务影响 | 治理难度 |
|---|---|---|---|
| 数据源混乱 | MySQL 业务表、Confluence 文档、S3 PDF、企微聊天记录共存,更新频率从秒级到季度级 | 同一问题多个版本答案,互相矛盾 | ★★★★★ |
| 检索不准 | 纯向量检索对专有名词、缩写、错误检索鲁棒性差;BM25 对长尾语义召回不足 | Top-5 命中率 60%,业务方不信任 | ★★★★ |
| 响应不可控 | 千万级文档下,P99 延迟从 800ms 恶化到 6s,节假日高峰期雪崩 | 用户放弃使用,沦为内部玩具 | ★★★★★ |
| 幻觉与过期 | LLM 基于旧版本文档给出过期答案;面对未覆盖问题时自由发挥 | 合规风险,财务、法务场景不可接受 | ★★★★★ |
1.3 架构治理总览
针对上述四类问题,我们设计了一套分层治理架构。核心思路是:把 RAG 当成一个数据系统来设计,而不是"LLM + 检索"的简单拼装。
MySQL/TiDB"] S2["文档类
Confluence/语雀"] S3["时序/日志
Kafka"] S4["消息/IM
企微SDK"] end subgraph ETL[接入治理层 · ETL Pipeline] E1["解析
SmartDocParser"] E2["清洗
去噪/归一化"] E3["元数据注入
血缘+权限标签"] end subgraph IDX[索引层 · 多粒度索引] I1["倒排索引
ES"] I2["向量索引
Milvus"] I3["摘要索引
TiDB"] I4["元数据KV
权限/版本"] end subgraph RET[检索层 · 混合召回] R1["Query 改写"] R2["意图路由"] R3["BM25 + 向量 + Rerank"] R4["Cache"] end subgraph ORCH[编排层 · LLM Orchestration] O1["Prompt 模板"] O2["多模型协同
Cascade"] O3["SSE 流式"] O4["工具调用/敏感过滤"] end subgraph FB[反馈层 · 质量闭环] F1["用户反馈"] F2["标注平台/评测集"] F3["版本管理/知识更新"] end S1 & S2 & S3 & S4 --> E1 --> E2 --> E3 E3 --> I1 & I2 & I3 & I4 I1 & I2 & I3 & I4 --> R1 --> R2 --> R3 R3 -.命中缓存.-> R4 R3 --> O1 --> O2 --> O3 --> O4 O4 --> F1 --> F2 --> F3 F3 -.回流.-> E1 style S1 fill:#1a1a2e,stroke:#00d4ff,stroke-width:2px style S2 fill:#1a1a2e,stroke:#00d4ff,stroke-width:2px style S3 fill:#1a1a2e,stroke:#00d4ff,stroke-width:2px style S4 fill:#1a1a2e,stroke:#00d4ff,stroke-width:2px style E1 fill:#16213e,stroke:#7b61ff style E2 fill:#16213e,stroke:#7b61ff style E3 fill:#16213e,stroke:#7b61ff style I1 fill:#0a0e27,stroke:#00d4ff,stroke-width:2px style I2 fill:#0a0e27,stroke:#00d4ff,stroke-width:2px style R3 fill:#0a0e27,stroke:#00d4ff,stroke-width:2px style O2 fill:#16213e,stroke:#7b61ff style F2 fill:#16213e,stroke:#7b61ff
1.4 关键约束:成本与 SLA
在动手之前,必须先明确两个硬约束,否则架构会被业务方反复推翻:
// 硬约束:SLA 与成本基线
const sla = {
p99_latency_ms: 2000, // 端到端 P99 < 2s(包含 LLM 推理)
p95_latency_ms: 1500, // P95 < 1.5s
availability: 0.999, // 99.9% 月度可用
recall_at_10: 0.92, // 检索召回率 @Top-10 >= 92%
answer_accuracy: 0.88, // 端到端答案准确率 >= 88%
hallucination: 0.03, // 幻觉率 < 3%
};
const cost = {
// 单次对话成本上限(元)
per_query_yuan: 0.15,
// 月度预算上限
monthly_budget: 80000,
// 知识库规模
corpus_size: '1800万 chunks',
// 业务方数量
tenants: 6,
};
💡 架构深挖点
- 为什么 RAG 不能直接套用传统数据仓库的 SLA 模型?答案藏在 LLM 的"非确定性"里——同样的输入可能得到不同质量的输出,这对 SLA 设计意味着什么?
- 业务方提出"答案必须 100% 准确"时,架构师该如何用分层置信度替代二元答案?这背后是产品形态的重构。
- 当知识源多达 27 个时,统一元数据治理的最小公共模型应该长什么样?过度抽象与过细粒度的边界在哪里?
二、知识接入:多源异构数据的统一治理
2.1 业务背景:27 个数据源的真实样貌
企业知识治理的第一战不是"接 LLM",而是"接数据"。我们面对的 27 个数据源可以分为四类,每一类的更新频率、信任级别、权限边界都不同:
- 结构化数据:MySQL 业务表(产品库、价格库、用户库)、TiDB(订单)、PolarDB(财务)
- 文档类:Confluence(产品手册)、SharePoint(内部规范)、语雀(团队 Wiki)
- 对象存储:S3/COS 上的 PDF/Word/Excel/PPT(合同、报告、白皮书)
- 消息流:企微会话记录、工单系统、邮件归档
如果直接用 LangChain 的 27 个 Loader 各接各的,三个月后没人能回答"这条知识来自哪里、什么时候更新过、谁有权访问"。所以第一步必须建立统一知识元模型。
2.2 统一知识元模型 (Unified Knowledge Schema)
我们对所有数据源做了一次抽象,提取出六元组最小公共模型。任何数据源接入时都必须映射到这个模型:
// 统一知识单元 (KnowledgeUnit) - 跨数据源的最小公共表示
type KnowledgeUnit = {
unit_id: string; // 全局唯一 ID (ULID)
tenant_id: string; // 多租户隔离
source_type: 'db' | 'doc' | 'file' | 'im';
source_id: string; // 原始数据源 ID
content_hash: string; // 内容指纹 (sha256)
content: string; // 归一化后的纯文本
metadata: {
title: string;
author: string[];
created_at: ISODate;
updated_at: ISODate;
language: 'zh' | 'en' | 'mixed';
domain: string[]; // 业务域标签: ['finance', 'legal', ...]
sensitivity: 0 | 1 | 2 | 3; // 敏感度 0=公开 3=机密
expire_at?: ISODate; // 过期时间(合同、价格表)
};
lineage: {
upstream_ids: string[]; // 上游知识单元(用于溯源)
pipeline_ver: string; // 处理 pipeline 版本
sync_strategy: 'realtime' | 'cdc' | 'batch' | 'manual';
};
acl: {
read_roles: string[]; // 角色级 RBAC
field_mask: Record<string, 'hidden' | 'masked' | 'plain'>;
};
};
2.3 接入策略:四种同步模式
不同数据源的更新频率差异巨大(毫秒级 CDC vs 季度级合同),我们设计了四种同步模式,关键决策是不强求"实时":
| 同步模式 | 适用场景 | 延迟 | 实现成本 | 代表数据源 |
|---|---|---|---|---|
| CDC (Change Data Capture) | 高频变更业务表 | < 5s | 高(需部署 Debezium) | MySQL 产品表、订单表 |
| Webhook / 事件驱动 | 支持回调的 SaaS | 10s - 1min | 中 | Confluence、语雀、企微 |
| 定时批处理 | 全量或大批量文件 | 1h - 24h | 低(Airflow 调度) | S3 PDF 库、SharePoint |
| 手动触发 | 低频且合规要求高 | 人工 | 极低 | 合同模板、法务文档 |
2.4 文档解析:被低估的脏活
真实场景下,50% 的"检索不准"问题其实源于解析阶段。我们踩过最深的坑是:把 PDF 直接喂给 PyPDFLoader,结果表格变成乱码、目录页被当成正文、页眉页脚污染向量。一个 100 页的财报,最终进入向量库的"有效 chunk"只有 60%。
下图展示了四种同步模式如何统一接入 ETL Pipeline,核心是异步、解耦、可观测:
业务库)] DC[(Confluence/语雀
Wiki)] S3[(S3/COS
PDF/Word)] IM[企微IM/工单] end subgraph SYNC[同步模式] SY1["CDC
<5s"] SY2["Webhook
10s-1min"] SY3["定时批
1-24h"] SY4["手动触发
人工"] end subgraph ETL[ETL Pipeline] E1[解析
SmartDocParser] E2[清洗
去噪/归一化] E3[Chunking
语义切分] E4[Embedding
向量化] end subgraph IDX[索引] IDX1[(Milvus
向量)] IDX2[(ES
倒排)] IDX3[(TiDB
元数据)] end DB --> SY1 DC --> SY2 S3 --> SY3 IM --> SY2 SY1 & SY2 & SY3 & SY4 --> E1 --> E2 --> E3 --> E4 E4 --> IDX1 & IDX2 & IDX3 style SY1 fill:#0a0e27,stroke:#00d4ff,stroke-width:2px style E3 fill:#16213e,stroke:#7b61ff style E4 fill:#0a0e27,stroke:#00d4ff,stroke-width:2px style IDX1 fill:#16213e,stroke:#7b61ff style IDX2 fill:#16213e,stroke:#7b61ff
后来我们自研了 SmartDocParser,核心思路是版面分析 + 结构化重建:
// SmartDocParser:基于版面分析的文档解析
class SmartDocParser {
async parse(file: File, docType: 'pdf' | 'docx' | 'html'): Promise<Document> {
// 1. 版面分析 (Layout Analysis)
// 使用 LayoutLMv3 识别页眉/页脚/目录/正文/表格/图片
const layout = await this.layoutModel.predict(file);
// 2. 噪声剔除
const cleanedPages = layout.pages.map(p => ({
...p,
blocks: p.blocks.filter(b =>
!b.isHeader && !b.isFooter && !b.isPageNum
),
}));
// 3. 表格重建(关键:避免表格被切碎成"行")
const tables = await this.tableExtractor.extract(cleanedPages);
// 表格转 Markdown 保留列对齐语义
// 4. 图片 OCR + 文字描述
// 使用 Qwen-VL 生成图片文字描述,一并进入向量
const imageCaptions = await this.vlm.caption(layout.images);
// 5. 重建结构化文档
return {
title: layout.title,
sections: this.buildSectionTree(cleanedPages, tables, imageCaptions),
rawHash: sha256(file),
};
}
}
💡 架构深挖点
- 多源接入时,字段命名冲突如何收敛?比如不同业务方都定义了"客户"字段,但口径完全不同——是用全局字典还是保留源系统命名?
- 当 S3 上的 PDF 突然被业务方修改了文件名但内容没变时,如何避免重复入库?内容指纹 (content_hash) 是答案,但向量库去重还有哪些边界情况?
- 敏感度分级 (sensitivity) 应该由数据源决定还是由字段内容决定?当一张表同时包含公开和机密字段时,权限如何下钻到字段级?
三、Chunking 策略:语义切分与多粒度索引
3.1 为什么 Chunking 决定了 RAG 的天花板
Chunking 是 RAG 中"看似简单、实则最影响召回质量"的环节。我们的 A/B 测试显示:
- 从 256 token 切到 512 token:Top-5 命中率从 71% 提升到 84%,但单 chunk 噪声变大
- 从固定切分改成语义切分:长文档召回率再提升 6%,但索引时间增加 3 倍
- 加入父-子 chunk 多粒度索引:P99 延迟几乎不变,但回答上下文丰富度显著提升
结论是:Chunking 不是一刀切,而是文档类型驱动的策略组合。
3.2 切分策略对比
| 切分策略 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 固定长度切分 | 按 N token 硬切,重叠 K token | 实现简单,速度快 | 切断句子/段落,语义不完整 | 纯日志、代码片段 |
| 按段落/标题切分 | 利用 Markdown 标题、段落边界 | 保留语义完整性 | 依赖文档结构化程度 | Confluence、博客、产品手册 |
| 语义切分 (Semantic Splitting) | Embedding 相似度突变处切分 | 自适应语义边界 | 计算量大,索引慢 3-5x | 长报告、合同、白皮书 |
| 滑动窗口 + 父-子索引 | 父 chunk 提供上下文,子 chunk 用于精确召回 | 兼顾精度与上下文 | 存储翻倍 | 高价值文档(财报、法务) |
3.3 我们的实现:四级切分器
针对不同文档类型,我们实现了 4 个切分器,由 router 根据文档特征自动选择:
// 切分器路由:按文档特征选择策略
class ChunkingRouter {
pickStrategy(doc: ParsedDocument): Chunker {
if (doc.isStructured && doc.tables.length > 0) {
return new TableAwareChunker({
// 表格整行保留,避免被切断
preserveTable: true,
chunkSize: 512,
overlap: 64,
});
}
if (doc.language === 'code' || doc.isCodeHeavy) {
return new ASTChunker({
// 按函数/类切分,保留 import 上下文
granularity: 'function',
includeContext: ['imports', 'docstring'],
});
}
if (doc.totalTokens > 8000) {
// 长文档走语义切分
return new SemanticChunker({
embeddingModel: 'bge-large-zh',
similarityThreshold: 0.72,
minChunk: 256,
maxChunk: 768,
// 关键:父-子双粒度
parentChild: {
parentSize: 2048,
childSize: 256,
overlap: 32,
},
});
}
return new RecursiveChunker({
// 短文档按段落/句子递归切分
separators: ['\n##', '\n###', '\n\n', '。', '. ', ' '],
chunkSize: 384,
overlap: 48,
});
}
}
3.4 父-子索引:小 chunk 召回 + 大 chunk 喂给 LLM
这是 RAG 实践中最重要的"一招鲜"。核心矛盾是:小 chunk 检索精度高,但上下文不够;大 chunk 上下文全,但检索不准。父-子索引的解法是:
// 父-子双粒度索引:检索用子,喂给 LLM 用父
interface IndexResult {
// 检索时用:子 chunk(小、精确)
childChunks: {
chunkId: string;
text: string;
parentId: string; // 指向父 chunk
embedding: number[]; // 用于向量检索
}[];
// 命中后用:父 chunk(大、上下文全)
parentChunks: Map<string, {
parentId: string;
text: string; // 完整段落/章节
childrenIds: string[];
}>;
}
// 检索流程
async function hybridRetrieve(query: string, topK = 10) {
// 1. 用子 chunk 做混合检索
const childHits = await this.retrieveChildren(query, topK);
// 2. 去重:取子 chunk 的 unique parent
const parentIds = [...new Set(childHits.map(h => h.parentId))];
// 3. 取出父 chunk 全文
const parents = await this.getParents(parentIds);
// 4. 父 chunk 喂给 LLM
return parents.map(p => ({
content: p.text,
source: p.childrenIds,
score: this.aggregateScore(p.childrenIds, childHits),
}));
}
3.5 Chunking 质量评估
没有评估就没有优化。我们用三组指标监控 chunking 质量:
// Chunking 质量评估 - 离线评测集
class ChunkingEvaluator {
metrics = {
// 1. 边界准确率:人工标注的"该切分点"是否被正确识别
boundaryF1: 0.0,
// 2. 检索召回率:在该 chunking 方案下,Top-10 命中标注答案的比例
recallAtK: { k10: 0.0, k20: 0.0, k50: 0.0 },
// 3. 上下文完整度:被命中的 chunk 是否能独立回答问题(GPT-4 评分)
contextCompleteness: 0.0,
};
async run(testSet: QAPair[]) {
// 用 500 条人工标注的 QA 对做评测
// 每周回归,发现 chunking 策略退化
}
}
💡 架构深挖点
- 当文档同时包含中文、英文、代码、公式时,统一用一种 embedding 模型是否合理?多语言模型 vs 分语种专门模型,成本-精度曲线如何?
- Chunking 之后,原文表格中的数字列在语义切分时可能被切到不同 chunk,导致 LLM 算错。结构化数据的 chunk 切分该用怎样的策略?
- 父-子索引让存储翻倍,但实际查询中可能 80% 的子 chunk 永远不会被命中。冷热分层在这里是否适用?
四、混合检索:BM25 + 向量 + 重排序的召回-精度平衡
4.1 纯向量检索的三大失败模式
我们做过严格的对比实验:纯向量 vs BM25 vs 混合,在 1800 万 chunk 的真实语料上:
- 专有名词/缩写失败:用户搜"K8s 故障排查",向量模型不认识 K8s (Kubernetes),但 BM25 完全命中
- 低频长尾词失败:内部产品代号"XM-9",在训练语料中几乎不出现,embedding 几乎随机
- 精确匹配失败:用户问"价格 999 是不是真的",向量给了语义相近的"价格优惠",但没给"999 元"原话
纯向量不是银弹,必须叠加稀疏检索。这就是 hybrid search 的工程价值。
4.2 混合检索架构
改写/扩展/HyDE] QR --> P[并行召回] P --> BM[BM25 召回
ES · Top 50] P --> VS[向量召回
Milvus · Top 50] P --> ST[结构化召回
元数据过滤] BM --> RRF{RRF 融合
k=60} VS --> RRF ST --> RRF RRF --> RE[Cross-Encoder
Rerank] RE --> TOP[Top-10 排序] TOP --> LLM[送入 LLM 生成] style QR fill:#0a0e27,stroke:#00d4ff,stroke-width:2px style BM fill:#16213e,stroke:#7b61ff style VS fill:#16213e,stroke:#7b61ff style RRF fill:#0a0e27,stroke:#00d4ff,stroke-width:2px style RE fill:#0a0e27,stroke:#00d4ff,stroke-width:2px style LLM fill:#16213e,stroke:#7b61ff
4.3 RRF 融合:为什么不用加权打分
把 BM25 和向量打分"加权求和"看似合理,实际上很糟糕——两种打分的分布完全不同(BM25 0-30,向量 0-1),权重调参是噩梦。
我们用 Reciprocal Rank Fusion (RRF)——只看排名不看分数,鲁棒得多:
// RRF 融合:对每个 chunk 的多个来源排名做倒数加权
function rrfFusion(
bm25Hits: SearchHit[],
vectorHits: SearchHit[],
k = 60 // 关键超参数
): SearchHit[] {
const scores = new Map<string, number>();
// BM25 排名贡献
bm25Hits.forEach((hit, rank) => {
scores.set(hit.id, (scores.get(hit.id) || 0) + 1 / (k + rank + 1));
});
// 向量排名贡献
vectorHits.forEach((hit, rank) => {
scores.set(hit.id, (scores.get(hit.id) || 0) + 1 / (k + rank + 1));
});
return [...scores.entries()]
.sort(([, a], [, b]) => b - a)
.map(([id, score]) => ({ id, score }));
}
// 关键技巧:归一化前先按 source 过滤
// 比如"内部技术文档"类问题,应该让 BM25 权重更高
function adaptiveRRF(query, bm25Hits, vectorHits, ctx) {
if (ctx.tenantId === 'tech-docs') {
return rrfFusion(bm25Hits, vectorHits, k = 30); // BM25 更敏感
}
return rrfFusion(bm25Hits, vectorHits, k = 60);
}
4.4 重排序 (Rerank) 的杠杆效应
Cross-Encoder Rerank 是 RAG 系统的"投入产出比之王"。我们用 bge-reranker-large 做精排,效果惊人:
| 阶段 | 方法 | NDCG@10 | 延迟 (P50) | 成本 |
|---|---|---|---|---|
| 召回 | 纯 BM25 | 0.61 | 80ms | 低 |
| 召回 | 纯向量 | 0.68 | 120ms | 中 |
| 召回 | 混合 (RRF) | 0.76 | 180ms | 中 |
| 精排 | + bge-reranker | 0.89 | +90ms | 中 |
| 精排 | + 多路召回去重 | 0.90 | +30ms | 低 |
用一个 90ms 的 rerank,把 NDCG@10 从 0.76 拉到了 0.89。这是"小改动、大杠杆"的典型。
4.5 Query 改写:被忽视的隐性收益
用户真实 query 往往是残缺的——缺主语、省略上下文、用代词。我们用 LLM 做 query 改写,效果稳定提升 5-8%:
// Query 改写:3 种策略组合
class QueryRewriter {
async rewrite(rawQuery: string, ctx: ConversationContext) {
// 1. 指代消解
// "这个怎么用?" → "RAG 知识库这个产品怎么用?"
const resolved = await this.resolveCoreference(rawQuery, ctx.history);
// 2. HyDE (Hypothetical Document Embeddings)
// 先用 LLM 生成"假想答案",再用答案 embedding 去检索
// 对没有匹配文档的新查询特别有效
const hyde = await this.hyde(resolved);
// 3. 多 Query 扩展
// "RAG 检索" → ["RAG 检索原理", "RAG 检索优化", "混合检索"]
const expanded = await this.expandQueries(resolved, n = 3);
return { original: rawQuery, resolved, hyde, expanded };
}
}
💡 架构深挖点
- 当 query 涉及多意图("对比 A 和 B 的区别")时,混合检索的 Top-K 应该返回并集还是分意图?这关系到后续 LLM 怎么组织答案。
- RRF 的 k 参数(通常 60)在长尾 query 上是否应该动态调整?业务方反馈"冷门问题召回差",是不是 k 太小导致 BM25 排名靠后的文档被截断?
- Cross-Encoder Rerank 延迟 90ms,能否用更小的蒸馏模型在 20ms 内逼近相同效果?TPP (Time-Performance Product) 优化的边界在哪里?
五、LLM 编排:意图路由与多模型协同
5.1 编排层的真实职责
很多人把 LLM 编排理解成"调个 OpenAI API",但企业级编排远不止于此。它是整个 RAG 系统的"大脑皮层",承担五大职责:
① 上下文管理:把检索回来的文档、用户历史对话、系统 Prompt 拼装成 LLM 能理解的输入。这需要token 预算控制、文档排序、冲突检测等细节处理。
② 路由决策:根据 query 类型选择不同的处理路径——闲聊走轻量模型,深度分析走 GPT-4,多模态调用视觉模型。
③ 重试与降级:LLM 调用失败(限流/超时/内容审查)时,自动降级到备用模型或缓存答案。
④ 工具调用:让 LLM 调用外部 API(搜索/计算/数据库查询),实现 Agent 能力。
⑤ 流式输出与可观测:SSE/WebSocket 流式推送给前端,同时记录完整链路用于问题排查。
5.2 意图路由设计:从关键词到语义理解
意图路由是编排层的核心决策点。早期我们用关键词匹配("价格"→查产品库),但这种方式维护成本高,扩展性差。后来演进到小模型分类(BERT 分类器),现在主流方案是LLM 自路由:
5min 内} CACHE -->|是| RT1[复用决策
直接返回] CACHE -->|否| LLM[LLM 自路由
gpt-4o-mini · temperature=0] LLM --> CONF{confidence
>= 0.7?} CONF -->|是| ROUTE{意图分类} CONF -->|否| FALLBACK[降级到 RAG
最常见路径] ROUTE -->|chat| P1[闲聊/通用
轻量模型] ROUTE -->|rag| P2[RAG 检索
混合召回] ROUTE -->|tool| P3[工具调用
Agent 链路] ROUTE -->|code| P4[代码生成
专用模型] P1 & P2 & P3 & P4 --> SAVE[写入 LRU Cache] style LLM fill:#0a0e27,stroke:#00d4ff,stroke-width:2px style ROUTE fill:#0a0e27,stroke:#00d4ff,stroke-width:2px style P2 fill:#16213e,stroke:#7b61ff style FALLBACK fill:#16213e,stroke:#7b61ff style CONF fill:#0a0e27,stroke:#00d4ff,stroke-width:2px
// 意图路由引擎 - 实际生产代码示例
class IntentRouter {
private llm: LLMClient;
private cache: LRUCache<string, RouteDecision>;
async route(query: string, ctx: Context): Promise<RouteDecision> {
// 1. 缓存优先:相同 query 5 分钟内复用
const cached = this.cache.get(query);
if (cached && Date.now() - cached.ts < 300_000) return cached.decision;
// 2. LLM 路由:让 LLM 自己判断走哪条路径
const prompt = `你是意图分类器。根据用户 query 决定走哪条处理路径。
- "chat": 闲聊/问候/通用知识
- "rag": 需要查知识库的专业问题
- "tool": 需要调用工具(计算/搜索/数据库)
- "code": 代码相关(生成/调试/解释)
用户 query: ${query}
对话上下文: ${JSON.stringify(ctx.history.slice(-3))}
返回 JSON: {"intent": "chat|rag|tool|code", "confidence": 0-1, "subIntent": "可选"}`;
const decision = await this.llm.json<RouteDecision>(prompt, {
model: 'gpt-4o-mini', // 路由用小模型,省钱
temperature: 0, // 路由要确定性
timeout: 2000, // 路由 2s 超时,宁可降级到默认
});
// 3. 低置信度降级:LLM 说不准时,默认走 RAG(最常见路径)
if (decision.confidence < 0.7) {
decision.intent = 'rag';
decision.fallback = true;
}
this.cache.set(query, { decision, ts: Date.now() });
return decision;
}
}
5.3 多模型协同与成本优化
企业 RAG 通常需要多模型协同:路由用小模型(省钱)、生成用大模型(保质量)、特定任务用专业模型(保精度)。这是经典的cascade 架构——从便宜到贵逐级尝试。
| 任务 | 模型选择 | 理由 | 单次成本 |
|---|---|---|---|
| Query 路由 | GPT-4o-mini / Qwen2.5-7B | 简单分类,小模型足够 | $0.0001 |
| Query 改写 | GPT-4o-mini / Claude Haiku | 需要 NLU 但要求不高 | $0.0003 |
| Rerank 评分 | Cohere Rerank-v3 / BGE-Reranker | 专用模型,效果远好于 LLM | $0.001 |
| 答案生成 | GPT-4o / Claude-3.5-Sonnet / Qwen2.5-72B | 主战场,质量优先 | $0.01-0.05 |
| 答案验证 | GPT-4o-mini | 简单 yes/no 校验 | $0.0002 |
成本控制三板斧:① 缓存(query 相似度 > 0.95 直接复用);② 降级(高峰期用小模型);③ 限额(每用户每天 50 次免费,超出付费)。
💡 架构深挖点
- 意图路由用 LLM 推理,延迟又增加 200-500ms。能否用向量检索 + 轻量分类(如 Embedding 相似度)替代 LLM 路由,把延迟压到 50ms 以内?
- 多模型协同时,模型升级/降级(如 GPT-4 → GPT-4o)的灰度策略怎么做?直接全量切换风险大,按用户分组灰度又如何保证体验一致?
- 当 LLM 输出触犯内容审查被拒答时,兜底回答应该返回什么?纯技术问题给"我不知道"可以,但业务咨询类问题"不知道"会激怒用户。是否需要预置"安全答案模板"?
六、质量闭环:用户反馈驱动的持续迭代
6.1 反馈采集的 4 个触点
RAG 系统上线后,没有反馈循环 = 没有优化路径。我们设计了 4 个反馈采集触点:
- 显式反馈:👍/👎 按钮、5 星评分、文字评论
- 隐式反馈:用户停留时长、复制答案行为、追问次数("展开说说"= 满意;"不是这个"= 不满意)
- 转人工:用户主动转人工 = 明确表达"AI 没解决我的问题"
- 答案采纳:用户复制了答案 / 把答案分享出去 = 答案有价值
这 4 类反馈汇总后,每周产出 Bad Case 报告,标注"应该答对但答错了"的高频问题,作为优化重点。
6.2 标注与再训练:从 Bad Case 到模型迭代
收集到 Bad Case 后,标准流程是:分类 → 归因 → 优化。
// Bad Case 归因分类(自动 + 人工结合)
const caseAnalyzer = {
// 检索问题:相关文档没召回到
retrieval: async (case) => {
const docs = await retrieve(case.query, { topK: 50 });
const target = case.groundTruth;
const recallAtK = computeRecall(docs, target);
return recallAtK < 0.8 ? '检索召回不足' : null;
},
// 排序问题:召回了但排名靠后
ranking: async (case) => {
const docs = await retrieve(case.query, { topK: 10 });
const rank = docs.findIndex(d => d.id === case.groundTruth);
return rank >= 5 ? '排序靠后' : null;
},
// 生成问题:检索对了但 LLM 没用到
generation: async (case) => {
const docs = await retrieve(case.query, { topK: 5 });
const hit = docs.some(d => d.id === case.groundTruth);
if (hit) return 'LLM 未有效利用检索内容';
return null;
},
// 数据问题:知识库本身缺内容
coverage: async (case) => {
const anyHit = await fullCorpusSearch(case.query);
return !anyHit ? '知识库缺内容' : null;
}
};
归因后,70% 的 Bad Case 集中在检索和排序——这意味着优化 embedding 模型和 Rerank 模型的性价比,远高于换更强的 LLM。
6.3 评估指标体系:让优化可量化
| 维度 | 指标 | 计算方法 | 目标值 |
|---|---|---|---|
| 检索质量 | Recall@10 | Top-10 召回正确文档比例 | > 0.85 |
| 检索质量 | MRR | 正确文档排名倒数均值 | > 0.75 |
| 答案质量 | Faithfulness | 答案忠实于检索内容的比例 | > 0.92 |
| 答案质量 | Helpfulness | 人工评分 ≥ 4 分(5 分制)的比例 | > 0.80 |
| 用户体验 | CSAT | 用户满意度(👍 比例) | > 0.75 |
| 用户体验 | 转人工率 | 对话转人工的占比 | < 0.20 |
💡 架构深挖点
- 用户反馈容易被"沉默大多数"稀释(只有 5% 用户会点 👍/👎)。能否用行为信号(停留时长、追问模式)作为隐式反馈的代理?
- Bad Case 标注依赖人工,成本高。能否用LLM-as-a-Judge(让 GPT-4 自动评分)替代部分人工标注?
- 指标体系中 Faithfulness(忠实度) 是 RAG 系统的生命线——如果答案偏离了检索内容,就变成了"幻觉"。但忠实度过高(>0.95)又会让答案"死板"。合理的平衡点在哪?
七、权限治理:多租户 + 字段级 + 成本归因
7.1 多租户隔离的三种范式
企业 RAG 通常服务多个业务方(HR/法务/产品/客服),不同业务方的数据必须严格隔离。我们对比三种隔离方案:
| 方案 | 隔离强度 | 成本 | 适用场景 |
|---|---|---|---|
| 独立向量库 | 物理隔离 | 高 | 金融/医疗等强合规 |
| 共享库 + namespace | 逻辑隔离 | 中 | 企业多部门 |
| 共享库 + metadata 过滤 | 查询时过滤 | 低 | 内部知识库 |
选型建议:合规要求强 → 独立库;业务方多但无强合规 → namespace;内部全员共享 → metadata 过滤。混合使用更常见(如:HR 数据用独立库,通用知识用 metadata 过滤)。
7.2 字段级访问控制:细粒度的安全护栏
有些文档需要字段级控制——HR 知识库包含员工工资信息,不是所有 HR 都能看。我们设计了"四层过滤链":
// 检索后的四层过滤链(按顺序执行)
const filterChain = [
// 1. 租户隔离:跨租户的文档直接过滤
doc => doc.tenantId === ctx.tenantId,
// 2. 部门隔离:同租户但跨部门
doc => doc.departments.includes(ctx.userDept) || doc.departments.length === 0,
// 3. 角色隔离:高管文档只对管理层开放
doc => !doc.requiresRole || ctx.userRoles.includes(doc.requiresRole),
// 4. 字段脱敏:工资/手机号等敏感字段按角色脱敏
doc => maskSensitiveFields(doc, ctx.userRoles),
];
const filteredDocs = retrievedDocs.filter(d => filterChain.every(fn => fn(d)));
关键点:过滤在检索后但 LLM 推理前执行。LLM 永远不应该"看到"无权访问的内容,否则一旦 prompt injection,用户能套出隔离信息。
7.3 成本归因:让每个业务方看到自己的账单
RAG 系统最大的隐性成本是LLM API 调用费。如果不归因,财务部门不知道谁在烧钱,预算也无从管控。我们的方案:
- 计量埋点:每次 LLM 调用记录 (tenantId, userId, model, promptTokens, completionTokens, latency)
- 单价配置:按模型维护单价表(GPT-4o: $5/1M input tokens)
- 月度账单:按 tenant 聚合,生成 Top10 用户/部门报表
- 预算熔断:单租户月度预算达到 80%/100% 自动告警/限流
💡 架构深挖点
- 字段级控制如果做到行列级(同一份合同文档,A 角色看金额、B 角色看条款),是预处理脱敏还是检索后过滤?预处理会破坏 embedding 语义,检索后过滤又可能漏掉关键信息。
- 成本归因如果按业务部门分摊,如何避免"冤大头"问题(研发测试的 query 也算到生产部门头上)?
- 多租户共享向量库时,embedding 模型升级需要全量重建索引(一次 100 万文档 × 几小时),期间新旧库怎么平滑切换?双写 + 异步 reindex 怎么保证一致性?
八、高可用设计:检索降级与兜底回答
8.1 检索降级链:从最优到兜底
RAG 系统的可用性挑战在于依赖链长:向量数据库、Embedding 服务、LLM API、Prometheus,任何一环抖动都会影响最终用户。我们设计了四级降级链:
5min TTL} L1 -->|命中| OK1([直接返回
~10ms]) L1 -->|未命中| L2{L2 混合检索
向量+BM25+Rerank} L2 -->|成功| OK2([返回结果
~200ms]) L2 -->|异常/超时| L3{L3 纯向量检索
跳过 Rerank} L3 -->|成功| OK3([降级返回
~80ms]) L3 -->|异常/超时| L4{L4 关键词检索
保底} L4 -->|保底| OK4([最差兜底
~30ms]) L4 -->|失败| ERR([系统繁忙
转人工]) L1 -. 写回 .-> L1 L2 -. 失败告警 .-> LOG1[Prometheus] L3 -. 失败告警 .-> LOG2[Prometheus] L4 -. 失败告警 .-> LOG3[Prometheus] style L1 fill:#0a0e27,stroke:#00d4ff,stroke-width:2px style L2 fill:#0a0e27,stroke:#00d4ff,stroke-width:2px style L3 fill:#16213e,stroke:#7b61ff style L4 fill:#16213e,stroke:#7b61ff style OK1 fill:#1a4d2e,stroke:#00ff88 style OK2 fill:#1a4d2e,stroke:#00ff88 style ERR fill:#4d1a1a,stroke:#ff4444
// 检索降级链(生产级实现)
class RetrievalDegradation {
async retrieve(query: string, ctx: Context): Promise<Doc[]> {
// L1: 语义缓存(5 分钟内相同 query 直接返回)
const cached = await this.semanticCache.get(query);
if (cached && Date.now() - cached.ts < 300_000) return cached.docs;
try {
// L2: 混合检索(向量 + BM25 + Rerank)
return await this.hybridRetrieve(query, ctx);
} catch (e) {
logger.warn('L2 检索失败,降级到 L3', e);
try {
// L3: 纯向量检索(跳过 Rerank)
return await this.vectorRetrieve(query, ctx);
} catch (e2) {
logger.warn('L3 检索失败,降级到 L4', e2);
// L4: 关键词检索(保底,不依赖向量服务)
return await this.keywordRetrieve(query, ctx);
}
}
}
}
关键设计:每一级降级都必须有"足够好"的兜底,而不是"破罐子破摔"。L4 关键词检索虽然效果差,但比直接返回"系统繁忙"好太多——用户至少能看到一些相关结果。
8.2 兜底回答机制:让系统永不"哑火"
当 LLM API 不可用时(限流/超时/服务降级),我们需要预先准备的兜底回答:
- FAQ 兜底:高频问题预生成静态答案,存 Redis。LLM 挂了直接从 FAQ 取
- 知识卡片兜底:当检索到相关文档但 LLM 不可用,直接返回文档摘要+原文链接
- 智能转人工:当所有兜底都失败,提示"系统繁忙,请稍后重试或转人工"
8.3 多级缓存策略
缓存是 RAG 系统降本增效的第一性原理。我们设计了 4 层缓存:
| 缓存层 | 内容 | TTL | 命中率 |
|---|---|---|---|
| L1 语义缓存 | query 向量 → 答案 | 5 分钟 | 15-25% |
| L2 检索缓存 | query → 文档列表 | 1 小时 | 30-40% |
| L3 Embedding 缓存 | 文本 → 向量 | 永久 | 60%+ |
| L4 LLM 响应缓存 | 完整 prompt → 答案 | 1 天 | 10-15% |
实测下来,4 层缓存叠加命中率超过 70%,每月节省 LLM 成本约 40%。
💡 架构深挖点
- 语义缓存(Semantic Cache)阈值怎么定?阈值 0.95 太严,命中率低;阈值 0.85 太松,缓存了错误答案。怎么根据业务场景动态调?
- 多级缓存的一致性:知识库新增文档时,如何让缓存失效(invalidation)?全量失效太粗暴,按文档 ID 失效又可能漏掉"语义相关但 ID 不同"的文档。
- 当 LLM 服务降级时,用户体验如何保持稳定?直接返回兜底答案用户会察觉到"系统变笨了",能否让降级过程对用户透明?
九、性能压测:千万级文档 P99<200ms 的路径
9.1 压测方法论:从基准到极限
RAG 性能压测比传统微服务复杂得多——除了 QPS/RT/错误率,还要关注检索质量 vs 速度的 trade-off。我们的压测分三阶段:
- 基准压测:用真实 query 分布测 P50/P95/P99 延迟
- 容量压测:逐步加压到系统临界点,定位瓶颈
- 极限压测:超过设计容量,验证降级和兜底是否生效
9.2 性能瓶颈定位:从外到内
RAG 性能瓶颈通常出现在 3 个位置,调用链路上各环节耗时分布如下:
80ms] A1 --> A2[embedding
30ms] A2 --> A3[vectorSearch
50ms] A2 --> A4[bm25Search
40ms] A3 --> A5[RRF 融合] A4 --> A5 A5 --> A6[rerank
90ms] A6 --> A7[contextBuild
20ms] A7 --> A8["llmGenerate
🔥 800ms"] A8 --> A9[postProcess
30ms] A9 --> OUT([响应]) style A8 fill:#4d1a1a,stroke:#ff4444,stroke-width:3px style A6 fill:#0a0e27,stroke:#00d4ff,stroke-width:2px style A1 fill:#16213e,stroke:#7b61ff style A5 fill:#16213e,stroke:#7b61ff
// RAG 调用链路(标注典型耗时)
const pipeline = {
queryRewrite: 80, // query 改写 LLM 调用
embedding: 30, // query 向量化
vectorSearch: 50, // 向量库检索 TopK=100
bm25Search: 40, // ES 关键词检索
rerank: 90, // Cross-Encoder 重排序
contextBuild: 20, // 上下文拼装
llmGenerate: 800, // LLM 生成(主战场)
postProcess: 30, // 后处理(引用格式化等)
// 合计:~1140ms(P50),瓶颈在 LLM
};
优化优先级:LLM 生成(800ms)> Rerank(90ms)> Query 改写(80ms)。优化 LLM 收益最大(如用 streaming + 提前返回首字 token)。
9.3 优化实战:把 P99 从 1500ms 压到 180ms
我们做了一系列优化,把 P99 从 1500ms 压到 180ms:
| 优化项 | 优化前 | 优化后 | 收益 |
|---|---|---|---|
| 向量库 HNSW 参数调优 | ef=64 | ef=128 + m=16 | 召回+5%, 延迟+30ms |
| Query 改写批量处理 | 单条串行 | Batch 16 条并发 | 延迟-60ms |
| Embedding 模型蒸馏 | bge-large (1024维) | bge-small (512维) | 延迟-15ms, 质量-2% |
| LLM 流式输出 | 等完整响应 | SSE 流式,首字 <200ms | 体感延迟-90% |
| 预计算 Rerank 候选 | Top-100 全 Rerank | Top-30 Rerank | 延迟-50ms |
💡 架构深挖点
- 向量数据库在亿级文档下,P99 通常 50-100ms。千万级 30ms 是怎么做到的?(提示:HNSW 参数调优 + 内存索引 + GPU 加速)
- 流式输出虽然首字延迟低,但总延迟可能更高(流式协议开销)。在 ToB 场景下,是流式还是整段返回?
- 压测时通常用历史 query 回放,但生产 query 分布会变(新业务、新知识)。压测集怎么持续更新?
十、经验沉淀:从 0 到 1 的 6 个决策点
10.1 决策点 1:先做 Demo 还是先做平台?
推荐先 Demo 后平台。用 LangChain + Pinecone + GPT-4 3 天出 Demo,跑通端到端流程。验证业务价值后,再投入工程化平台建设(自研编排、可观测、权限治理)。
反例:上来就自研编排,3 个月还没出 Demo,业务方失去信心,项目被砍。
10.2 决策点 2:向量数据库怎么选?
| 选型 | 优势 | 劣势 | 推荐场景 |
|---|---|---|---|
| Milvus | 国产开源,性能强 | 运维复杂 | 大规模生产 |
| Qdrant | Rust 性能好,资源占用低 | 生态较新 | 中小规模 |
| Weaviate | GraphQL + 模块化 | 中文支持一般 | 多模态场景 |
| Pinecone | 全托管,省心 | 贵,数据出境风险 | 中小规模快速验证 |
| pgvector | 复用现有 PG 集群 | 性能一般 | 千万级以下 + 已有 PG |
10.3 决策点 3:Embedding 模型选开源还是商用?
默认推荐 BGE 系列(bge-large-zh-v1.5,中文场景 SOTA),特殊场景才用 OpenAI text-embedding-3-large。开源模型需要自己部署,但可控、成本低。商用模型效果好但贵、且有数据出境风险。
10.4 决策点 4:LLM 用 GPT-4 还是开源?
看场景:
- 需要 SOTA 质量 + 不在乎钱 → GPT-4o / Claude-3.5
- 需要私有化部署 + 数据敏感 → Qwen2.5-72B / DeepSeek-V3
- 需要极致性价比 + 任务简单 → Qwen2.5-7B / GLM-4-9B
- 混合策略:路由到不同模型,复杂任务用大模型,简单任务用小模型
10.5 决策点 5:知识更新策略
实时同步 vs 定时批处理:
- 实时同步:CDC 监听数据库 Binlog,增量更新。延迟低(秒级),但工程复杂度高
- 定时批处理:每小时/每天全量重建。简单可靠,但延迟高
- 混合策略:热点数据实时同步 + 冷数据定时批处理
推荐混合策略,90% 场景是定时批处理足够,10% 高频更新走实时。
10.6 决策点 6:评估体系怎么建?
必须有3 个层次的评估:
- 离线评估:用标注集跑批量测试,CI 集成(每次模型/数据变更都跑)
- 在线 A/B:新旧版本 1:1 流量切分,对比核心指标
- 用户反馈:真实用户的 👍/👎/转人工率
缺一不可。没有离线评估 = 改动全靠"感觉";没有在线 A/B = 不敢全量;没有用户反馈 = 永远不知道真实质量。
10.7 总结:RAG 落地的 3 个反直觉
① 检索比生成更重要。80% 的 Bad Case 是检索问题,不是 LLM 问题。把精力放在 embedding/Rerank 上,性价比远高于换更强的 LLM。
② 简单优先于复杂。不要一上来就上 Agent/Multi-hop,先把单轮 RAG 做到 95% 准确率,再考虑复杂链路。80% 的业务用不到 Agent。
③ 评估先于开发。没有评估指标的 RAG 系统是"薛定谔的猫"——你不知道它好不好。建评估体系比写代码更重要。
💡 架构深挖点
- 本篇 RAG 架构讨论基于 2026 年的技术栈,2-3 年后哪些会过时?哪些是不变的架构本质?(提示:检索+生成的范式 vs 单一模型大一统的可能性)
- 如果业务要求多模态 RAG(图片、表格、公式),架构需要做哪些改造?多模态 Embedding、跨模态检索、视觉 LLM——哪些是关键路径?
- RAG 与 Fine-tuning 的边界:什么时候用 RAG(知识更新频繁),什么时候用 Fine-tuning(专业领域,prompt 不够用)?两者结合的最优实践是什么?
- 企业 RAG 系统的TCO(总拥有成本)应该怎么算?除了 LLM API,还有哪些隐性成本(向量库运维、Embedding 重建、Prompt 调优的人力成本)?