Spring AI 企业级 RAG 深度实战:混合检索、Re-ranking 与多租户安全隔离

摘要: 本文基于我在某制造企业 RAG 系统的深度优化实践,聚焦混合检索、Re-ranking 重排序、多租户安全隔离三个核心技术难点。系统从纯向量检索 60% 准确率起步,通过 RRF 混合检索提升至 85%,Cross-Encoder 重排序达到 92%,语义缓存将 LLM 调用成本降低 72%,三层多租户隔离杜绝数据泄露。每个模块提供完整可运行的 Spring AI 1.0.0 代码实现。
环境说明 :Spring AI 1.0.0 + JDK 17 + Spring Boot 3.2 + Redis Stack 7.2 + PostgreSQL 15 + Elasticsearch 8.x

版本提示:本文代码基于 Spring AI 1.0.0,0.8.x API 差异较大。Redis Vector 高级索引需 Redis Stack 7.2+。Elasticsearch 混合检索需 8.x 版本。
关键词:企业级RAG、混合检索、Re-ranking、多租户隔离、Spring AI、语义缓存、生产环境


项目背景:从 60% 准确率到 92% 的跨越

在我负责的某制造企业知识库项目中,RAG 系统上线首月准确率仅 60%。根因是纯向量检索对"产品型号 X-200 故障代码 E03"这类精确查询效果极差------向量语义匹配无法区分 X-200 和 X-201 的细微差异,而关键词检索又无法理解"怎么老报警"指的是故障报警。更严重的是,生产环境曾发生租户 A 查到租户 B 文档的数据泄露事故。这三重问题------检索不准、排序不精、隔离不严------是 RAG 从 Demo 走向生产的必经之坎。

架构决策:为什么选混合检索而非纯向量检索

维度 纯向量检索 纯关键词检索 混合检索(RRF 融合)
语义理解 强,能理解同义词和语义关联 弱,仅匹配字面关键词 强,继承向量检索优势
精确匹配 弱,"X-200"可能匹配到"X-201" 强,精确匹配型号和代码 强,关键词通道兜底精确匹配
专业术语 差,术语嵌入向量区分度低 中,依赖术语是否在文档中出现 好,双通道互补
实现复杂度 低,单一检索通道 低,单一检索通道 中,需融合算法和双通道维护
延迟 低,50-80ms 低,10-30ms 中,80-120ms(并行可优化)
成本 中,Embedding 计算开销 中,双通道存储和计算

决策结论:混合检索是当前最优解。纯向量检索在专业术语和精确匹配场景天花板约 70%,纯关键词检索无法理解自然语言查询。RRF 融合两者优势,准确率从 60% 提升到 85%,延迟增加可控(并行执行时仅增加 20-30ms)。不选纯向量检索的原因:制造企业的查询 40% 包含产品型号和故障代码,纯语义匹配无法处理。

架构决策:向量数据库选型------Redis Vector vs Milvus vs PgVector

维度 Redis Vector(Redis Stack 7.2+) Milvus 2.x PgVector(PostgreSQL 扩展)
查询延迟 极低,1-3ms(内存) 低,5-15ms 中,10-30ms(磁盘)
吞吐量 高,10 万+ QPS 极高,50 万+ QPS 中,1-5 万 QPS
运维复杂度 低,复用现有 Redis 高,独立集群 + 依赖 低,复用现有 PG
生态集成 Spring AI 原生支持 Spring AI 支持,社区活跃 Spring AI 原生支持
混合检索支持 需自行实现 RRF 内置混合检索 需自行实现 RRF
多租户 filterExpression Partition Key Row Level Security
成本 中,内存成本高 高,独立集群成本 低,复用 PG 实例
数据规模上限 500 万向量 10 亿+ 向量 1000 万向量

决策结论 :本项目选择 Redis Vector。原因:文档量 50 万级,Redis 延迟最优且复用现有基础设施,运维成本最低。不选 Milvus 的原因:50 万文档规模下 Milvus 是杀鸡用牛刀,运维复杂度不值得。不选 PgVector 的原因:延迟比 Redis 高 5-10 倍,且与 ES 双写增加存储成本。适用边界:文档量超过 500 万时必须迁移到 Milvus。

系统架构设计

#mermaid-svg-Ne9vQmHJmWEvS2Fb{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Ne9vQmHJmWEvS2Fb .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Ne9vQmHJmWEvS2Fb .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Ne9vQmHJmWEvS2Fb .error-icon{fill:#552222;}#mermaid-svg-Ne9vQmHJmWEvS2Fb .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Ne9vQmHJmWEvS2Fb .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Ne9vQmHJmWEvS2Fb .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Ne9vQmHJmWEvS2Fb .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Ne9vQmHJmWEvS2Fb .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Ne9vQmHJmWEvS2Fb .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Ne9vQmHJmWEvS2Fb .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Ne9vQmHJmWEvS2Fb .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Ne9vQmHJmWEvS2Fb .marker.cross{stroke:#333333;}#mermaid-svg-Ne9vQmHJmWEvS2Fb svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Ne9vQmHJmWEvS2Fb p{margin:0;}#mermaid-svg-Ne9vQmHJmWEvS2Fb .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Ne9vQmHJmWEvS2Fb .cluster-label text{fill:#333;}#mermaid-svg-Ne9vQmHJmWEvS2Fb .cluster-label span{color:#333;}#mermaid-svg-Ne9vQmHJmWEvS2Fb .cluster-label span p{background-color:transparent;}#mermaid-svg-Ne9vQmHJmWEvS2Fb .label text,#mermaid-svg-Ne9vQmHJmWEvS2Fb span{fill:#333;color:#333;}#mermaid-svg-Ne9vQmHJmWEvS2Fb .node rect,#mermaid-svg-Ne9vQmHJmWEvS2Fb .node circle,#mermaid-svg-Ne9vQmHJmWEvS2Fb .node ellipse,#mermaid-svg-Ne9vQmHJmWEvS2Fb .node polygon,#mermaid-svg-Ne9vQmHJmWEvS2Fb .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Ne9vQmHJmWEvS2Fb .rough-node .label text,#mermaid-svg-Ne9vQmHJmWEvS2Fb .node .label text,#mermaid-svg-Ne9vQmHJmWEvS2Fb .image-shape .label,#mermaid-svg-Ne9vQmHJmWEvS2Fb .icon-shape .label{text-anchor:middle;}#mermaid-svg-Ne9vQmHJmWEvS2Fb .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Ne9vQmHJmWEvS2Fb .rough-node .label,#mermaid-svg-Ne9vQmHJmWEvS2Fb .node .label,#mermaid-svg-Ne9vQmHJmWEvS2Fb .image-shape .label,#mermaid-svg-Ne9vQmHJmWEvS2Fb .icon-shape .label{text-align:center;}#mermaid-svg-Ne9vQmHJmWEvS2Fb .node.clickable{cursor:pointer;}#mermaid-svg-Ne9vQmHJmWEvS2Fb .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Ne9vQmHJmWEvS2Fb .arrowheadPath{fill:#333333;}#mermaid-svg-Ne9vQmHJmWEvS2Fb .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Ne9vQmHJmWEvS2Fb .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Ne9vQmHJmWEvS2Fb .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Ne9vQmHJmWEvS2Fb .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Ne9vQmHJmWEvS2Fb .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Ne9vQmHJmWEvS2Fb .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Ne9vQmHJmWEvS2Fb .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Ne9vQmHJmWEvS2Fb .cluster text{fill:#333;}#mermaid-svg-Ne9vQmHJmWEvS2Fb .cluster span{color:#333;}#mermaid-svg-Ne9vQmHJmWEvS2Fb div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Ne9vQmHJmWEvS2Fb .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Ne9vQmHJmWEvS2Fb rect.text{fill:none;stroke-width:0;}#mermaid-svg-Ne9vQmHJmWEvS2Fb .icon-shape,#mermaid-svg-Ne9vQmHJmWEvS2Fb .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Ne9vQmHJmWEvS2Fb .icon-shape p,#mermaid-svg-Ne9vQmHJmWEvS2Fb .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Ne9vQmHJmWEvS2Fb .icon-shape .label rect,#mermaid-svg-Ne9vQmHJmWEvS2Fb .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Ne9vQmHJmWEvS2Fb .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Ne9vQmHJmWEvS2Fb .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Ne9vQmHJmWEvS2Fb :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} AI 服务
数据层
RAG Service
API 网关
客户端
Web 前端
移动 App
Spring Cloud Gateway
TenantInterceptor

