RAG 工程最优解:意图路由分流架构

摘要

RAG(Retrieval-Augmented Generation)是知识库问答、智能客服、文档助手的主流落地方案。传统RAG对全部请求无脑执行"向量召回+大模型生成",简单查询带来算力浪费、时延抖动、成本居高不下。

业界存在两套主流工程路线:通用标准RAG(多路并行召回融合+重排截断)FAQ特化串行分层RAG(ES前置短路) ,二者适用场景与取舍完全不同,很多文章容易混淆,把特化方案当成通用最佳实践。

本文对比两套架构的优缺点、风险边界,并给出工业界落地最多的意图路由折中架构,同时补充Spring AI Alibaba Java核心实现与工程避坑要点,帮助开发者根据自身业务选型,平衡检索质量、响应时延与算力成本。


一、引言:RAG工程的两大误区

RAG依靠外部文档缓解大模型幻觉,但线上落地很容易踩两个极端:

  1. 全量请求无脑走完整链路:80%简单FAQ也要Embedding、向量检索、重排、LLM生成;QPS上涨后GPU/LLM调用成本爆炸,时延不可控。
  2. 把FAQ特化串行架构当成通用方案:ES/BM25前置串行短路,只要关键词得分足够就直接跳过向量检索;在通用知识库场景会造成隐性召回丢失,部分语义强、关键词弱的问题直接拿不到正确文档。

线上流量服从长尾分布:大部分查询简单直白,少部分需要多文档推理、语义理解。

意图识别三层漏斗是行业共识 :规则匹配 → 轻量小模型分类 → LLM兜底;

分歧点在于检索流水线:到底是BM25与向量并行多路召回,还是ES串行前置短路。


二、RAG检索核心挑战

  1. 语义‑关键词鸿沟:用户转述提问,关键词和文档不一致,关键词检索失效;部分问题关键词稀疏,但向量语义相似度很高。
  2. 召回完整性风险:串行ES短路模式下,如果BM25分数不达标,直接跳过向量检索,丢失向量可以命中的优质候选。
  3. 成本‑效果权衡:Cross-Encoder重排、Embedding推理算力开销不低,不能对所有请求无差别使用。
  4. 流量差异化:流量混杂高频FAQ、简单查询、多文档对比、多跳推理等不同类型请求,一套流水线很难兼顾全部。
  5. 缓存与一致性:知识库更新之后,问答缓存、索引缓存需要合理失效策略。

三、三套生产架构详细拆解

方案A:通用标准生产RAG(Dify/FastGPT/云厂商RAG底层,效果优先)

适用:企业知识库、技术文档、法律资料、ToB产品;召回完整性优先,不允许丢失潜在相关文档,QPS中等。

复制代码
用户Query
    ↓
L1:结果缓存(Redis,query归一化作为key,命中直接返回答案)
    ↓【缓存未命中】
多路召回【并行执行】
  ├ BM25 / ES稀疏关键词召回
  └ Embedding稠密向量召回
    ↓
RRF /加权算法做候选文档融合去重
    ↓
Cross-Encoder重排小模型做精细打分,配置相关性阈值截断
    ├ 重排最高分文档分数≥阈值 且 查询为简单FAQ类 → 直接返回预置答案/文档片段
    └ 否则 → 送入LLM做摘要、对比、推理生成
    ↓
结果写入结果缓存,返回应答

核心特点

  1. BM25与向量并行协同,永不串行互斥,关键词与语义两路互补,避免单路召回缺陷。
  2. 只缓存最终问答结果,不会把ES单独拎出来做短路判断
  3. 链路截断依靠重排之后的相关性分数,而不是ES原始BM25分数。
  4. LLM只处理真正需要推理整合的查询;简单直白的问题若重排后置信度足够,可直接返回片段或预置答案,节约大模型调用。实际生产中,即便返回片段,也常使用简单模板包装成自然语言,避免用户感知生硬。

缺点:Embedding、Cross-Encoder重排会消耗算力;高QPS场景小模型推理压力大。可通过限制重排候选集大小(如只对top-20重排)来缓解。


