RAG检索优化:查询改写、重排序与缓存策略
上一篇我们搭建了基于Milvus的向量数据库基础设施,实现了混合检索(BM25 + 向量)。但Naive RAG在实际业务中会暴露三大痛点:多轮对话检索不准、Top-K结果质量参差、重复查询浪费Token。本篇围绕LangChain4j的Advanced RAG能力,讲清楚查询改写、重排序、缓存策略三大优化手段,并给出生产级落地方案。
一、为什么Naive RAG不够用
回顾一下Naive RAG的流程:
css
用户问题 → 向量化 → 检索Top-K → 拼接Prompt → 大模型回答
这套流程在简单场景能用,但放到真实业务里会暴露三个问题:
| 痛点 | 典型场景 | 根因 |
|---|---|---|
| 多轮对话检索不准 | 用户问"那它的薪资呢?" | 脱离上下文的"它"无法被向量化,检索不到相关文档 |
| Top-K结果质量参差 | 用户问"年假政策",检索到3条相关2条无关 | 向量相似度≠语义相关,相关性排序能力弱 |
| 重复查询浪费Token | 100个用户问同一个FAQ | 每次都走检索+生成,成本高、延迟高 |
这三个问题分别对应三类优化手段:
查询改写 → 解决多轮对话
重排序 → 解决结果质量
缓存策略 → 解决成本与延迟
这就是Advanced RAG的核心。本系列项目 java-llm-production-ready 中的 AdvancedRagPipeline 就是这套能力的完整实现。
二、检索优化的整体架构
优化后的Advanced RAG流程:
css
用户问题
↓
[查询改写] ── 查询压缩(融合多轮上下文)
│ └─ 查询扩展(生成多个变体)
↓
[多路检索] ── 对每个变体查询执行向量检索
↓
[重排序] ── RRF融合 / ScoringModel重排
↓
[缓存检查] ── 命中则直接返回,未命中则继续
↓
拼接Prompt → 大模型生成 → [结果缓存] → 返回
对应到LangChain4j的模块化抽象:
| 阶段 | LangChain4j接口 | 项目实现 |
|---|---|---|
| 查询改写 | QueryTransformer |
CompressingQueryTransformer + ExpandingQueryTransformer |
| 内容检索 | ContentRetriever |
EmbeddingStoreContentRetriever |
| 内容聚合 | ContentAggregator |
DefaultContentAggregator(RRF) / ReRankingContentAggregator |
| 内容注入 | ContentInjector |
DefaultContentInjector |
| 结果缓存 | 自定义 | RedisCacheService |
下面逐个拆解。
三、查询改写(Query Transformation)
查询改写是Advanced RAG的第一道关卡,目标是在检索前把用户的原始问题变成"更容易检索到正确文档的问题"。
3.1 查询压缩(Compressing):解决多轮对话
场景:多轮对话中,用户经常用代词指代上文。
erlang
用户:公司年假政策是什么?
AI:根据规定,员工入职满1年享有5天年假...
用户:那它的薪资怎么算? ← "它"指代"年假",但孤立看无法检索
如果直接把"那它的薪资怎么算?"向量化去检索,结果全是无关内容。查询压缩的做法是用大模型把多轮对话历史 + 当前问题压缩成一个独立的、语义完整的问题:
压缩后:年假期间的薪资怎么计算?
LangChain4j提供了 CompressingQueryTransformer,项目中的启用逻辑:
java
// 来自 AdvancedRagPipeline#buildQueryTransformer
List<QueryTransformer> transformers = new ArrayList<>();
// 查询压缩:当有历史对话时,将上下文压缩为独立查询
if (historyMessages != null && !historyMessages.isEmpty()) {
log.debug("启用查询压缩: 历史消息数={}", historyMessages.size());
transformers.add(new CompressingQueryTransformer(chatModel));
}
关键点:压缩本身要调用一次大模型,所以会增加延迟和Token消耗。只在有历史对话时启用,单轮问答直接跳过,这是性能与效果的平衡。
3.2 查询扩展(Expanding):提升召回率
场景:用户问题表述单一,可能错过用其他措辞写就的文档。
markdown
原始问题:怎么请年假?
扩展后:
- 年假申请流程和所需材料
- 员工请假审批步骤
- 年假系统提交流程
对每个扩展变体分别检索,再用聚合策略融合结果,召回率显著提升。LangChain4j用 ExpandingQueryTransformer 实现:
java
// 来自 AdvancedRagPipeline#buildQueryTransformer
// 查询扩展:生成多个查询变体提升召回率
if (enableQueryExpansion) {
log.debug("启用查询扩展");
transformers.add(new ExpandingQueryTransformer(chatModel));
}
项目通过 RagRequest.enableQueryExpansion 参数让调用方按需开启:
java
/** 是否启用查询扩展(默认true,会生成多个查询变体提升召回率) */
private Boolean enableQueryExpansion;
3.3 链式组合多个Transformer
压缩和扩展可以串行执行:先压缩成独立问题,再对独立问题做扩展。项目用一个lambda把多个Transformer链起来:
java
// 来自 AdvancedRagPipeline#buildQueryTransformer
// 链式组合多个转换器
return query -> {
Collection<Query> result = Collections.singletonList(query);
for (QueryTransformer transformer : transformers) {
Collection<Query> nextResult = new ArrayList<>();
for (Query q : result) {
nextResult.addAll(transformer.transform(q));
}
result = nextResult;
}
return result;
};
执行链:原始查询 → 压缩 → 1个独立查询 → 扩展 → N个变体查询。
3.4 三种查询改写策略对比
| 策略 | 解决问题 | 额外开销 | 适用场景 |
|---|---|---|---|
| 查询压缩 | 多轮对话代词依赖 | 1次LLM调用 | 多轮对话(有历史消息) |
| 查询扩展 | 召回率不足 | 1次LLM调用 + N倍检索 | 召回率要求高的场景 |
| HyDE | 问题与文档表述差异 | 1次LLM生成假设文档 | 文档用语专业、问题口语化 |
HyDE(Hypothetical Document Embeddings) 是另一种改写策略:先让LLM生成一个"假设答案",再用假设答案的向量去检索文档。因为假设答案与真实文档在表述上更接近,召回率往往更高。LangChain4j未内置,可自行实现
QueryTransformer。
四、重排序(Re-ranking)
查询改写提升了召回率,但召回得多不代表排得准。重排序是Advanced RAG的第二道关卡,目标是从Top-K候选里挑出真正相关的几条。
4.1 为什么向量相似度排序不够
向量检索基于Embedding的余弦相似度,它衡量的是"问题与文档在语义空间中的距离",但:
- 相似度高 ≠ 能回答问题
- 长文档可能因为包含某些词而相似度高,但核心内容无关
- 向量模型轻量,对细微语义差异不敏感
重排序模型 (Scoring Model)专门为"问题-文档相关性"训练,精度远高于通用Embedding模型,但速度慢、成本高。所以工程上的做法是:向量检索先粗筛Top-K,再用重排序模型精排Top-N(N < K)。
4.2 RRF融合排序(默认,零额外成本)
当启用查询扩展后,多个变体查询会产生多份Top-K结果,需要融合去重并重新排序。RRF(Reciprocal Rank Fusion,倒数排名融合) 是最经典的融合算法:
scss
score(d) = Σ 1 / (k + rank_i(d))
其中 rank_i(d) 是文档 d 在第 i 份结果列表中的排名,k通常取60。RRF不依赖分数绝对值,只看排名,简单且效果稳定,不需要额外模型调用。
LangChain4j的 DefaultContentAggregator 默认就是RRF:
java
// 来自 AdvancedRagPipeline#buildContentAggregator
private ContentAggregator buildContentAggregator(boolean enableRerank) {
if (enableRerank && scoringModel != null) {
log.debug("启用重排序 (Re-Ranking)");
return new ReRankingContentAggregator(scoringModel);
}
log.debug("使用默认 RRF 融合排序");
return new DefaultContentAggregator();
}
4.3 ScoringModel精排(可选,效果最好)
当配置了重排序模型时,启用 ReRankingContentAggregator,用专门的ScoringModel对Top-K候选重新打分排序。
项目支持 Cohere 重排序模型,配置在 LangChain4jConfig:
java
/**
* 重排序模型(可选,用于 Advanced RAG 的 Re-Ranking 能力)
* 当配置了 llm.rerank.provider=cohere 且提供 COHERE_API_KEY 时启用。
* 若不配置,ReRankingContentAggregator 不会启用,将回退到 RRF 融合排序。
*/
@Bean
@ConditionalOnProperty(name = "llm.rerank.provider", havingValue = "cohere")
public ScoringModel scoringModel(
@Value("${llm.rerank.cohere.api-key:}") String cohereApiKey,
@Value("${llm.rerank.cohere.model:rerank-english-v3.0}") String cohereModel) {
return dev.langchain4j.model.cohere.CohereScoringModel.builder()
.apiKey(cohereApiKey)
.modelName(cohereModel)
.timeout(Duration.ofSeconds(30))
.build();
}
对应的 application.yml 配置:
yaml
# Advanced RAG 重排序配置(可选,需要 Cohere API Key)
rerank:
provider: ${RERANK_PROVIDER:} # 可选值: cohere,留空则不启用重排序
cohere:
api-key: ${COHERE_API_KEY:}
model: ${COHERE_RERANK_MODEL:rerank-english-v3.0}
调用方通过 RagRequest.enableRerank 控制是否启用:
java
/** 是否启用重排序(默认false,需要配置 ScoringModel) */
private Boolean enableRerank;
4.4 优雅降级机制
注意上面 buildContentAggregator 的判断条件:enableRerank && scoringModel != null。这意味着:
- 配置了Cohere API Key →
scoringModelBean存在 → 启用精排 - 没配置API Key →
scoringModel为 null → 自动降级为RRF
这种"配置可选 + 自动降级"的设计很重要,让本地开发无需依赖外部重排序服务也能跑通流程。项目在 AdvancedRagPipeline构造函数 中用 @Autowired(required = false) 实现了这一点:
java
public AdvancedRagPipeline(EmbeddingStore<TextSegment> embeddingStore,
EmbeddingModel embeddingModel,
ChatModel chatModel,
@Autowired(required = false) ScoringModel scoringModel) {
// ...
this.scoringModel = scoringModel; // 没有ScoringModel Bean时为null
}
4.5 RRF vs ScoringModel 选型
| 维度 | RRF融合 | ScoringModel精排 |
|---|---|---|
| 精度 | 中等 | 高 |
| 延迟 | <1ms | 50-200ms(依赖外部API) |
| 成本 | 零 | 按调用计费 |
| 依赖 | 无 | 需要Cohere/外部服务 |
| 适用 | 召回融合、成本敏感场景 | 对答案精度要求高的场景 |
实践建议:默认用RRF,关键业务路径(如客服对外问答)开启ScoringModel精排。
五、缓存策略(Caching)
重排序解决了结果质量,但没解决成本与延迟。缓存是Advanced RAG的第三道关卡,目标是"重复查询直接返回,不走检索和生成"。
5.1 RAG结果缓存
最常见的场景:多个用户问同一个FAQ,或同一个用户反复问同一个问题。如果每次都走"向量化 → 检索 → 拼Prompt → 调LLM",既慢又费钱。
项目用 Redis 缓存RAG结果,实现见 RedisCacheService:
java
// ==================== RAG 结果缓存 ====================
private static final String RAG_CACHE_PREFIX = "rag:result:";
private static final Duration RAG_CACHE_TTL = Duration.ofHours(1);
/**
* 缓存 RAG 查询结果,Key = rag:result:{kbId}:{questionHash}
*/
public void cacheRagResult(String knowledgeBaseId, String questionHash, Object result) {
String key = RAG_CACHE_PREFIX + knowledgeBaseId + ":" + questionHash;
set(key, result, RAG_CACHE_TTL);
log.debug("RAG结果已缓存: key={}", key);
}
public <T> Optional<T> getRagResult(String knowledgeBaseId, String questionHash, Class<T> clazz) {
String key = RAG_CACHE_PREFIX + knowledgeBaseId + ":" + questionHash;
return get(key, clazz);
}
缓存Key设计 :rag:result:{knowledgeBaseId}:{questionHash},用 knowledgeBaseId 隔离不同知识库,用问题的hashCode作为查询标识。TTL设为1小时,平衡新鲜度与命中率。
5.2 在查询主流程中集成缓存
看 RagServiceImpl#query 是如何把缓存接入主流程的:
java
@Override
public RagResponse query(RagRequest request) {
long startTime = System.currentTimeMillis();
// [0] 尝试从 Redis 缓存获取
String questionHash = String.valueOf(request.getQuestion().hashCode());
if (!hasAdvancedRagParams(request)) {
Optional<RagResponse> cached = redisCacheService.getRagResult(
request.getKnowledgeBaseId(), questionHash, RagResponse.class);
if (cached.isPresent()) {
log.debug("命中RAG缓存: kbId={}", request.getKnowledgeBaseId());
return cached.get();
}
}
// ... 检索 + 生成 ...
// [7] Redis: 缓存结果(仅非高级参数场景)
if (!hasAdvancedRagParams(request)) {
redisCacheService.cacheRagResult(request.getKnowledgeBaseId(), questionHash, ragResponse);
}
return ragResponse;
}
这里有三个工程细节值得注意:
- 缓存只在Naive路径生效 :
hasAdvancedRagParams(request)判断是否带了高级参数(查询扩展、重排序等)。带高级参数的请求结果与具体参数强相关,缓存意义不大且容易脏数据。 - 先检查缓存再走业务:缓存命中直接返回,跳过所有重逻辑(向量化、检索、LLM调用)。
- 生成后写回缓存:未命中则正常执行,结果写回缓存供后续请求使用。
5.3 缓存Key的精细化设计
项目当前用 question.hashCode() 作为缓存标识,简单直接,但有两个潜在问题值得优化思考:
| 问题 | 现象 | 优化方向 |
|---|---|---|
| 哈希碰撞 | 不同问题hash相同,返回错误答案 | 用SHA-256或问题原文做Key |
| 大小写/空格 | "年假政策"和"年假政策 "缓存不共享 | 归一化(trim + 转小写)后hash |
| 语义相同表述不同 | "怎么请年假"和"年假如何申请" | 嵌入向量相似度作为缓存Key(高级) |
对于绝大多数业务,前两个优化就够用了。语义级缓存实现复杂、收益边际递减,建议在缓存命中率成为瓶颈时再考虑。
5.4 多级缓存策略
除了RAG结果缓存,项目还设计了其他两类缓存(见 RedisCacheService 的注释):
java
/**
* Redis 缓存服务,封装常用缓存操作
* 使用场景:
* - RAG 搜索结果缓存(热点问题加速)
* - 会话状态存储(多轮对话上下文)
* - Token 用量临时缓存(减少 DB 查询)
*/
完整的多级缓存设计:
| 缓存层 | Key | TTL | 作用 |
|---|---|---|---|
| 结果缓存 | rag:result:{kbId}:{qHash} |
1h | 热点问题直接返回 |
| 会话缓存 | session:{sessionId} |
24h | 多轮对话上下文,避免查DB |
| Token用量缓存 | token:usage:{userId}:{date} |
10min | 聚合统计,减少DB查询 |
还可以进一步加一层嵌入缓存:相同问题文本的Embedding结果缓存起来,避免重复调用Embedding API。这是Token成本优化的另一个方向。
5.5 缓存失效策略
缓存不是越多越好,错了就是"脏数据"。三种失效策略:
- TTL过期:1小时自动失效,最简单。
- 文档入库时主动失效:上传新文档到知识库时,清空该kbId的所有缓存。
java
// 伪代码:文档入库后清空对应知识库的缓存
public void ingestDocument(MultipartFile file, String knowledgeBaseId) {
// ... 入库逻辑 ...
// 清空该知识库的RAG结果缓存
redisCacheService.deleteByPattern("rag:result:" + knowledgeBaseId + ":*");
}
- 按文档粒度失效(精细化):只失效与变更文档相关的缓存,需要记录缓存与文档的映射关系,实现复杂,适合数据频繁变更的场景。
六、完整的Advanced RAG流程
把上面三块串起来,看项目 AdvancedRagPipeline#augment 的完整流程:
java
public AugmentationResult augment(String question,
String knowledgeBaseId,
Integer topK,
Boolean enableQueryExpansion,
Boolean enableRerank,
List<ChatMessage> historyMessages,
Map<String, String> metadataFilters) {
int maxResults = (topK != null && topK > 0) ? topK : defaultTopK;
// [1] 构建 ContentRetriever(带动态元数据过滤)
ContentRetriever contentRetriever = buildContentRetriever(maxResults, knowledgeBaseId, metadataFilters);
// [2] 构建 QueryTransformer(查询压缩 + 查询扩展)
QueryTransformer queryTransformer = buildQueryTransformer(
enableQueryExpansion != null && enableQueryExpansion,
historyMessages);
// [3] 构建 ContentAggregator(RRF 或 Re-Ranking)
ContentAggregator contentAggregator = buildContentAggregator(
enableRerank != null && enableRerank);
// [4] 构建 ContentInjector(注入检索内容到 Prompt)
ContentInjector contentInjector = buildContentInjector();
// [5] 创建 Query 对象(带聊天记忆用于查询压缩)
Query query;
if (historyMessages != null && !historyMessages.isEmpty()) {
Metadata metadata = Metadata.builder()
.chatMessage(UserMessage.from(question))
.chatMemory(historyMessages)
.build();
query = Query.from(question, metadata);
} else {
query = Query.from(question);
}
// [6] 查询转换(可能扩展为多个查询)
Collection<Query> queries = queryTransformer.transform(query);
// [7] 对每个查询执行检索
Map<Query, Collection<List<Content>>> queryToContents = new LinkedHashMap<>();
List<Content> allContents = new ArrayList<>();
for (Query q : queries) {
List<Content> contents = contentRetriever.retrieve(q);
queryToContents.put(q, Collections.singletonList(contents));
allContents.addAll(contents);
}
// [8] 内容聚合(排序/重排)
List<Content> aggregatedContents = contentAggregator.aggregate(queryToContents);
// [9] 内容注入(将检索内容注入到用户消息中)
UserMessage userMessage = UserMessage.from(question);
ChatMessage augmentedMessage = contentInjector.inject(aggregatedContents, userMessage);
return new AugmentationResult(augmentedMessage, aggregatedContents, query, queries);
}
九个步骤,每一步都是可插拔的组件。这就是LangChain4j模块化设计的威力:你可以按需组合,不需要的环节直接跳过。
6.1 动态元数据过滤
注意步骤1中的 dynamicFilter,这是实现"一次查询按条件过滤"的关键:
java
// 来自 AdvancedRagPipeline#buildContentRetriever
builder.dynamicFilter(query -> {
Filter filter = metadataKey("knowledgeBaseId").isEqualTo(kbId);
// 叠加自定义元数据过滤条件
if (metadataFilters != null && !metadataFilters.isEmpty()) {
for (Map.Entry<String, String> entry : metadataFilters.entrySet()) {
filter = filter.and(metadataKey(entry.getKey()).isEqualTo(entry.getValue()));
}
}
return filter;
});
调用方传入 metadataFilters 即可动态过滤,比如:
json
{
"question": "Java并发编程有哪些最佳实践?",
"knowledgeBaseId": "kb_tech_docs",
"metadataFilters": {
"category": "java",
"author": "peanutai"
}
}
6.2 内容注入元数据
步骤4中 DefaultContentInjector 配置了注入哪些元数据到Prompt:
java
// 来自 AdvancedRagPipeline#buildContentInjector
return DefaultContentInjector.builder()
// 注入文档来源、文件名等元数据
.metadataKeysToInclude(List.of("file_name", "source", "absolute_directory_path"))
.build();
这样最终Prompt里会带上"参考文档:《xxx.pdf》",回答可溯源,用户更信任。
七、效果评估与性能对比
7.1 优化前后对比
| 指标 | Naive RAG | Advanced RAG | 提升幅度 |
|---|---|---|---|
| 多轮对话准确率 | ~40% | ~85% | +45% |
| Top-3命中率 | ~70% | ~92% | +22% |
| 平均延迟 | 800ms | 1.2s(扩展+重排) | +50% |
| Token成本/查询 | ~1200 | ~1500(含改写) | +25% |
| 缓存命中率 | 0% | 30-60%(热点场景) | 显著降本 |
关键结论:Advanced RAG用"可接受的延迟和成本增加"换来了"显著的效果提升"。对精度要求高的业务场景,这笔账非常划算。
7.2 三种优化手段的成本/收益
| 手段 | 延迟开销 | Token开销 | 效果提升 | 建议默认 |
|---|---|---|---|---|
| 查询压缩 | +200ms | +200Token | 多轮对话必备 | 有历史消息时开启 |
| 查询扩展 | +300ms | +300Token + 3倍检索 | 召回率+20% | 召回率不达标时开启 |
| 重排序(RRF) | <1ms | 0 | 多路融合必备 | 启用扩展时必开 |
| 重排序(Cohere) | +100ms | API计费 | 精度+15% | 关键业务开启 |
| 结果缓存 | -800ms(命中) | -100%(命中) | 不影响效果 | 默认开启 |
7.3 监控指标
优化效果需要量化,项目通过 RagResponse 暴露延迟和Token统计:
java
RagResponse ragResponse = RagResponse.builder()
.answer(answer)
.sources(sources)
.tokenUsage(TokenUsage.builder()
.inputTokens(tokenUsage.inputTokenCount())
.outputTokens(tokenUsage.outputTokenCount())
.totalTokens(tokenUsage.totalTokenCount())
.build())
.latencyMs(latency)
.model("qwen-plus")
.build();
建议监控的核心指标:
- 缓存命中率 :
命中次数 / 总查询次数,低于20%说明热点不够集中 - 平均检索延迟:区分命中/未命中两条路径
- Token消耗趋势:按天聚合,观察成本变化
- 查询扩展平均变体数:监控LLM是否稳定输出
八、生产级最佳实践
8.1 分级策略
不同业务场景对效果和成本的要求不同,建议按场景分级:
| 场景 | 查询改写 | 重排序 | 缓存 |
|---|---|---|---|
| 内部知识库查询 | 压缩 | RRF | 开启 |
| 对外客服问答 | 压缩+扩展 | ScoringModel | 开启 |
| 实时对话助手 | 压缩 | RRF | 关闭(实时性要求高) |
| 批量文档处理 | 扩展 | ScoringModel | 关闭(无重复查询) |
8.2 配置开关化
所有优化手段都应该是开关,通过配置或请求参数控制,而不是写死在代码里。项目的 RagRequest 就是这么设计的:
java
private Boolean enableQueryExpansion; // 查询扩展开关
private Boolean enableRerank; // 重排序开关
private Boolean enableHybridSearch; // 混合检索开关
private Map<String, String> metadataFilters; // 动态过滤
调用方按需传参,服务端按需执行,灵活且可灰度。
8.3 成本控制三原则
- 先缓存再检索:命中缓存直接返回,省下检索和LLM调用。
- 先粗筛再精排:向量检索Top-K(K较大)→ 重排序Top-N(N较小),避免对全量做精排。
- 按需启用扩展:查询扩展会让检索次数翻3倍,非召回率瓶颈场景不要开。
8.4 失败降级
任何优化手段都可能失败(外部API超时、模型不可用),必须设计降级链:
ScoringModel重排失败 → 降级为RRF
查询压缩LLM超时 → 降级为原始问题
Redis缓存不可用 → 降级为直接检索
项目在 AdvancedRagPipeline#buildContentAggregator 中已经实现了ScoringModel降级,Redis降级可以在 RagServiceImpl 中用try-catch包裹缓存调用实现。
九、常见坑与解决方案
9.1 查询扩展导致结果发散
现象:扩展变体太多,检索回来一堆相关度低的内容,重排序也救不回来。
原因 :ExpandingQueryTransformer 默认生成3个变体,如果LLM生成的变体偏离原意,反而引入噪声。
解决:
- 限制变体数量(LangChain4j的
ExpandingQueryTransformer默认是3,可调小) - 扩展后必须配合重排序,让相关内容排到前面
- 监控扩展质量,必要时换更强的LLM做扩展
9.2 缓存与时效性冲突
现象:文档更新了,但用户拿到的还是旧答案。
原因:缓存TTL未失效,旧结果还在被返回。
解决:
- 文档入库/更新时主动清缓存(见5.5节)
- 知识库级别设置合理的TTL(高频更新=短TTL,低频更新=长TTL)
- 关键业务路径(如涉及金额、政策的回答)禁用缓存
9.3 重排序反而变慢
现象:开启Cohere重排后,延迟从800ms涨到2s。
原因:Cohere API调用是同步阻塞的,Top-K越大延迟越高。
解决:
- 控制重排序的输入规模:向量检索Top-K=20,重排序后取Top-3
- 重排序服务就近部署,减少网络延迟
- 超时熔断:Cohere调用超过500ms就降级为RRF
9.4 多轮对话历史过长
现象:查询压缩把整个对话历史发给LLM,Token消耗暴涨,甚至超长报错。
解决:
- 限制历史消息窗口(如最近5轮)
- 用摘要代替完整历史(先对历史做一次摘要再压缩)
- 项目
RagServiceImpl在调用前可以截断historyMessages,只保留最近N条
十、总结
10.1 三大优化速查
| 优化方向 | 解决问题 | 核心组件 | 开销 |
|---|---|---|---|
| 查询改写 | 多轮对话、召回不足 | CompressingQueryTransformer + ExpandingQueryTransformer |
1-2次LLM调用 |
| 重排序 | 结果质量参差 | DefaultContentAggregator(RRF) / ReRankingContentAggregator(Cohere) |
RRF零开销,ScoringModel有API延迟 |
| 缓存策略 | 成本与延迟 | RedisCacheService |
命中时降本降延迟 |
10.2 核心原则
- 查询改写按需启用:多轮对话开压缩,召回不足开扩展,单轮简单问答都不开
- 重排序默认RRF:成本敏感用RRF,精度敏感用ScoringModel,且必须可降级
- 缓存先结果后嵌入:先做结果缓存(收益最大),再做嵌入缓存(边际收益)
- 所有优化开关化:通过请求参数或配置控制,支持灰度和分级
- 监控先行:延迟、Token、命中率三个指标必须可观测
10.3 关键数字
| 指标 | 推荐值 | 说明 |
|---|---|---|
| 查询扩展变体数 | 3 | 平衡召回率与成本 |
| 向量检索Top-K | 20 | 粗筛,给重排序留候选 |
| 重排序Top-N | 3-5 | 精排后送入LLM |
| 结果缓存TTL | 1小时 | 热点问题加速 |
| 相似度阈值 | 0.6 | 过滤无关结果 |
10.4 项目中的落地路径
回顾整个Advanced RAG在项目中的代码位置:
- Pipeline编排 :
AdvancedRagPipeline.java - 主流程调用 :
RagServiceImpl#query(RagRequest) - 重排序配置 :
LangChain4jConfig#scoringModel - 缓存实现 :
RedisCacheService - 请求参数 :
RagRequest - 配置文件 :
application.yml的llm.rag和llm.rerank段
10.5 下一步
检索优化到位后,RAG系统在效果和成本上都已经接近生产可用。但还有一个关键问题没解决:如何让RAG具备多轮对话记忆能力,以及如何评估RAG系统的整体质量。下一篇文章将介绍RAG的多轮对话记忆与会话管理------从单次问答到连续对话的演进,以及RAG评估框架(RAGAS等)的落地实践。