JWT 租户提取
QueryRewriteService

查询改写
HybridRetrievalEngine

混合检索引擎
RerankingService

Re-ranking 重排序
SemanticCacheService

语义缓存
ConfidenceEvaluator

置信度评估
TenantContext

租户上下文
Redis Vector

向量索引
Elasticsearch 8.x

关键词索引
PostgreSQL 15

元数据+租户
Redis Cache

L2 语义缓存
BGE-M3

Embedding
BGE-Reranker

重排序模型
DeepSeek V3

LLM

核心模块实现

1. 混合检索引擎

Why:纯向量检索对"产品型号 X-200 故障代码 E03"这类精确查询效果差,混合检索将向量语义匹配和 ES 关键词匹配的结果融合,准确率从 60% 提升到 85%。

java 复制代码
package com.enterprise.rag.service;

import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
import org.springframework.data.elasticsearch.core.SearchHit;
import org.springframework.data.elasticsearch.core.query.NativeQuery;
import org.springframework.stereotype.Service;

import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.Collectors;

/**
 * 混合检索引擎
 * 并行执行向量检索和关键词检索,使用倒数秩融合(RRF)合并结果
 */
@Slf4j
@Service
public class HybridRetrievalEngine {

    private final VectorStore vectorStore;
    private final ElasticsearchOperations esOperations;
    private final ExecutorService executor = Executors.newFixedThreadPool(2);

    /** RRF 融合参数 k,值越大低排名结果影响越小 */
    private static final int RRF_K = 60;

    /** 向量检索返回数量 */
    private static final int VECTOR_TOP_K = 20;

    /** 关键词检索返回数量 */
    private static final int KEYWORD_TOP_K = 20;

    /** 最终融合返回数量 */
    private static final int FINAL_TOP_K = 10;

    public HybridRetrievalEngine(VectorStore vectorStore,
                                  ElasticsearchOperations esOperations) {
        this.vectorStore = vectorStore;
        this.esOperations = esOperations;
    }

    /**
     * 执行混合检索
     * @param query 用户查询文本
     *param keywords 提取的关键词(用于 ES 检索)
     * @param tenantId 租户 ID(隔离过滤)
     * @return 融合后的文档列表
     */
    public List<RetrievalResult> search(String query, List<String> keywords,
                                        String tenantId) {
        long startTime = System.currentTimeMillis();

        // 并行执行向量检索和关键词检索
        CompletableFuture<List<RetrievalResult>> vectorFuture =
                CompletableFuture.supplyAsync(
                        () -> vectorSearch(query, tenantId), executor);

        CompletableFuture<List<RetrievalResult>> keywordFuture =
                CompletableFuture.supplyAsync(
                        () -> keywordSearch(keywords, tenantId), executor);

        // 等待两个检索完成
        List<RetrievalResult> vectorResults = vectorFuture.join();
        List<RetrievalResult> keywordResults = keywordFuture.join();

        // RRF 融合
        List<RetrievalResult> fused = reciprocalRankFusion(
                vectorResults, keywordResults);

        log.info("混合检索完成 向量结果={} 关键词结果={} 融合结果={} 耗时={}ms",
                vectorResults.size(), keywordResults.size(),
                fused.size(), System.currentTimeMillis() - startTime);

        return fused.stream().limit(FINAL_TOP_K).collect(Collectors.toList());
    }

    /**
     * 向量语义检索
     */
    private List<RetrievalResult> vectorSearch(String query, String tenantId) {
        SearchRequest request = SearchRequest.builder()
                .query(query)
                .topK(VECTOR_TOP_K)
                .similarityThreshold(0.6)
                .filterExpression("tenant_id == '" + tenantId + "'")
                .build();

        List<Document> docs = vectorStore.similaritySearch(request);

        return docs.stream()
                .map(doc -> new RetrievalResult(
                        doc.getId(),
                        doc.getContent(),
                        doc.getMetadata(),
                        doc.getMetadata().containsKey("distance")
                                ? 1.0 - (Double) doc.getMetadata().get("distance")
                                : 0.8,
                        "vector"))
                .collect(Collectors.toList());
    }

    /**
     * Elasticsearch 关键词检索
     */
    private List<RetrievalResult> keywordSearch(List<String> keywords,
                                                 String tenantId) {
        // 构建多字段匹配查询,带租户过滤
        String queryString = String.join(" ", keywords);
        NativeQuery esQuery = NativeQuery.builder()
                .withQuery(q -> q.bool(b -> b
                        .must(m -> m.multiMatch(mm -> mm
                                .query(queryString)
                                .fields("content", "title^2", "keywords^3")
                                .type(co.elastic.clients.elasticsearch._types
                                        .TextQueryType.BestFields)))
                        .filter(f -> f.term(t -> t
                                .field("tenant_id")
                                .value(tenantId)))
                ))
                .withMaxResults(KEYWORD_TOP_K)
                .build();

        List<SearchHit<Map>> hits = esOperations.search(
                esQuery, Map.class).getSearchHits();

        return hits.stream()
                .map(hit -> {
                    Map<String, Object> source = hit.getContent();
                    return new RetrievalResult(
                            (String) source.get("doc_id"),
                            (String) source.get("content"),
                            source,
                            (double) hit.getScore(),
                            "keyword");
                })
                .collect(Collectors.toList());
    }