方案B:特化串行分层RAG(极致降本,仅适合高QPS FAQ客服)

就是早期文章中的架构,属于垂直场景特化优化,不是通用RAG标准

适用:电商客服、工单FAQ,90%以上为关键词明确的字面问题;可以接受极小概率召回损失换取时延与成本收益。

复制代码
用户Query
    ↓
L1:结果缓存
├命中直接返回
└未命中 → ES-BM25单独检索
  ├ ES得分超过高阈值:确信答案充分,直接返回文档片段,跳过向量、重排、LLM
  └ ES得分不足:才执行向量检索、重排,再判断是否进入LLM生成

核心风险

存在召回丢失 :部分查询关键词稀疏,但语义和文档高度匹配;ES分数不达标直接短路跳过向量检索,优质候选彻底丢失。

现实中纯这套架构落地并不多,更多作为子分支配合路由使用。


方案C:工业界首选折中路由架构【推荐落地】

把上面两套方案结合,通过意图分类做流量分流,兼顾降本和召回完整性,绝大多数中大型RAG项目最终选择该方案。

复制代码
用户query
    ↓
1. Redis结果缓存,命中直接返回
    ↓
2. 前置意图分类(规则+轻量小模型,LLM兜底)
  👉 识别为高频FAQ字面查询:走ES-BM25短路分支;高分直接返回文档片段;分数不足下沉完整链路
  👉 识别为复杂推理、多文档对比、语义改写类查询:**强制禁用ES短路,直接进入多路并行召回流水线**
    ↓
重排打分,阈值截断,判断是否送入LLM生成
    ↓
写入缓存返回结果

优势

  1. FAQ流量享受ES短路带来低时延、低成本收益;
  2. 复杂语义查询不会被串行ES短路坑掉召回;
  3. 规避纯串行架构的召回缺陷;
    代价:增加一层意图路由逻辑,系统复杂度小幅上升。

架构对比总表

架构 ES&向量关系 核心目标 业务场景 缺点
标准通用RAG 并行多路召回融合 效果优先 企业知识库、文档问答 Embedding、rerank算力开销大
特化串行RAG ES先跑,达标跳过向量 极致降本、压低RT 高QPS客服FAQ 存在召回损失风险
折中路由版 FAQ走ES短路;复杂请求强制并行多路 效果+成本平衡 绝大多数线上业务 新增意图路由逻辑,复杂度上升

四、线上业务选型决策建议

  1. 企业内部知识库、技术文档、法务资料:优先标准通用RAG;QPS压力大时,升级为折中路由架构。
  2. 百万QPS智能客服FAQ :可以启用ES短路分支,但必须搭配意图路由,复杂查询强制切并行链路,不要全局串行。
  3. 千万不要全局开启ES串行短路用于通用知识库,隐性召回问题很难通过测试发现,只会线上出现BadCase。
  4. 阈值不要写死:BM25阈值、向量相似度阈值、重排分数阈值全部做成可配置,配合监控观测各分支流量占比。
  5. 小团队维护成本考量:若团队不具备训练/维护意图分类模型的能力,且QPS压力不大,直接使用标准通用RAG更简单;只有当成本或时延成为明显瓶颈时,再引入折中路由。

五、Java核心代码实现(折中路由架构)

生产环境建议:

  • ES 使用 Elasticsearch Java API ClientSpring Data Elasticsearch

  • 向量库使用 MilvusPineconeSpring AI 支持的向量数据库

  • 重排模型可通过 sentence-transformers 部署为服务,Java 侧用 HTTP 调用

  • 缓存使用 Redis 替代本地 Caffeine

下面代码以 Spring AI Alibaba 为基础,提供核心逻辑的完整实现思路,个别外部服务调用以伪代码形式呈现,但足够表达工程做法。

复制代码
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.document.Document;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
import org.springframework.data.elasticsearch.core.SearchHit;
import org.springframework.data.elasticsearch.core.SearchHits;
import org.springframework.data.elasticsearch.core.query.NativeSearchQuery;
import org.springframework.data.elasticsearch.core.query.NativeSearchQueryBuilder;
import org.springframework.data.elasticsearch.core.query.Query;
import org.springframework.data.elasticsearch.core.query.SortBuilders;
import org.springframework.data.elasticsearch.core.query.QueryBuilders;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

