构建高可靠 RAG

举报
yd_292659020 发表于 2026/11/04 00:37:49 2026/11/04
【摘要】 从 Demo 到生产:构建高可靠 RAG 系统的工程实践本文不谈 RAG 是什么,谈的是把 RAG 从"能跑的 Demo"变成"敢上线的系统"过程中,那些真正踩过的坑。 一、为什么你的 RAG Demo 上不了线如果你做过 RAG,大概率经历过这个流程:文档 → 切块 → Embedding → 向量库 → 检索 Top-K → 拼 Prompt → LLM 生成在 Demo 阶段,这个流...

从 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

关键原则:

  1. 不要在句子中间断开 —— 用句号/换行符作为最小不可分割单位
  2. 保留上下文路径 —— 把标题层级存入 metadata,检索时可以用来做过滤或重排
  3. 大块再切,小块不合并 —— 超过阈值的大段落递归切分,小段落保持独立(合并会引入噪声)

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 的上下文。这带来两个问题:

  1. Lost in the Middle:研究表明 LLM 对 Prompt 中间位置的信息注意力会下降,关键信息放在中间反而容易被忽略
  2. 成本和延迟:输入 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)

构建评估集

这是最花时间但最值得的投入:

  1. 从真实用户问题中采样 100~200 条
  2. 人工标注每条问题的正确答案和应该召回的文档
  3. 每次系统改动后跑一遍评估集,看指标变化

没有评估集的优化都是盲调。


六、工程化:让系统跑得稳

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 不复杂,但把每一步都做对、做稳,需要的是工程纪律,不是模型魔法。


本文观点基于笔者在实际项目中的工程实践,指标数据来自特定场景的评估集,仅供参考。不同语料和业务场景下数值会有差异。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。