    /**
     * 倒数秩融合(Reciprocal Rank Fusion)
     * 公式:RRF(d) = Σ 1/(k + rank(d))
     * k 值越大,低排名文档对最终排序的影响越小
     */
    private List<RetrievalResult> reciprocalRankFusion(
            List<RetrievalResult> vectorResults,
            List<RetrievalResult> keywordResults) {

        // 文档 ID → 融合分数
        Map<String, Double> scoreMap = new HashMap<>();
        // 文档 ID → 文档内容(去重用)
        Map<String, RetrievalResult> docMap = new HashMap<>();

        // 向量检索结果按排名计算 RRF 分数
        for (int i = 0; i < vectorResults.size(); i++) {
            RetrievalResult result = vectorResults.get(i);
            double rrfScore = 1.0 / (RRF_K + i + 1);
            scoreMap.merge(result.docId(), rrfScore, Double::sum);
            docMap.putIfAbsent(result.docId(), result);
        }

        // 关键词检索结果按排名计算 RRF 分数
        for (int i = 0; i < keywordResults.size(); i++) {
            RetrievalResult result = keywordResults.get(i);
            double rrfScore = 1.0 / (RRF_K + i + 1);
            scoreMap.merge(result.docId(), rrfScore, Double::sum);
            docMap.putIfAbsent(result.docId(), result);
        }

        // 按融合分数降序排序
        return scoreMap.entrySet().stream()
                .map(entry -> {
                    RetrievalResult original = docMap.get(entry.getKey());
                    return new RetrievalResult(
                            original.docId(),
                            original.content(),
                            original.metadata(),
                            entry.getValue(),
                            "hybrid");
                })
                .sorted(Comparator.comparingDouble(
                        RetrievalResult::score).reversed())
                .collect(Collectors.toList());
    }

    /**
     * 检索结果记录
     */
    public record RetrievalResult(
            String docId,
            String content,
            Map<String, Object> metadata,
            double score,
            String source
    ) {}
}

2. Re-ranking 重排序

Why:混合检索的 RRF 融合只是粗排,无法区分"相关"和"高度相关"。Cross-Encoder 重排序将准确率从 85% 提升到 92%。

java 复制代码
package com.enterprise.rag.service;

import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.stereotype.Service;

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

/**
 * Re-ranking 重排序服务
 * 使用 Cross-Encoder 模型对混合检索结果精排
 */
@Slf4j
@Service
public class RerankingService {

    private final ChatClient chatClient;

    /** 重排序分数阈值,低于此值的结果丢弃 */
    private static final double RELEVANCE_THRESHOLD = 0.5;

    /** 最终返回的结果数量 */
    private static final int FINAL_TOP_N = 5;

    public RerankingService(ChatClient chatClient) {
        this.chatClient = chatClient;
    }

    /**
     * 对检索结果执行重排序
     * @param query 用户原始查询
     * @param results 混合检索的候选结果
     * @return 重排序后的结果列表
     */
    public List<RerankedResult> rerank(String query,
                                       List<HybridRetrievalEngine.RetrievalResult> results) {
        if (results.isEmpty()) {
            return Collections.emptyList();
        }

        long startTime = System.currentTimeMillis();

        // 构建重排序提示词,让 LLM 对每个结果打分
        String documentsText = buildDocumentsText(results);
        String rerankPrompt = buildRerankPrompt(query, documentsText);

        // 调用 LLM 执行重排序评分
        String response = chatClient.prompt()
                .user(rerankPrompt)
                .call()
                .content();

        // 解析评分结果
        Map<String, Double> scores = parseRerankScores(response);

        // 组装重排序结果,过滤低分项
        List<RerankedResult> reranked = results.stream()
                .map(result -> {
                    Double rerankScore = scores.getOrDefault(
                            result.docId(), result.score());
                    return new RerankedResult(
                            result.docId(),
                            result.content(),
                            result.metadata(),
                            rerankScore,
                            result.source(),
                            rerankScore >= RELEVANCE_THRESHOLD
                    );
                })
                .filter(RerankedResult::relevant)
                .sorted(Comparator.comparingDouble(
                        RerankedResult::rerankScore).reversed())
                .limit(FINAL_TOP_N)
                .collect(Collectors.toList());

        log.info("Re-ranking完成 输入={} 输出={} 过滤低分={} 耗时={}ms",
                results.size(), reranked.size(),
                results.size() - reranked.size(),
                System.currentTimeMillis() - startTime);

        return reranked;
    }

    /**
     * 构建文档文本(用于重排序提示词)
     */
    private String buildDocumentsText(
            List<HybridRetrievalEngine.RetrievalResult> results) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < results.size(); i++) {
            HybridRetrievalEngine.RetrievalResult result = results.get(i);
            sb.append(String.format("[DOC_ID: %s]\n%s\n\n",
                    result.docId(),
                    result.content().length() > 500
                            ? result.content().substring(0, 500) + "..."
                            : result.content()));
        }
        return sb.toString();
    }

    /**
     * 构建重排序提示词
     */
    private String buildRerankPrompt(String query, String documentsText) {
        return String.format("""
                你是一个文档相关性评估专家。请对以下文档与查询的相关性打分。

                查询:%s

                文档列表:
                %s

                请按以下格式输出每个文档的相关性分数(0.0-1.0):
                DOC_ID|分数
                每行一个,不要输出其他内容。分数标准:
                - 0.9-1.0:完全匹配,直接回答了查询
                - 0.7-0.89:高度相关,包含关键信息
                - 0.5-0.69:部分相关,包含部分有用信息
                - 0.3-0.49:弱相关,仅间接提及
                - 0.0-0.29:不相关
                """, query, documentsText);
    }

    /**
     * 解析 LLM 返回的评分结果
     */
    private Map<String, Double> parseRerankScores(String response) {
        Map<String, Double> scores = new HashMap<>();
        String[] lines = response.trim().split("\n");
        for (String line : lines) {
            String[] parts = line.trim().split("\\|");
            if (parts.length == 2) {
                try {
                    String docId = parts[0].trim();
                    double score = Double.parseDouble(parts[1].trim());
                    scores.put(docId, score);
                } catch (NumberFormatException e) {
                    log.warn("解析重排序分数失败: {}", line);
                }
            }
        }
        return scores;
    }

    /**
     * 重排序结果记录
     */
    public record RerankedResult(
            String docId,
            String content,
            Map<String, Object> metadata,
            double rerankScore,
            String source,
            boolean relevant
    ) {}
}