@Slf4j
@Service
@RequiredArgsConstructor
public class HybridLayeredRagService {

    // ---------- 缓存 ----------
    private final Cache<String, String> answerCache = Caffeine.newBuilder()
            .maximumSize(100000)
            .expireAfterWrite(24, TimeUnit.HOURS)
            .build();

    // ---------- 依赖注入 ----------
    private final VectorStore vectorStore;          // Spring AI 向量存储抽象
    private final EmbeddingModel embeddingModel;    // Embedding 模型(可预留)
    private final ChatClient.Builder chatClientBuilder;
    private final RestTemplate restTemplate;        // 用于调用重排服务
    private final ElasticsearchOperations elasticsearchOperations; // 需引入 spring-boot-starter-data-elasticsearch

    // ---------- 配置项(生产应从配置中心读取) ----------
    @Value("${rag.vector.threshold:0.75}")
    private double vectorThreshold;

    @Value("${rag.bm25.threshold:3.0}")
    private double bm25Threshold;

    @Value("${rag.rerank.threshold:0.8}")
    private double rerankThreshold;

    @Value("${rag.rerank.url:http://localhost:8000/rerank}")
    private String rerankUrl;

    @Value("${rag.llm.enabled:true}")
    private boolean llmEnabled;

    /**
     * 主入口:折中路由架构
     */
    public String answer(String query) {
        // 0. 归一化
        String normalizedQuery = normalizeQuery(query);

        // 1. 缓存检查
        String cached = answerCache.getIfPresent(normalizedQuery);
        if (cached != null) {
            log.debug("缓存命中,query={}", normalizedQuery);
            return cached;
        }

        // 2. 意图分类
        boolean isFaqQuery = detectFaqIntent(normalizedQuery);
        log.info("意图分类:query={}, isFaq={}", normalizedQuery, isFaqQuery);

        // 3. 获取候选文档
        List<Document> candidates;
        if (isFaqQuery) {
            // FAQ分支:尝试ES短路
            Document shortDoc = searchEsShortPath(normalizedQuery);
            if (shortDoc != null) {
                String answer = shortDoc.getContent();
                answerCache.put(normalizedQuery, answer);
                log.info("FAQ短路命中,直接返回内容。query={}", normalizedQuery);
                return answer;
            }
            // 短路失败,继续完整多路召回
            candidates = multiPathRecall(normalizedQuery);
        } else {
            // 复杂查询强制多路召回
            candidates = multiPathRecall(normalizedQuery);
        }

        if (candidates.isEmpty()) {
            String fallback = "抱歉,没有找到相关内容。";
            answerCache.put(normalizedQuery, fallback);
            return fallback;
        }

        // 4. 重排
        List<Document> rerankedDocs = rerank(candidates, normalizedQuery);
        if (!rerankedDocs.isEmpty() && isRerankScoreEnough(rerankedDocs.get(0))) {
            // 重排分数足够高,且查询为FAQ类型,可以直接返回文档片段
            String answer = rerankedDocs.get(0).getContent();
            answerCache.put(normalizedQuery, answer);
            log.info("重排分数达标,直接返回文档。query={}", normalizedQuery);
            return answer;
        }

        // 5. LLM生成
        if (llmEnabled) {
            String llmAnswer = generateWithLlm(normalizedQuery, rerankedDocs);
            answerCache.put(normalizedQuery, llmAnswer);
            return llmAnswer;
        } else {
            // 如果没有启用LLM,返回重排后第一个文档内容
            String answer = rerankedDocs.isEmpty() ? "暂无答案" : rerankedDocs.get(0).getContent();
            answerCache.put(normalizedQuery, answer);
            return answer;
        }
    }

    // ==================== 辅助方法 ====================

