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

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

系统架构设计

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%。

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%。

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%。

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%。

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 文档的数据泄露事故,三层隔离是安全底线。

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();
    }
}
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();
    }
}
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%。

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 不是万能的,当检索结果不足或置信度低时,必须诚实回答"无法确定"而非编造答案。

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-miniDeepSeek V3本地 Qwen2.5-72BGPT-4o
回答质量良好,简单查询足够优秀,中文理解突出良好,专业领域需微调优秀,复杂推理最佳
延迟200-500ms300-600ms500-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 不合理。适用边界:金融/政务等数据合规要求严格的场景必须选本地部署。

生产上线流程

灰度发布策略

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

监控看板

指标基线值告警阈值监控工具
QPS50-80低于 10 或超过 200Prometheus + 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 延迟450ms145ms-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 级别。


专栏导航:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

行者·全栈架构师

如果您觉得文章对你有用请点个赞

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值