3. 查询改写与扩展

Why:用户查询"X-200 怎么老报警"需要改写为"产品 X-200 故障报警 原因 解决方案",扩展后检索召回率提升 25%。

java 复制代码
package com.enterprise.rag.service;

import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;

import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * 查询改写服务
 * 将用户口语化查询改写为结构化查询,提取关键词用于混合检索
 */
@Slf4j
@Service
public class QueryRewriteService {

    private final ChatClient chatClient;

    /** 提取关键词的正则模式 */
    private static final Pattern KEYWORD_PATTERN =
            Pattern.compile("关键词[::]\\s*(.+?)(?:\n|$)");

    public QueryRewriteService(ChatClient chatClient) {
        this.chatClient = chatClient;
    }

    /**
     * 改写查询并提取关键词
     * @param originalQuery 用户原始查询
     * @return 改写结果,包含扩展查询和关键词列表
     */
    public RewriteResult rewrite(String originalQuery) {
        long startTime = System.currentTimeMillis();

        String prompt = String.format("""
                请将以下用户查询改写为更适合检索的结构化查询。

                原始查询:%s

                请按以下格式输出:
                改写查询:[改写后的查询,保留专业术语和型号,补充同义词]
                关键词:[用空格分隔的关键词列表,用于关键词检索]

                改写规则:
                1. 保留产品型号和故障代码等精确标识
                2. 将口语化表达转为专业术语(如"老报警"→"故障报警")
                3. 补充可能的同义词和相关术语
                4. 关键词应包含原始查询中的核心实体
                """, originalQuery);

        String response = chatClient.prompt()
                .user(prompt)
                .call()
                .content();

        // 解析改写结果
        String rewrittenQuery = originalQuery;
        List<String> keywords = new ArrayList<>();

        String[] lines = response.trim().split("\n");
        for (String line : lines) {
            if (line.startsWith("改写查询:") || line.startsWith("改写查询:")) {
                rewrittenQuery = line.substring(5).trim();
            }
            Matcher matcher = KEYWORD_PATTERN.matcher(line);
            if (matcher.find()) {
                keywords = Arrays.stream(matcher.group(1).trim().split("\\s+"))
                        .filter(kw -> !kw.isEmpty())
                        .collect(Collectors.toList());
            }
        }

        // 如果解析失败,使用原始查询
        if (keywords.isEmpty()) {
            keywords = Arrays.stream(originalQuery.split("[,。?\\s]+"))
                    .filter(kw -> kw.length() >= 2)
                    .collect(Collectors.toList());
        }

        log.info("查询改写 原始=[{}] 改写=[{}] 关键词={} 耗时={}ms",
                originalQuery, rewrittenQuery, keywords,
                System.currentTimeMillis() - startTime);

        return new RewriteResult(originalQuery, rewrittenQuery, keywords);
    }

    /**
     * 查询改写结果
     */
    public record RewriteResult(
            String originalQuery,
            String rewrittenQuery,
            List<String> keywords
    ) {}
}

4. 语义缓存

Why:72% 的查询是重复或语义相似的,语义缓存命中后延迟从 450ms 降至 5ms,LLM 调用成本降低 72%。

java 复制代码
package com.enterprise.rag.service;

import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.data.redis.core.StringRedisTemplate;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;

import org.springframework.stereotype.Service;

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

/**
 * 语义缓存服务
 * L1 Caffeine 本地缓存(精确匹配)+ L2 Redis 语义缓存(向量相似度匹配)
 */
@Slf4j
@Service
public class SemanticCacheService {

    private final Cache<String, String> l1Cache;
    private final StringRedisTemplate redisTemplate;
    private final VectorStore cacheVectorStore;
    private final ObjectMapper objectMapper;

    /** 语义相似度阈值,高于此值视为命中 */
    private static final double SEMANTIC_THRESHOLD = 0.92;

    /** L1 缓存最大容量 */
    private static final int L1_MAX_SIZE = 10000;

    /** L1 缓存过期时间(分钟) */
    private static final int L1_TTL_MINUTES = 5;

    /** L2 缓存过期时间(小时) */
    private static final int L2_TTL_HOURS = 1;

    /** Redis 缓存 key 前缀 */
    private static final String CACHE_KEY_PREFIX = "rag:cache:";

    /** 缓存向量索引名称 */
    private static final String CACHE_INDEX_NAME = "cache_vector_index";

    public SemanticCacheService(StringRedisTemplate redisTemplate,
                                VectorStore cacheVectorStore,
                                ObjectMapper objectMapper) {
        this.redisTemplate = redisTemplate;
        this.cacheVectorStore = cacheVectorStore;
        this.objectMapper = objectMapper;
        this.l1Cache = Caffeine.newBuilder()
                .maximumSize(L1_MAX_SIZE)
                .expireAfterWrite(L1_TTL_MINUTES, TimeUnit.MINUTES)
                .build();
    }

    /**
     * 查询缓存
     * @param query 用户查询
     * @param tenantId 租户 ID
     * @return 缓存的答案,未命中返回 null
     */
    public String get(String query, String tenantId) {
        // L1 精确匹配
        String l1Key = buildL1Key(query, tenantId);
        String cached = l1Cache.getIfPresent(l1Key);
        if (cached != null) {
            log.debug("L1缓存命中 query={}", query);
            return cached;
        }

        // L2 语义匹配
        String l2Result = semanticSearch(query, tenantId);
        if (l2Result != null) {
            // 回填 L1 缓存
            l1Cache.put(l1Key, l2Result);
            log.debug("L2缓存命中 query={}", query);
            return l2Result;
        }

        return null;
    }

    /**
     * 写入缓存
     * @param query 用户查询
     * @param tenantId 租户 ID
     * @param answer LLM 生成的答案
     */
    public void put(String query, String tenantId, String answer) {
        // 写入 L1
        String l1Key = buildL1Key(query, tenantId);
        l1Cache.put(l1Key, answer);

        // 写入 L2 Redis(精确 key)
        String l2Key = CACHE_KEY_PREFIX + tenantId + ":" + UUID.randomUUID();
        try {
            CacheEntry entry = new CacheEntry(query, answer, tenantId);
            redisTemplate.opsForValue().set(l2Key,
                    objectMapper.writeValueAsString(entry),
                    L2_TTL_HOURS, TimeUnit.HOURS);

            // 写入向量索引(用于语义匹配)
            Document cacheDoc = new Document(
                    l2Key,
                    query,
                    Map.of("tenant_id", tenantId,
                           "cache_key", l2Key,
                           "type", "cache_query"));
            cacheVectorStore.add(List.of(cacheDoc));

            log.debug("缓存写入成功 query={}", query);
        } catch (Exception e) {
            log.error("缓存写入失败 query={}", query, e);
        }
    }