    /**
     * 查询归一化:去除多余空格、统一小写、去尾部标点
     */
    private String normalizeQuery(String query) {
        if (query == null) return "";
        return query.trim()
                .toLowerCase()
                .replaceAll("\\s+", " ")
                .replaceAll("[?.!!?。]+$", "");
    }

    /**
     * 意图分类:演示使用规则;生产强烈建议使用轻量分类模型或LLM判断
     */
    private boolean detectFaqIntent(String query) {
        // 规则示例
        List<String> faqKeywords = Arrays.asList("多少", "时间", "密码", "流程", "年假", "请假", "怎么", "如何", "哪里", "什么");
        boolean hitKeyword = faqKeywords.stream().anyMatch(query::contains);
        boolean shortQuery = query.length() < 40;
        // 可增加更多规则,如 query 以问号结尾等
        return hitKeyword && shortQuery;
    }

    /**
     * FAQ专用ES短路:只返回最相关的一条文档,如果BM25分数足够高则返回,否则返回null
     */
    private Document searchEsShortPath(String query) {
        try {
            // 使用 Spring Data Elasticsearch 查询
            NativeSearchQuery searchQuery = new NativeSearchQueryBuilder()
                    .withQuery(QueryBuilders.multiMatchQuery(query, "title", "content"))
                    .withSorts(SortBuilders.scoreSort())
                    .withPageable(PageRequest.of(0, 1))
                    .build();

            SearchHits<EsDocument> hits = elasticsearchOperations.search(searchQuery, EsDocument.class);
            if (hits.hasSearchHits()) {
                SearchHit<EsDocument> hit = hits.getSearchHit(0);
                float score = hit.getScore();
                if (score >= bm25Threshold) {
                    // 构造Spring AI Document对象
                    Document doc = new Document(hit.getContent().getContent(), Map.of("id", hit.getId(), "score", score));
                    return doc;
                }
            }
        } catch (Exception e) {
            log.error("ES短路查询失败,query={}", query, e);
        }
        return null; // 短路不命中或异常
    }

    /**
     * 多路召回:BM25 + 向量,然后RRF融合
     */
    private List<Document> multiPathRecall(String query) {
        List<Document> bm25Docs = new ArrayList<>();
        List<Document> vectorDocs = new ArrayList<>();

        // 1. BM25召回
        try {
            bm25Docs = searchWithBm25(query);
        } catch (Exception e) {
            log.error("BM25召回失败,query={}", query, e);
        }

        // 2. 向量召回
        try {
            vectorDocs = vectorStore.similaritySearch(
                    SearchRequest.query(query)
                            .withTopK(10)
                            .withSimilarityThreshold(vectorThreshold)
            );
        } catch (Exception e) {
            log.error("向量召回失败,query={}", query, e);
        }

        if (bm25Docs.isEmpty() && vectorDocs.isEmpty()) {
            return Collections.emptyList();
        }

        // 3. RRF融合
        return rrfMerge(bm25Docs, vectorDocs);
    }

    /**
     * BM25召回:使用Elasticsearch查询,返回top10文档
     */
    private List<Document> searchWithBm25(String query) {
        NativeSearchQuery searchQuery = new NativeSearchQueryBuilder()
                .withQuery(QueryBuilders.multiMatchQuery(query, "title", "content"))
                .withSorts(SortBuilders.scoreSort())
                .withPageable(PageRequest.of(0, 10))
                .build();

        SearchHits<EsDocument> hits = elasticsearchOperations.search(searchQuery, EsDocument.class);
        return hits.stream()
                .map(hit -> new Document(hit.getContent().getContent(),
                        Map.of("id", hit.getId(), "score", hit.getScore())))
                .collect(Collectors.toList());
    }

    /**
     * RRF融合算法
     */
    private List<Document> rrfMerge(List<Document> list1, List<Document> list2) {
        Map<String, Document> docMap = new LinkedHashMap<>();
        Map<String, Double> scoreMap = new HashMap<>();
        final double k = 60.0;

        addToRrfScore(list1, docMap, scoreMap, k);
        addToRrfScore(list2, docMap, scoreMap, k);

        return docMap.values().stream()
                .sorted((d1, d2) -> Double.compare(
                        scoreMap.getOrDefault(d2.getId(), 0.0),
                        scoreMap.getOrDefault(d1.getId(), 0.0)))
                .collect(Collectors.toList());
    }

