RAG架构从Demo到生产的工程化路径
RAG 架构从 Demo 到生产的工程化路径
一份构建过 3 个生产级 RAG 系统的趟坑总结,不是"对接个 LLM + 向量库就跑"的入门教程。
1. 为什么大多数 RAG Demo 上不了生产?
你照着 LangChain 的 Quickstart 搭了一个"上传 PDF → 切片 → 存向量库 → 问答"的流程,本地跑得挺顺。推到生产环境后,用户第一句就问:"为什么搜不到我上周上传的那份合同里的违约金条款?"
Demo 和生产之间存在五道鸿沟:
| 维度 | Demo | 生产 |
|---|---|---|
| 切片 | 固定 500 token 一刀切 | 按文档结构自适应切分 |
| 检索 | 单路向量相似度 | 多路召回 + 重排序 + 元数据过滤 |
| 上下文 | 塞 Top-K 进 prompt | 动态窗口 + 引用溯源 |
| 评测 | 肉眼看看"像不像" | 自动化 RAGAS / TruLens 打分 |
| 运维 | 单进程跑完拉倒 | 增量索引、向量库高可用、query 日志 |
下面按这五道关口逐一拆解。
2. 切片策略:别把一句话劈成两半
固定长度切片是最常见的翻车点——把"甲方应在收到发票后 30 日内支付款项"切成两段,模型读到前半句"甲方应在收到发票后 30 日内"就答"不清楚支付对象"。
自适应分片方案
from langchain_text_splitters import RecursiveCharacterTextSplitter, MarkdownHeaderTextSplitter
# 方案 A:Markdown 文档用标题结构天然分片
headers_to_split_on = [
("#", "h1"),
("##", "h2"),
("###", "h3"),
]
md_splitter = MarkdownHeaderTextSplitter(headers_to_split_on)
chunks = md_splitter.split_text(markdown_text)
# 每片保留 h1/h2/h3 元数据,检索时可做标题加权
# 方案 B:通用文本用递归语义分片 + 重叠窗口
splitter = RecursiveCharacterTextSplitter(
chunk_size=512,
chunk_overlap=64, # 重叠 64 字符,防边界切断
separators=["\n\n", "\n", "。", "!", "?", ".", "!", "?", " "],
length_function=len,
)
工程建议
- chunk_overlap 不能省略:即使牺牲一些存储,也要保证相邻 chunk 有 10%-15% 的重叠。边界截断导致的"查不出来"在生产环境中很难 debug。
- 元数据别浪费:切片时尽量保留源文档名、页码、章节标题、创建时间。检索阶段做元数据预过滤能大幅缩小搜索空间。
- 不要用 embedding 长度偷懒:有人为了省 token 把 chunk_size 设成 embedding 模型的 max_length。chunk_size 由语义完整性决定,是检索质量参数,不是成本参数。
3. 检索管道:为什么单路向量搜不够
只用 vector_store.similarity_search(query, k=5) 的场景,准确率天花板大概在 60%-70%。要上 85%+,需要多路召回 + 重排序。
三路召回架构
用户 query
│
├─ 路1: 稠密向量检索 (语义相似)
│ embedding → FAISS/Milvus → Top-20
│
├─ 路2: 稀疏关键词检索 (精确匹配)
│ BM25 / Elasticsearch → Top-20
│
└─ 路3: 元数据过滤 + 向量检索 (范围收窄)
时间/作者/文档类型过滤 → 子空间向量检索 → Top-20
│
↓
RRF 融合 (Reciprocal Rank Fusion) → Top-15
↓
Cross-Encoder 重排序 (bge-reranker-v2) → Top-5
↓
送入 LLM 生成
RRF 融合代码
def reciprocal_rank_fusion(results_lists, k=60):
"""
results_lists: [[doc_id, ...], [doc_id, ...], ...]
每路返回的是一个按相关性排序的 doc_id 列表
"""
fused_scores = {}
for results in results_lists:
for rank, doc_id in enumerate(results):
fused_scores[doc_id] = fused_scores.get(doc_id, 0) + 1 / (k + rank + 1)
return sorted(fused_scores.items(), key=lambda x: x[1], reverse=True)
重排序:Cross-Encoder 的成本取舍
BM25 + Dense + RRF 融合后,Top-15 已经比单路 Top-5 好不少,但真正把准确率拉上 90% 的是 Cross-Encoder 重排序。
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")
def rerank(query: str, candidates: list[str]) -> list[tuple[str, float]]:
pairs = [(query, doc) for doc in candidates]
scores = reranker.predict(pairs)
return sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
成本估算:单次 query 对 15 个候选做 Cross-Encoder 打分,在 CPU 上约 200-400ms。对于用户交互式场景可接受;对于批量处理场景建议只用 RRF 融合结果。
4. 引用溯源:让用户知道答案"从哪来的"
生产级 RAG 和 Demo 的另一道分水岭是可验证性。用户不仅要答案,还要求证。
实现思路
每个 chunk 入库时打上溯源标签:
chunk_metadata = {
"source": "合同_WX20240315_甲乙方代理协议.pdf",
"page": 3,
"chunk_index": 7,
"section": "第六条 违约责任",
"created_at": "2024-03-15T10:30:00Z",
}
# 生成时要求 LLM 标注引用
prompt_template = """
基于以下上下文回答问题,并在每个关键事实后标注引用编号(如 [1], [2]):
[1] {chunk_1_metadata.source} P{chunk_1_metadata.page}:
{chunk_1_text}
[2] {chunk_2_metadata.source} P{chunk_2_metadata.page}:
{chunk_2_text}
问题:{query}
请给出答案,确保所有关键事实都有引用标注。
"""
LLM 输出示例:
违约金为合同总金额的 20% [1],甲方应在收到发票后 30 日内支付 [2]。
前端渲染时,[1] 和 [2] 可点开展示原文片段和文档链接。
5. 评测体系:别凭感觉判断
人工评测 100 条 query 要花半小时,而且不可复现。RAG 系统至少需要三个自动化评测维度:
from ragas import evaluate
from ragas.metrics import (
faithfulness, # 答案是否忠实于检索到的上下文
answer_relevancy, # 答案是否切题
context_recall, # 上下文是否覆盖了参考答案所需信息
context_precision, # 检索到的上下文有多少是相关的
)
result = evaluate(
dataset=eval_dataset, # 需要 ground truth 的标注数据集
metrics=[faithfulness, answer_relevancy, context_recall, context_precision],
)
工程建议
- ground truth 数据集至少 200 条:100 条的统计意义有限,一个异常 query 就能把指标拉偏 2-3 个百分点。
- faithfulness 是最硬指标:其他三个维度可以通过加结果数量来灌水,只有 faithfulness 衡量的是"模型有没有胡说",这个出问题 = 事故。
- 每次索引重建/模型切换后必须全量重评:向量模型的 minor version 升级都可能导致检索分布漂移。
6. 生产运维三板斧
6.1 增量索引
全量重建索引在百万级文档时动辄数小时,必须支持增量:
# 方案:基于文档哈希检测变更 + 按文档范围局部重索引
def incremental_index(docs: list[Document]):
for doc in docs:
new_hash = hashlib.md5(doc.content.encode()).hexdigest()
old_hash = redis.get(f"doc_hash:{doc.doc_id}")
if old_hash != new_hash:
# 先删旧 chunk,再写新 chunk
vector_store.delete(filter={"doc_id": doc.doc_id})
chunks = splitter.split(doc)
vector_store.add(chunks)
redis.set(f"doc_hash:{doc.doc_id}", new_hash)
6.2 Query 日志闭环
每一条用户 query 都应该记录:
query_log = {
"query_id": uuid4(),
"query_text": "违约金比例是多少",
"retrieved_chunks": [...], # 检索结果
"reranked_chunks": [...], # 重排后结果
"llm_response": "...",
"user_feedback": "thumbs_up", # 隐式反馈(点了引用链接)
"latency_ms": 1450,
"timestamp": datetime.now().isoformat(),
}
这些日志是评测数据集持续扩增的来源——低分 query 人工标注后加进 ground truth,形成飞轮。
6.3 向量库选型
| 场景 | 推荐 | 理由 |
|---|---|---|
| 单机 < 10 万文档 | FAISS | 零运维,性能极好 |
| 分布式 / 百万级 | Milvus | 支持增量、混合查询、集群 |
| 已有 ES 的团队 | Elasticsearch 8.x+ | 原生支持 dense_vector + BM25 混合 |
| 云原生 | Pinecone / Weaviate Cloud | 免运维,按量付费 |
7. 一张图总结
flowchart LR
A[文档上传] --> B[自适应切片<br/>+元数据提取]
B --> C[Embedding<br/>+写入向量库]
C --> D[增量索引<br/>哈希变更检测]
E[用户Query] --> F[Query改写<br/>同义词/子问题拆解]
F --> G[三路召回<br/>Dense+BM25+元数据]
G --> H[RRF融合<br/>Top-15]
H --> I[Cross-Encoder<br/>重排序 Top-5]
I --> J[LLM生成<br/>带引用标注]
J --> K[返回答案]
J --> L[Query日志<br/>用户反馈]
L --> M[评测数据集<br/>持续扩增]
M --> N[RAGAS自动评测]
N --> O[调优:切片/Embedding/重排]
8. 关键决策检查清单
启动一个生产级 RAG 项目前,逐条确认:
- 切片策略是否按文档类型(Markdown / PDF / 纯文本)做了自适应?
- chunk_overlap 是否至少设置了 chunk_size 的 10%?
- 检索是否至少包含 Dense + BM25 两路?是否加了 RRF 融合?
- 是否引入了 Cross-Encoder 重排序?
- 答案是否带引用溯源标记?
- 是否有自动化评测流水线(RAGAS / TruLens)?
- 是否支持增量索引而不需要全量重建?
- query 日志是否闭环(反馈 → 标注 → 评测数据集)?
其中最后三条是区分"能用"和"能运维"的关键。
下一篇预告:信贷风控方向——「评分卡模型的变量分箱:等频 vs 等距 vs 决策树,选错了代价多大?」