    /**
     * 文档更新时清除相关缓存
     * @param tenantId 租户 ID
     * @param docIds 更新的文档 ID 列表
     */
    public void invalidateByDocIds(String tenantId, List<String> docIds) {
        // 清除 L1 中该租户的所有缓存
        l1Cache.asMap().keySet()
                .removeIf(key -> key.startsWith(tenantId + ":"));

        // 清除 L2 中该租户的缓存(按前缀扫描删除)
        Set<String> keys = redisTemplate.keys(
                CACHE_KEY_PREFIX + tenantId + ":*");
        if (keys != null && !keys.isEmpty()) {
            redisTemplate.delete(keys);
        }

        log.info("缓存清除 租户={} 文档数={}", tenantId, docIds.size());
    }

    /**
     * L2 语义搜索
     */
    private String semanticSearch(String query, String tenantId) {
        try {
            SearchRequest request = SearchRequest.builder()
                    .query(query)
                    .topK(3)
                    .similarityThreshold(SEMANTIC_THRESHOLD)
                    .filterExpression("tenant_id == '" + tenantId
                            + "' && type == 'cache_query'")
                    .build();

            List<Document> results = cacheVectorStore.similaritySearch(request);
            if (results.isEmpty()) {
                return null;
            }

            // 取最相似的缓存项
            String cacheKey = (String) results.get(0)
                    .getMetadata().get("cache_key");
            String json = redisTemplate.opsForValue().get(cacheKey);
            if (json == null) {
                return null;
            }

            CacheEntry entry = objectMapper.readValue(json, CacheEntry.class);
            return entry.answer();
        } catch (Exception e) {
            log.warn("语义缓存查询异常", e);
            return null;
        }
    }

    private String buildL1Key(String query, String tenantId) {
        return tenantId + ":" + query.hashCode();
    }

    /**
     * 缓存条目
     */
    public record CacheEntry(String query, String answer, String tenantId) {}
}

5. 多租户安全隔离

Why:生产环境曾发生租户 A 查到租户 B 文档的数据泄露事故,三层隔离是安全底线。

java 复制代码
package com.enterprise.rag.tenant;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

/**
 * 租户上下文(ThreadLocal)
 * 在请求线程内传递租户信息,确保数据隔离
 */
@Component
public class TenantContext {

    private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();
    private static final ThreadLocal<IsolationLevel> ISOLATION_LEVEL =
            new ThreadLocal<>();

    /** 隔离级别枚举 */
    public enum IsolationLevel {
        /** 独立数据库,最高安全,成本最高 */
        DATABASE,
        /** 共享数据库,独立 Schema */
        SCHEMA,
        /** 共享表,filterExpression 过滤(当前方案) */
        ROW
    }

    /**
     * 设置当前租户 ID
     */
    public void setTenantId(String tenantId) {
        // 校验租户 ID 格式,防止注入
        if (tenantId == null
                || !tenantId.matches("^[a-zA-Z0-9_-]{1,64}$")) {
            throw new IllegalArgumentException("非法租户 ID: " + tenantId);
        }
        CURRENT_TENANT.set(tenantId);
    }

    /**
     * 获取当前租户 ID
     */
    public String getCurrentTenantId() {
        String tenantId = CURRENT_TENANT.get();
        if (tenantId == null) {
            throw new IllegalStateException("未设置租户 ID,请求被拒绝");
        }
        return tenantId;
    }

    /**
     * 设置隔离级别
     */
    public void setIsolationLevel(IsolationLevel level) {
        ISOLATION_LEVEL.set(level);
    }

    /**
     * 获取隔离级别
     */
    public IsolationLevel getIsolationLevel() {
        IsolationLevel level = ISOLATION_LEVEL.get();
        return level != null ? level : IsolationLevel.ROW;
    }

    /**
     * 清除上下文(请求结束后必须调用)
     */
    public void clear() {
        CURRENT_TENANT.remove();
        ISOLATION_LEVEL.remove();
    }
}
java 复制代码
package com.enterprise.rag.tenant;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

/**
 * 租户拦截器
 * 从 JWT Token 提取租户 ID,设置到 TenantContext
 */
@Slf4j
@Component
public class TenantInterceptor implements HandlerInterceptor {

    private final TenantContext tenantContext;

    @Value("${rag.security.jwt-secret}")
    private String jwtSecret;

    public TenantInterceptor(TenantContext tenantContext) {
        this.tenantContext = tenantContext;
    }

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response,
                             Object handler) {
        String token = request.getHeader("Authorization");

        if (token != null && token.startsWith("Bearer ")) {
            try {
                String jwt = token.substring(7);
                Claims claims = Jwts.parser()
                        .setSigningKey(jwtSecret)
                        .build()
                        .parseClaimsJws(jwt)
                        .getBody();

                String tenantId = claims.get("tenant_id", String.class);
                String isolationLevel = claims.get(
                        "isolation_level", String.class);

                tenantContext.setTenantId(tenantId);
                if (isolationLevel != null) {
                    tenantContext.setIsolationLevel(
                            TenantContext.IsolationLevel.valueOf(
                                    isolationLevel));
                }

                log.debug("租户上下文设置 tenantId={} level={}",
                        tenantId, isolationLevel);
            } catch (Exception e) {
                log.error("JWT 解析失败", e);
                response.setStatus(401);
                return false;
            }
        } else {
            // 无 Token 的请求也必须拒绝
            log.warn("请求缺少 Authorization 头 path={}",
                    request.getRequestURI());
            response.setStatus(401);
            return false;
        }

        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request,
                                 HttpServletResponse response,
                                 Object handler,
                                 Exception ex) {
        // 请求结束必须清除,防止线程复用导致租户串号
        tenantContext.clear();
    }
}
java 复制代码
package com.enterprise.rag.tenant;

import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.data.elasticsearch.core.query.NativeQuery;
import org.springframework.stereotype.Component;

import co.elastic.clients.elasticsearch._types.query_dsl.Query;

/**
 * 多租户隔离查询构建器
 * 在向量检索和 ES 检索两个层面同时实现租户隔离
 */
@Slf4j
@Component
public class TenantIsolationBuilder {

    private final TenantContext tenantContext;

    public TenantIsolationBuilder(TenantContext tenantContext) {
        this.tenantContext = tenantContext;
    }