    private void addToRrfScore(List<Document> docs, Map<String, Document> docMap, Map<String, Double> scoreMap, double k) {
        int rank = 1;
        for (Document doc : docs) {
            String id = doc.getId();
            docMap.putIfAbsent(id, doc);
            double rrfScore = 1.0 / (k + rank);
            scoreMap.merge(id, rrfScore, Double::sum);
            rank++;
        }
    }

    /**
     * Cross-Encoder重排:调用外部重排服务(如基于sentence-transformers的API)
     */
    private List<Document> rerank(List<Document> docs, String query) {
        if (docs.isEmpty()) return docs;

        try {
            // 构造请求体
            Map<String, Object> request = new HashMap<>();
            request.put("query", query);
            request.put("documents", docs.stream().map(Document::getContent).collect(Collectors.toList()));

            // 调用重排服务
            RerankResponse response = restTemplate.postForObject(rerankUrl, request, RerankResponse.class);

            if (response != null && response.getScores() != null) {
                // 按分数降序排序
                List<Document> sorted = new ArrayList<>(docs);
                List<Double> scores = response.getScores();
                // 构建索引排序
                Integer[] indices = new Integer[sorted.size()];
                for (int i = 0; i < indices.length; i++) indices[i] = i;
                Arrays.sort(indices, (a, b) -> Double.compare(scores.get(b), scores.get(a)));

                List<Document> reranked = new ArrayList<>();
                for (int idx : indices) {
                    Document doc = sorted.get(idx);
                    doc.getMetadata().put("rerank_score", scores.get(idx));
                    reranked.add(doc);
                }
                return reranked;
            }
        } catch (Exception e) {
            log.error("重排服务调用失败,降级为原始顺序。query={}", query, e);
        }
        // 降级:返回原列表
        return docs;
    }

    private boolean isRerankScoreEnough(Document doc) {
        Object scoreObj = doc.getMetadata().get("rerank_score");
        if (scoreObj instanceof Number) {
            return ((Number) scoreObj).doubleValue() >= rerankThreshold;
        }
        return false;
    }

    /**
     * LLM生成最终答案
     */
    private String generateWithLlm(String query, List<Document> contextDocs) {
        // 构造上下文
        String context = contextDocs.stream()
                .limit(5) // 限制上下文长度
                .map(doc -> "【文档片段】\n" + doc.getContent())
                .collect(Collectors.joining("\n\n"));

        String systemPrompt = "你是一个知识库问答助手。请根据以下参考内容回答用户问题,如果参考内容中没有答案,请如实说明。";
        String userPrompt = String.format("用户问题:%s\n\n参考内容:\n%s\n\n请给出准确、简洁的回答。", query, context);

        ChatClient chatClient = chatClientBuilder.build();
        // Spring AI 调用(不同版本API略有差异)
        ChatResponse response = chatClient.prompt()
                .system(systemPrompt)
                .user(userPrompt)
                .call()
                .chatResponse();

        return response.getResult().getOutput().getContent();
    }

    // ==================== 内部类 ====================

    /**
     * 重排服务返回结果
     */
    @lombok.Data
    public static class RerankResponse {
        private List<Double> scores;
    }

    /**
     * Elasticsearch 文档映射(需与索引字段对应)
     */
    @lombok.Data
    public static class EsDocument {
        private String title;
        private String content;
    }
}

代码说明

  1. 缓存 :使用 Caffeine 本地缓存,生产可替换为 Redis。通过 normalizeQuery 统一 key。
  2. 意图分类:示例使用规则,生产务必替换为小模型分类或 LLM 判断,以提高准确率。
  3. ES 短路 :通过 Spring Data Elasticsearch 查询,只取 top1,若分数超过阈值则直接返回文档内容,否则返回 null 进入完整链路。
  4. 多路召回:并行调用 BM25 和向量检索,并做异常捕获,单路失败不影响整体。
  5. RRF 融合:实现了标准的 RRF 算法。
  6. 重排:调用外部重排服务(HTTP),示例代码给出了请求构造和结果解析;如果服务不可用则降级为原始顺序。
  7. LLM 生成:使用 Spring AI 的 ChatClient 构建 prompt,限制上下文长度,生成最终答案。
  8. 配置参数 :所有阈值都通过 @Value 注入,方便在配置文件中调整。

