摘要: 本文基于我在某制造企业 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。
系统架构设计
核心模块实现
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-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 值。
总结
- 混合检索 + Re-ranking 是 RAG 准确率提升的核心组合,纯向量检索的天花板约 70%
- 语义缓存是成本优化的第一优先级,72% 命中率意味着 LLM 调用量减少 72%
- 多租户隔离必须在向量检索和关键词检索两个层面同时实现,缺一不可
- 置信度评估和兜底策略是生产级 RAG 的安全底线,"不知道"比"编造"好
- RRF 融合的 k 参数和语义缓存阈值是需要根据业务数据调优的关键参数
适用边界
适用场景:企业内部知识库(文档量 10-100 万)、客服智能问答(需配合人工兜底)、技术文档检索(结构化文档效果最佳)、制造业/金融等对精确匹配要求高的领域。
不适用场景:实时数据分析(RAG 不擅长数值计算)、多轮复杂推理(需 Agent 架构,见第 026 篇)、文档量超过 500 万(需迁移到 Milvus)、对外公开服务(需增加内容审核和安全过滤)。
已知局限:Redis Vector 在文档量超过 500 万时性能下降,需迁移到 Milvus;Re-ranking 依赖 LLM 评分,增加 100-300ms 延迟;语义缓存阈值需根据业务数据调优,无法一次到位;ROW 级别隔离的安全性不如独立索引,金融场景建议 SCHEMA 或 DATABASE 级别。
专栏导航:
- 📖 上一篇: 知识库更新策略:增量更新、版本管理、一致性保证
- 📖 下一篇: Agent 架构设计:感知、规划、行动、记忆四层模型
- 🌟 推荐文章:

597

被折叠的 条评论
为什么被折叠?