    /**
     * 为向量检索构建带租户隔离的 SearchRequest
     * 使用 filterExpression 确保只检索当前租户的文档
     */
    public SearchRequest buildVectorSearchRequest(String query, int topK) {
        String tenantId = tenantContext.getCurrentTenantId();

        // filterExpression 拼接必须使用单引号包裹字符串值
        // 防止表达式语法错误和注入风险
        String filterExpr = "tenant_id == '" + tenantId + "'";

        log.debug("向量检索租户隔离 tenantId={} filter={}", tenantId, filterExpr);

        return SearchRequest.builder()
                .query(query)
                .topK(topK)
                .similarityThreshold(0.6)
                .filterExpression(filterExpr)
                .build();
    }

    /**
     * 为 ES 检索构建带租户隔离的 bool filter
     * 必须与向量检索使用相同的租户过滤条件
     */
    public Query buildEsTenantFilter() {
        String tenantId = tenantContext.getCurrentTenantId();

        log.debug("ES检索租户隔离 tenantId={}", tenantId);

        return Query.of(q -> q.term(t -> t
                .field("tenant_id")
                .value(tenantId)));
    }

    /**
     * 记录安全审计日志
     * 每次检索操作都记录租户信息,用于安全审计
     */
    public void auditLog(String operation, String query, int resultCount) {
        String tenantId = tenantContext.getCurrentTenantId();
        log.info("安全审计 租户={} 操作={} 查询摘要={} 结果数={}",
                tenantId, operation,
                query.length() > 50
                        ? query.substring(0, 50) + "..." : query,
                resultCount);
    }
}

6. 文档处理 Pipeline

Why:文档质量决定 RAG 上限,分块策略直接影响检索效果。固定 512 token 分块在表格和代码场景效果差,递归分块+语义分块将检索准确率提升 12%。

java 复制代码
package com.enterprise.rag.pipeline;

import lombok.extern.slf4j.Slf4j;
import org.apache.tika.Tika;
import org.apache.tika.exception.TikaException;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.*;
import java.nio.file.*;
import java.util.*;
import java.util.stream.Collectors;

/**
 * 文档处理 Pipeline
 * 解析 → 递归分块 → 元数据提取 → 向量化 + 双写索引
 */
@Slf4j
@Service
public class DocumentPipeline {

    private final Tika tika;
    private final VectorStore vectorStore;
    private final ElasticsearchIndexer esIndexer;
    private final RabbitTemplate rabbitTemplate;

    /** 递归分块的目标大小(字符数) */
    @Value("${rag.chunk.target-size:800}")
    private int chunkTargetSize;

    /** 分块重叠大小(字符数) */
    @Value("${rag.chunk.overlap-size:200}")
    private int chunkOverlapSize;

    /** 单个分块最大大小(字符数) */
    @Value("${rag.chunk.max-size:1200}")
    private int chunkMaxSize;

    public DocumentPipeline(VectorStore vectorStore,
                            ElasticsearchIndexer esIndexer,
                            RabbitTemplate rabbitTemplate) {
        this.tika = new Tika();
        this.vectorStore = vectorStore;
        this.esIndexer = esIndexer;
        this.rabbitTemplate = rabbitTemplate;
    }

    /**
     * 提交文档处理任务(异步)
     */
    public String submitDocument(MultipartFile file, String tenantId) {
        String docId = UUID.randomUUID().toString();

        PipelineMessage message = new PipelineMessage(
                docId,
                file.getOriginalFilename(),
                tenantId
        );

        // 先保存文件到临时目录
        try {
            Path tempFile = Files.createTempFile("rag_upload_", ".tmp");
            file.transferTo(tempFile.toFile());
            message.setFilePath(tempFile.toString());
        } catch (IOException e) {
            throw new RuntimeException("文件保存失败", e);
        }

        // 发送到 RabbitMQ 异步处理
        rabbitTemplate.convertAndSend("document.processing", message);
        log.info("文档处理任务提交 docId={} file={}", docId,
                file.getOriginalFilename());

        return docId;
    }

    /**
     * 异步消费文档处理消息
     */
    @RabbitListener(queues = "document.processing")
    public void processDocument(PipelineMessage message) {
        long startTime = System.currentTimeMillis();
        log.info("开始处理文档 docId={}", message.getDocId());

        try {
            // 步骤 1:Apache Tika 解析
            String content = parseDocument(message.getFilePath());
            if (content == null || content.isBlank()) {
                log.warn("文档内容为空 docId={}", message.getDocId());
                return;
            }

            // 步骤 2:递归分块
            List<String> chunks = recursiveChunk(content);
            log.info("文档分块完成 docId={} 分块数={}", message.getDocId(),
                    chunks.size());

            // 步骤 3:构建带元数据的 Document 列表
            List<Document> documents = buildDocuments(
                    chunks, message);

            // 步骤 4:向量化 + 写入 Redis Vector
            vectorStore.add(documents);

            // 步骤 5:双写 Elasticsearch
            esIndexer.batchIndex(documents);

            // 清理临时文件
            Files.deleteIfExists(Path.of(message.getFilePath()));

            log.info("文档处理完成 docId={} 分块数={} 耗时={}ms",
                    message.getDocId(), chunks.size(),
                    System.currentTimeMillis() - startTime);

        } catch (Exception e) {
            log.error("文档处理失败 docId={}", message.getDocId(), e);
        }
    }

    /**
     * 使用 Apache Tika 解析文档
     * 支持 PDF/Word/Excel/PPT 等格式
     */
    private String parseDocument(String filePath) {
        try (InputStream is = Files.newInputStream(Path.of(filePath))) {
            return tika.parseToString(is);
        } catch (IOException | TikaException e) {
            log.error("文档解析失败 file={}", filePath, e);
            return null;
        }
    }

    /**
     * 递归分块(Recursive Character Text Splitter)
     * 按分隔符层级递归切分,优先在段落边界分割
     */
    private List<String> recursiveChunk(String content) {
        // 分隔符优先级:段落 > 句号 > 换行 > 空格
        List<String> separators = List.of("\n\n", "。", "!", "?",
                "\n", " ", "");

        List<String> chunks = new ArrayList<>();
        recursiveSplit(content, separators, 0, chunks);

        // 过滤过短的分块
        return chunks.stream()
                .filter(chunk -> chunk.trim().length() >= 50)
                .collect(Collectors.toList());
    }

    /**
     * 递归分割实现
     */
    private void recursiveSplit(String text, List<String> separators,
                                int separatorIndex, List<String> chunks) {
        if (text.length() <= chunkTargetSize) {
            if (text.trim().length() >= 50) {
                chunks.add(text.trim());
            }
            return;
        }

        if (separatorIndex >= separators.size()) {
            // 无更多分隔符,强制按最大大小切分
            for (int i = 0; i < text.length(); i += chunkMaxSize) {
                String chunk = text.substring(i,
                        Math.min(i + chunkMaxSize, text.length()));
                if (chunk.trim().length() >= 50) {
                    chunks.add(chunk.trim());
                }
            }
            return;
        }

        String separator = separators.get(separatorIndex);
        String[] parts = text.split(separator);

        StringBuilder currentChunk = new StringBuilder();
        for (String part : parts) {
            if (currentChunk.length() + part.length() + separator.length()
                    > chunkTargetSize && currentChunk.length() > 0) {
                // 当前分块已满,保存并开始新分块
                chunks.add(currentChunk.toString().trim());
                // 重叠:保留尾部内容到新分块
                String overlap = getOverlap(currentChunk.toString());
                currentChunk = new StringBuilder(overlap);
            }
            currentChunk.append(part).append(separator);
        }

        if (currentChunk.length() >= 50) {
            chunks.add(currentChunk.toString().trim());
        }
    }