六、工程避坑要点

  1. 不要把特化串行架构当作通用方案:串行ES短路只允许FAQ分支启用;复杂语义查询必须强制走多路并行召回,规避召回丢失。
  2. 缓存设计 :使用归一化后的query做key(去除多余空格、大小写统一);知识库更新主动失效对应缓存,不能单纯依赖TTL。代码中已提供normalizeQuery示例,生产可替换为Redis。
  3. 阈值可配置化 :BM25、向量相似度、重排分数全部配置化,线上根据监控调参,不要硬编码常量。示例代码中使用了@Value注入,生产建议接入配置中心(如Nacos、Apollo)。
  4. BadCase回流机制:收集召回失败、回答错误案例,区分是意图分类错误、BM25漏召回、向量漏召回还是重排打分不准,迭代优化。
  5. 完善监控指标:缓存命中率、FAQ分支占比、复杂查询占比、各分支RT、重排截断率、LLM调用量;通过指标驱动架构调优。在代码关键路径添加日志和埋点(如Micrometer)。
  6. 多路召回融合优先RRF:RRF鲁棒性优于简单加权,不需要人工调权重,工业界RAG首选融合算法。
  7. 重排不要滥用:高QPS场景,可以对候选集合做裁剪,只对top-N文档做重排,降低小模型算力压力。方案A中尤其注意。
  8. 异常降级与容错:BM25或向量召回失败时,要有降级策略,保证服务可用性。示例代码中捕获异常并记录日志。

七、总结

  1. 串行ES前置分层RAG是高QPS FAQ场景特化优化,牺牲微小召回质量换取成本时延收益,不能直接套用到通用知识库。
  2. 标准通用RAG采用BM25+向量并行多路召回、RRF融合、Cross-Encoder重排截断,追求召回完整性,是Dify、FastGPT等开源框架底层方案。
  3. 折中意图路由架构是生产落地首选:通过意图识别分流流量,FAQ走短路降本,复杂查询强制完整多路链路,兼顾效果、成本、时延。
  4. RAG工程落地不是一套架构通吃所有业务,需要结合业务流量构成、QPS压力、对召回完整性容忍度做选型。
  5. 在实现细节上务必重视缓存归一化、阈值动态配置、异常降级和监控埋点,这些才是决定系统稳定性的关键。
相关推荐
余槐i1 小时前
Firecrawl 实战:将网站转换为大模型可用数据
人工智能·python·工具·firecrawl
hsg771 小时前
简述: 人工智能 + 行动意见
人工智能
jason_renyu2 小时前
《月魁传》的终极预言:真正的 AI 未来,是超智能进化与万物互联
人工智能·万物互联·超人工智能·人工智能未来畅想
XiaoZhenHua982 小时前
VisionPro多相机高速检测性能优化实战:从图像堆积到稳定运行
人工智能·计算机视觉·自动化
昇腾知识体系2 小时前
CANN 安装升级避坑:version.cfg 查版本、银河麒麟找不到驱动、nnrt --version 无输出排查
人工智能·华为·知识图谱
java_logo2 小时前
Claude 遭大规模「蒸馏」?过去 8 个月,AI 行业另一场战争被摊开了
人工智能·claude·qwen·ai 安全·kimi·模型蒸馏·anthropic
yychen_java2 小时前
第二篇:从世界模型到 Physical AI——一套可落地的工业智能体架构
人工智能·架构
matlab代码2 小时前
基于matlab多尺度形态学提取眼前节组织【源码73期】
图像处理·人工智能·计算机视觉
IT_陈寒2 小时前
Redis的Set操作居然能把我的服务整挂了?
前端·人工智能·后端