构建高可靠 RAG
从 Demo 到生产:构建高可靠 RAG 系统的工程实践
本文不谈 RAG 是什么,谈的是把 RAG 从"能跑的 Demo"变成"敢上线的系统"过程中,那些真正踩过的坑。
一、为什么你的 RAG Demo 上不了线
如果你做过 RAG,大概率经历过这个流程:
文档 → 切块 → Embedding → 向量库 → 检索 Top-K → 拼 Prompt → LLM 生成
在 Demo 阶段,这个流程用 50 行代码就能跑通,回答看起来也挺像回事。但一旦拿到真实数据上,问题就来了:
| Demo 中的表现 | 生产中的真相 |
|---|---|
| 文档几十篇,检索又快又准 | 文档几万篇,Top-K 里混入大量无关内容 |
| 问题简单直接,Embedding 命中率高 | 用户问法千变万化,语义鸿沟导致召回率骤降 |
| 回答错了也无所谓 | 回答错了可能引发业务事故 |
| 没人关心延迟 | 用户等 15 秒就刷新页面 |
| 成本不是问题 | 几万次调用后账单让你心慌 |
核心矛盾在于:Demo 验证的是"能不能做",生产要回答的是"做得够不够稳"。 下面逐个拆解。
二、切块(Chunking):最被低估的环节
很多人把切块当成预处理的一个 split_text() 调用,随手设个 chunk_size=512, overlap=50 就完事了。这是最大的误区。
2.1 固定长度切块的致命问题
固定长度切块会在语义中间硬切断,比如:
...该接口的鉴权方式采用 AK/SK 签名认证,具体流程为:第一步,构造规范请求;第二步,
--- 切断线 ---
计算签名串;第三步,添加 Authorization 头。签名算法使用 HMAC-SHA256...
检索时,如果用户问"鉴权流程是什么",上半段和下半段各自得分都不够高,可能都进不了 Top-K。
2.2 结构感知切块
更好的做法是按文档结构切块:
def chunk_by_structure(document):
"""
按标题层级递归切块,保证每个 chunk 语义完整。
"""
chunks = []
def recurse(node, parent_path=""):
current_path = f"{parent_path}/{node.title}"
if node.has_children():
# 有子节点:先处理子节点,再决定是否需要父节点单独成块
for child in node.children:
recurse(child, current_path)
else:
# 叶子节点:内容作为独立 chunk
content = node.content.strip()
if len(content) > MIN_CHUNK_SIZE:
chunks.append({
"text": content,
"metadata": {
"path": current_path,
"level": node.level,
"doc_id": document.id
}
})
recurse(document.root)
return chunks
关键原则:
- 不要在句子中间断开 —— 用句号/换行符作为最小不可分割单位
- 保留上下文路径 —— 把标题层级存入 metadata,检索时可以用来做过滤或重排
- 大块再切,小块不合并 —— 超过阈值的大段落递归切分,小段落保持独立(合并会引入噪声)
2.3 一个容易被忽略的指标:Chunk 语义密度
不是所有 chunk 都有检索价值。比如版权声明、目录页、修订记录,它们的 Embedding 向量和正文几乎正交,但会占据 Top-K 的名额。
建议在入库前做一轮语义密度过滤:用一个轻量模型给每个 chunk 打分,低于阈值的直接丢弃或降权。这一步能提升 10-15% 的检索准确率,成本几乎为零。
三、检索:从"向量相似"到"语义相关"
3.1 纯向量检索的盲区
向量相似 ≠ 语义相关。一个经典反例:
- 文档中:“我们的服务不支持跨区域复制功能。”
- 用户问:“你们支持跨区域复制吗?”
这两个句子的 Embedding 相似度可能高达 0.85,但语义是相反的。纯向量检索会把这条"不支持"的声明排在很前面,LLM 拿到后可能回答"支持",因为 Prompt 里只看到"跨区域复制"这个关键词,否定词在生成时被忽略了。
3.2 混合检索(Hybrid Search)
生产级 RAG 几乎都用混合检索:
最终分数 = α × 向量检索分数 + (1 - α) × 关键词检索分数
- 向量检索(Dense):捕捉语义相似,适合 paraphrase 场景
- 关键词检索(Sparse / BM25):捕捉精确匹配,适合专有名词、错误码、API 名
- α 通常设 0.5~0.7,偏语义的语料调高,偏技术文档的调低
def hybrid_search(query, top_k=10, alpha=0.6):
# 向量检索
dense_results = vector_store.search(
embedding_model.encode(query),
top_k=top_k * 2 # 过采样
)
# 关键词检索(BM25)
sparse_results = bm25_store.search(
tokenize(query),
top_k=top_k * 2
)
# 分数归一化后加权融合
fused = score_fusion(dense_results, sparse_results, alpha=alpha)
return fused[:top_k]
3.3 重排(Reranking):性价比最高的提升
检索召回 Top-50 后,用一个 Cross-Encoder 重排模型精排到 Top-5:
检索阶段:Bi-Encoder,快但粗(向量点积,万级文档 < 50ms)
重排阶段:Cross-Encoder,慢但准(逐对计算,50 条 < 200ms)
Cross-Encoder 把 query 和 document 拼在一起送入模型,能捕捉细粒度的交互关系(比如上面那个否定词的例子)。实测在技术文档场景下,加入重排后回答准确率从 ~72% 提升到 ~86%。
推荐模型:bge-reranker-v2-m3(多语言,轻量,部署成本低)。
四、生成:Prompt 工程不是玄学
4.1 最小可用 Prompt 结构
你是一个技术文档问答助手。请根据以下检索到的文档片段回答用户问题。
规则:
1. 只基于提供的文档片段回答,不要使用外部知识
2. 如果文档中没有相关信息,明确回答"根据现有文档无法回答此问题"
3. 回答时引用来源,格式为 [文档名#章节]
检索到的文档片段:
---
[1] 《API参考》/ 鉴权 / AK-SK签名
构造规范请求 → 计算签名串 → 添加Authorization头
签名算法:HMAC-SHA256
---
[2] 《API参考》/ 鉴权 / Token认证
适用于Web场景,Token有效期24小时
---
用户问题:鉴权有哪几种方式?
三条规则各有用意:
- 规则 1:防止幻觉,这是 RAG 存在的全部意义
- 规则 2:给模型一个"合法拒绝"的出口,比让它硬编好得多
- 规则 3:可追溯性,出问题时能定位到是哪个 chunk 导致的
4.2 上下文窗口的取舍
一个常见错误是把检索到的所有 chunk 都塞进 Prompt。假设检索回 10 个 chunk,每个 500 token,就是 5000 token 的上下文。这带来两个问题:
- Lost in the Middle:研究表明 LLM 对 Prompt 中间位置的信息注意力会下降,关键信息放在中间反而容易被忽略
- 成本和延迟:输入 token 是要花钱的,而且越长生成越慢
建议:Top-K 设 3~5,把最相关的放在 Prompt 的开头和结尾(首因效应 + 近因效应)。
五、评估:没有度量就没有改进
5.1 别用"看起来不错"当标准
RAG 最难的不是搭系统,是评估系统。"我试了几个问题感觉回答得还行"不是评估,是自欺欺人。
5.2 RAG 三层评估框架
Layer 1: 检索评估 —— 召回的文档对不对?
Layer 2: 生成评估 —— 生成的答案对不对?
Layer 3: 端到端评估 —— 用户满意不满意?
检索层指标
| 指标 | 含义 | 计算方式 |
|---|---|---|
| Hit Rate | 正确文档是否出现在 Top-K 中 | 1 if relevant_doc in top_k else 0 |
| MRR | 正确文档的排名倒数 | 1 / rank_of_first_relevant |
| Recall@K | Top-K 中相关文档的比例 | relevant_in_top_k / total_relevant |
生成层指标(用 LLM-as-Judge)
def evaluate_answer(question, answer, ground_truth, reference_docs):
prompt = f"""
请评估以下回答的质量。
问题:{question}
参考文档:{reference_docs}
标准答案:{ground_truth}
待评估回答:{answer}
从以下维度打分(1-5):
- 准确性:回答是否与参考文档和标准答案一致
- 完整性:是否覆盖了问题的主要方面
- 忠实性:是否没有编造文档中不存在的信息
输出 JSON:{{"accuracy": x, "completeness": x, "faithfulness": x, "reason": "..."}}
"""
return llm_judge(prompt)
构建评估集
这是最花时间但最值得的投入:
- 从真实用户问题中采样 100~200 条
- 人工标注每条问题的正确答案和应该召回的文档
- 每次系统改动后跑一遍评估集,看指标变化
没有评估集的优化都是盲调。
六、工程化:让系统跑得稳
6.1 缓存层
RAG 系统中有很多可以缓存的环节:
Query → [Query Cache] → Embedding → [Embedding Cache] → Search → Rerank → [Result Cache] → Generate
- Query Cache:相同问题直接返回上次答案(适合 FAQ 场景,命中率 20-40%)
- Embedding Cache:相同文本的向量不重复计算(文档入库时尤其有用)
- Result Cache:query + 检索结果做 key,省掉检索+重排(命中后只走生成)
用 Redis 做缓存,TTL 设 1 小时,对延迟敏感的场景效果立竿见影。
6.2 降级策略
def rag_pipeline(query):
try:
# 主链路:混合检索 + 重排
docs = hybrid_search(query)
docs = rerank(query, docs)
return generate(query, docs)
except (VectorStoreError, RerankerTimeout):
try:
# 降级链路 1:仅向量检索,跳过重排
docs = vector_search(query)
return generate(query, docs)
except Exception:
try:
# 降级链路 2:仅关键词检索
docs = bm25_search(query)
return generate(query, docs)
except Exception:
# 最终兜底:纯 LLM 生成(标注"未检索到相关文档")
return generate(query, docs=[], fallback=True)
核心思想:检索可以降级,生成不能停。 用户宁可收到一个标注了"未找到相关文档"的回答,也不要收到一个 500 错误。
6.3 可观测性
生产系统必须能看到每次请求的完整链路:
@trace
def rag_pipeline(query):
with span("embedding"):
query_vec = embed(query)
with span("retrieval"):
docs = search(query_vec)
log.info("retrieved_docs", doc_ids=[d.id for d in docs], scores=[d.score for d in docs])
with span("rerank"):
docs = rerank(query, docs)
log.info("reranked_scores", scores=[d.rerank_score for d in docs])
with span("generation"):
answer = generate(query, docs)
log.info("generation", prompt_tokens=count_tokens(query, docs), answer=answer)
return answer
关键日志字段:
retrieved_doc_ids+scores:检索结果是否合理reranked_scores:重排是否改变了排序prompt_tokens:上下文长度是否超预期latency_breakdown:各环节耗时分布
七、成本控制
7.1 Embedding 成本
文档入库是一次性成本,查询 Embedding 是持续成本。如果 QPS 高,Embedding 调用费用不可忽视。
优化方案:
- 用开源模型自部署(如
bge-m3),消除 API 调用成本 - 批量 Embedding(一次编码多条 query,减少请求次数)
- Embedding 缓存(相同 query 不重复编码)
7.2 LLM 生成成本
这是大头。几个杠杆:
| 手段 | 效果 | 代价 |
|---|---|---|
| 减少 Top-K(5→3) | 输入 token 减少 40% | 可能丢失相关信息 |
| 用小模型做简单问答 | 单次成本降低 70% | 复杂问题质量下降 |
| Prompt 缓存 | 重复问题零成本 | 缓存命中率依赖问题分布 |
| 流式生成 + 提前终止 | 用户体感提升 | 不省钱,但省时间 |
实际建议:先用大模型(如 GPT-4 级别)跑通评估集,确认准确率达标后,再逐步降级到小模型,看准确率下降是否可接受。很多时候 70B 级别模型就够用了。
八、一张图总结
┌─────────────────────────────────────────┐
│ RAG 生产架构 │
└─────────────────────────────────────────┘
离线流程 在线流程
──────── ────────
文档 用户 Query
│ │
▼ ▼
结构感知切块 Query 改写/扩展
│ │
▼ ▼
语义密度过滤 Embedding (缓存)
│ │
▼ ▼
Embedding 混合检索
│ ┌────┴────┐
▼ ▼ ▼
向量入库 向量检索 BM25检索
└────┬────┘
▼
分数融合 (α加权)
│
▼
Cross-Encoder 重排
│
▼
Top-3~5 文档
│
▼
Prompt 组装 (首尾优先)
│
▼
LLM 生成 (流式)
│
▼
答案 + 引用来源
九、写在最后
RAG 的本质不是"检索 + 生成",而是用检索来约束生成。所有的工程努力——切块、混合检索、重排、Prompt 设计、评估——都是为了让这个约束更精准。
如果你只记住一件事,记住这个:
先建评估集,再做任何优化。
没有评估集,你不知道改动是变好了还是变差了;有了评估集,每一次改动都有据可依。
RAG 不复杂,但把每一步都做对、做稳,需要的是工程纪律,不是模型魔法。
本文观点基于笔者在实际项目中的工程实践,指标数据来自特定场景的评估集,仅供参考。不同语料和业务场景下数值会有差异。
- 点赞
- 收藏
- 关注作者
评论(0)