    /**
     * 获取文本尾部的重叠部分
     */
    private String getOverlap(String text) {
        if (text.length() <= chunkOverlapSize) {
            return text;
        }
        return text.substring(text.length() - chunkOverlapSize);
    }

    /**
     * 构建带元数据的 Document 列表
     */
    private List<Document> buildDocuments(List<String> chunks,
                                          PipelineMessage message) {
        List<Document> documents = new ArrayList<>();
        for (int i = 0; i < chunks.size(); i++) {
            Map<String, Object> metadata = new HashMap<>();
            metadata.put("tenant_id", message.getTenantId());
            metadata.put("source", message.getFileName());
            metadata.put("chunk_index", i);
            metadata.put("total_chunks", chunks.size());
            metadata.put("doc_id", message.getDocId());

            Document doc = new Document(
                    message.getDocId() + "_chunk_" + i,
                    chunks.get(i),
                    metadata
            );
            documents.add(doc);
        }
        return documents;
    }

    /**
     * Pipeline 消息
     */
    public static class PipelineMessage {
        private String docId;
        private String fileName;
        private String tenantId;
        private String filePath;

        public PipelineMessage(String docId, String fileName,
                               String tenantId) {
            this.docId = docId;
            this.fileName = fileName;
            this.tenantId = tenantId;
        }

        public String getDocId() { return docId; }
        public String getFileName() { return fileName; }
        public String getTenantId() { return tenantId; }
        public String getFilePath() { return filePath; }
        public void setFilePath(String filePath) {
            this.filePath = filePath;
        }
    }
}

7. 置信度评估与兜底策略

Why:RAG 不是万能的,当检索结果不足或置信度低时,必须诚实回答"无法确定"而非编造答案。

java 复制代码
package com.enterprise.rag.service;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.util.List;

/**
 * 置信度评估服务
 * 基于检索结果的相关性分数计算置信度,低置信度触发兜底策略
 */
@Slf4j
@Service
public class ConfidenceEvaluator {

    /** 置信度阈值:高于此值正常回答 */
    @Value("${rag.confidence.high-threshold:0.7}")
    private double highThreshold;

    /** 置信度阈值:低于此值触发兜底 */
    @Value("${rag.confidence.low-threshold:0.4}")
    private double lowThreshold;

    /**
     * 评估检索结果的置信度
     * @param rerankedResults 重排序后的结果列表
     * @return 置信度评估结果
     */
    public ConfidenceResult evaluate(
            List<RerankingService.RerankedResult> rerankedResults) {

        if (rerankedResults.isEmpty()) {
            log.warn("检索结果为空,置信度极低");
            return new ConfidenceResult(0.0, FallbackStrategy.NO_RESULT,
                    "未检索到任何相关文档");
        }

        // 基于重排序分数计算综合置信度
        // 权重:Top1 结果 60%,Top3 均值 30%,结果数量 10%
        double top1Score = rerankedResults.get(0).rerankScore();
        double top3Avg = rerankedResults.stream()
                .limit(3)
                .mapToDouble(RerankingService.RerankedResult::rerankScore)
                .average()
                .orElse(0.0);
        double quantityBonus = Math.min(
                rerankedResults.size() / 5.0, 1.0);

        double confidence = top1Score * 0.6
                + top3Avg * 0.3
                + quantityBonus * 0.1;

        // 确定兜底策略
        FallbackStrategy strategy;
        String reason;

        if (confidence >= highThreshold) {
            strategy = FallbackStrategy.NONE;
            reason = "置信度充足,正常回答";
        } else if (confidence >= lowThreshold) {
            strategy = FallbackStrategy.EXPAND_SEARCH;
            reason = "置信度中等,建议扩展检索范围";
        } else {
            strategy = FallbackStrategy.DECLINE;
            reason = "置信度不足,应明确告知用户无法确定";
        }

        log.info("置信度评估 score={} strategy={} reason={}",
                String.format("%.2f", confidence), strategy, reason);

        return new ConfidenceResult(confidence, strategy, reason);
    }

    /**
     * 根据兜底策略生成回答前缀
     */
    public String buildAnswerPrefix(ConfidenceResult result) {
        return switch (result.strategy()) {
            case NONE -> "";
            case EXPAND_SEARCH -> "根据现有资料初步判断(信息可能不完整):";
            case DECLINE -> "抱歉,现有知识库中未找到足够可靠的信息来回答该问题。";
            case NO_RESULT -> "抱歉,未检索到与您问题相关的文档。";
        };
    }

    /** 兜底策略枚举 */
    public enum FallbackStrategy {
        /** 无需兜底,正常回答 */
        NONE,
        /** 扩展检索范围后回答 */
        EXPAND_SEARCH,
        /** 拒绝回答,明确告知不确定 */
        DECLINE,
        /** 无检索结果 */
        NO_RESULT
    }

    /**
     * 置信度评估结果
     */
    public record ConfidenceResult(
            double confidence,
            FallbackStrategy strategy,
            String reason
    ) {}
}

架构决策:LLM 选型------GPT-4o-mini vs DeepSeek V3 vs 本地 Qwen2.5-72B

维度 GPT-4o-mini DeepSeek V3 本地 Qwen2.5-72B GPT-4o
回答质量 良好,简单查询足够 优秀,中文理解突出 良好,专业领域需微调 优秀,复杂推理最佳
延迟 200-500ms 300-600ms 500-1500ms(GPU) 500-1200ms
月度成本(5万查询/天) ~¥3,000 ~¥1,500 ~¥5,000(GPU 租赁) ~¥30,000
中文能力 中等,专业术语偶有偏差 优秀,原生中文训练 优秀,可定制领域词表 良好
数据安全 数据出境,需合规评估 数据出境,需合规评估 数据不出域,最高安全 数据出境,需合规评估
可替代性 低,绑定 OpenAI 生态 中,API 兼容 OpenAI 格式 高,完全自主可控 低,绑定 OpenAI 生态

决策结论 :本项目选择 DeepSeek V3 作为主模型,GPT-4o-mini 作为备选。原因:中文理解能力突出,成本仅为 GPT-4o-mini 的 50%,API 兼容 OpenAI 格式切换成本低。不选本地 Qwen2.5-72B 的原因:72B 模型需要 2×A100 GPU,月租赁成本 ¥5,000+,且运维复杂度高,当前阶段不值得。不选 GPT-4o 的原因:月成本 ¥30,000,ROI 不合理。适用边界:金融/政务等数据合规要求严格的场景必须选本地部署。

生产上线流程

灰度发布策略

阶段 流量比例 持续时间 观察指标 回滚条件
阶段 1 5% 2 小时 错误率 < 1%,P95 < 200ms 错误率 > 5% 或 P95 > 500ms
阶段 2 20% 4 小时 准确率 > 85%,缓存命中率 > 50% 准确率 < 80% 或租户隔离异常
阶段 3 50% 8 小时 全指标达标,无安全事件 任何租户数据泄露
阶段 4 100% 持续 全指标稳定 P95 > 300ms 持续 5 分钟

回滚方案

回滚场景 触发条件 操作步骤 预计恢复时间
服务异常 错误率 > 5% 或健康检查失败 1. 切换流量到旧版本服务 2. 保留新版本实例用于排查 3. 确认旧版本指标正常 2-5 分钟
数据异常 检索结果明显错误或租户隔离失效 1. 立即停止新版本流量 2. 回滚向量索引到上一版本快照 3. 清除语义缓存 4. 验证数据一致性 10-30 分钟
模型异常 LLM 返回异常内容或延迟飙升 1. 切换到备选 LLM(DeepSeek → GPT-4o-mini) 2. 增大缓存 TTL 降低 LLM 调用量 3. 通知模型供应商 1-3 分钟

监控看板

指标 基线值 告警阈值 监控工具
QPS 50-80 低于 10 或超过 200 Prometheus + Grafana
P95 延迟 145ms > 300ms(Warning)> 500ms(Critical) Prometheus + Grafana
缓存命中率 72% < 50%(Warning)< 30%(Critical) 自定义 Metrics
检索准确率 92% < 85%(Warning)< 75%(Critical) 人工抽检 + 自动化评测
错误率 0.2% > 1%(Warning)> 5%(Critical) Prometheus + Grafana

踩坑指南

坑1:混合检索的 RRF 参数 k 值设置不当导致排序失真

现象:混合检索上线后,部分查询返回的结果明显不相关,Top3 中出现低质量文档。RRF 融合后的排序与直觉不符,关键词检索的 Top1 结果被排到了第 5 位。

根因:RRF 公式中 k 值设为 1,导致排名靠前的文档获得过高的分数优势。当向量检索和关键词检索结果差异较大时,k=1 使得某一通道的 Top1 结果几乎垄断最终排序,另一通道的高质量结果被压制。

解决:将 k 值从 1 调整为 60(业界推荐值)。k=60 时,排名差异的影响被平滑,两个通道的结果能更均衡地融合。实测 k=60 时 NDCG@10 比 k=1 提升 18%。k 值不是越大越好,k 超过 100 后融合效果趋于平缓,且低质量结果更容易混入 Top-K。

坑2:语义缓存阈值 0.95 过高导致命中率仅 15%

现象:语义缓存上线后命中率仅 15%,远低于预期的 70%。大量语义相似的查询无法命中缓存,LLM 调用量没有显著下降。

根因:0.95 的相似度阈值过于严格。用户查询"X-200 故障怎么处理"和"X-200 出了故障怎么办"语义完全相同,但 Embedding 向量余弦相似度仅 0.91,低于 0.95 阈值被判定为未命中。中文查询的表述多样性进一步放大了这个问题。

解决:将语义缓存阈值从 0.95 降至 0.92。同时引入查询归一化预处理:去除语气词、统一标点、繁简转换。调整后命中率从 15% 提升到 72%。阈值不能低于 0.88,否则会出现"答非所问"的缓存命中------相似但不同含义的查询返回了错误答案。

坑3:filterExpression 拼接错误导致多租户数据泄露

现象:租户 A 的用户查询"设备维护流程"时,返回了租户 B 的设备维护文档。安全审计发现 3 次跨租户数据泄露事件。

根因 :filterExpression 拼接时缺少单引号,tenant_id == tenant_a 被解析为变量引用而非字符串比较,导致过滤条件失效。更深层原因是 ES 检索通道完全遗漏了租户过滤,只依赖向量检索的 filterExpression,而 ES 的 bool query 中没有添加 tenant_id 条件。

解决 :三重防护。第一,filterExpression 必须使用单引号包裹字符串值:tenant_id == '" + tenantId + "'"。第二,在 TenantIsolationBuilder 中统一构建向量检索和 ES 检索的租户过滤,避免分散拼接。第三,增加集成测试:每个检索接口必须验证只返回当前租户的文档,CI 流水线中自动执行跨租户隔离测试。

效果验证

指标 实施前 实施后 提升
检索准确率 60% 92% +53%
P95 延迟 450ms 145ms -68%
缓存命中率 0% 72% -
LLM 调用成本 ¥8,000/月 ¥2,200/月 -72%
多租户安全事件 2次/月 0次/月 -100%

上述数据基于 2025 年 10 月生产环境实测,测试集 1000 条查询,覆盖事实型/操作型/对比型三类。准确率由人工标注评估,延迟由 Prometheus 采集 P95 值。

总结

  1. 混合检索 + Re-ranking 是 RAG 准确率提升的核心组合,纯向量检索的天花板约 70%
  2. 语义缓存是成本优化的第一优先级,72% 命中率意味着 LLM 调用量减少 72%
  3. 多租户隔离必须在向量检索和关键词检索两个层面同时实现,缺一不可
  4. 置信度评估和兜底策略是生产级 RAG 的安全底线,"不知道"比"编造"好
  5. RRF 融合的 k 参数和语义缓存阈值是需要根据业务数据调优的关键参数

适用边界

适用场景:企业内部知识库(文档量 10-100 万)、客服智能问答(需配合人工兜底)、技术文档检索(结构化文档效果最佳)、制造业/金融等对精确匹配要求高的领域。

不适用场景:实时数据分析(RAG 不擅长数值计算)、多轮复杂推理(需 Agent 架构,见第 026 篇)、文档量超过 500 万(需迁移到 Milvus)、对外公开服务(需增加内容审核和安全过滤)。

已知局限:Redis Vector 在文档量超过 500 万时性能下降,需迁移到 Milvus;Re-ranking 依赖 LLM 评分,增加 100-300ms 延迟;语义缓存阈值需根据业务数据调优,无法一次到位;ROW 级别隔离的安全性不如独立索引,金融场景建议 SCHEMA 或 DATABASE 级别。


专栏导航: