第2050篇:RAG查询优化——HyDE、查询分解和步退提示
大约 6 分钟
第2050篇:RAG查询优化——HyDE、查询分解和步退提示
适读人群:基础RAG已上线、想进一步提升检索质量的工程师 | 阅读时长:约18分钟 | 核心价值:掌握三种查询优化技术,从查询侧提升RAG的检索准确率
基础RAG有一个根本性的矛盾:用户的查询是"问题形式",文档里存的是"答案形式"。
比如用户问"为什么我的nginx配置不生效",文档里存的可能是"nginx配置生效必须执行nginx -s reload"。这两段文字的向量距离可能并不近,因为一个是描述问题,一个是描述解决方案。
查询优化的核心思路:改造查询,让它在语义上更接近文档中可能包含答案的文本。
技术一:HyDE(假设性文档嵌入)
HyDE(Hypothetical Document Embedding)的思路很反直觉:先让LLM根据用户问题"假设"一个答案,然后用这个假设答案做检索,而不是用原始问题。
为什么有效:假设答案的向量和文档中真实答案的向量距离更近(都是"答案形式"的文本),比用"问题形式"检索效果更好。
/**
* HyDE实现:假设性文档嵌入
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class HydeQueryOptimizer {
private final ChatLanguageModel llm;
private final EmbeddingModel embeddingModel;
private final EmbeddingStore<TextSegment> vectorStore;
/**
* 使用HyDE进行检索
*/
public List<TextSegment> searchWithHyde(String originalQuery, int topK) {
// 第一步:让LLM生成假设性答案
String hypotheticalDocument = generateHypotheticalDocument(originalQuery);
log.debug("HyDE假设文档: {}", hypotheticalDocument.substring(0,
Math.min(100, hypotheticalDocument.length())));
// 第二步:用假设答案做向量检索
float[] hydeEmbedding = embeddingModel.embed(hypotheticalDocument);
EmbeddingSearchRequest request = EmbeddingSearchRequest.builder()
.queryEmbedding(Embedding.from(hydeEmbedding))
.maxResults(topK)
.minScore(0.6)
.build();
List<EmbeddingMatch<TextSegment>> matches = vectorStore.search(request).matches();
return matches.stream()
.map(EmbeddingMatch::embedded)
.collect(Collectors.toList());
}
private String generateHypotheticalDocument(String query) {
String hydePrompt = String.format("""
以下是一个用户问题,请生成一段假设性的文档内容,这段内容应该包含该问题的答案。
注意:生成的是文档风格的解释性文本,不是直接回答用户。
用户问题:%s
假设性文档内容(2-4句话):
""", query);
return llm.generate(hydePrompt);
}
/**
* HyDE增强版:生成多个假设文档,取平均向量
* 覆盖更多可能的答案角度
*/
public List<TextSegment> searchWithMultiHyde(String originalQuery, int topK) {
List<float[]> embeddings = new ArrayList<>();
// 生成3个角度不同的假设文档
for (int i = 0; i < 3; i++) {
String hypothetical = generateHypotheticalDocument(originalQuery +
(i > 0 ? " (从" + getAngle(i) + "角度)" : ""));
embeddings.add(embeddingModel.embed(hypothetical));
}
// 计算平均向量
float[] avgEmbedding = averageEmbeddings(embeddings);
EmbeddingSearchRequest request = EmbeddingSearchRequest.builder()
.queryEmbedding(Embedding.from(avgEmbedding))
.maxResults(topK)
.build();
return vectorStore.search(request).matches().stream()
.map(EmbeddingMatch::embedded)
.collect(Collectors.toList());
}
private String getAngle(int index) {
return switch (index) {
case 1 -> "原因分析";
case 2 -> "解决方案";
default -> "概述";
};
}
private float[] averageEmbeddings(List<float[]> embeddings) {
if (embeddings.isEmpty()) return new float[0];
int dim = embeddings.get(0).length;
float[] avg = new float[dim];
for (float[] emb : embeddings) {
for (int i = 0; i < dim; i++) {
avg[i] += emb[i];
}
}
for (int i = 0; i < dim; i++) {
avg[i] /= embeddings.size();
}
return avg;
}
}技术二:查询分解(Multi-Query)
复杂问题往往包含多个子问题,单一查询只能覆盖一个方面。查询分解把一个复杂问题拆成多个子查询:
/**
* 查询分解:把复杂问题分解为多个子查询
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class QueryDecomposer {
private final ChatLanguageModel llm;
private final EmbeddingModel embeddingModel;
private final EmbeddingStore<TextSegment> vectorStore;
/**
* 查询分解检索
* 适用场景:复杂的、多方面的问题
*/
public List<TextSegment> searchWithDecomposition(String originalQuery, int topK) {
// 分解查询
List<String> subQueries = decomposeQuery(originalQuery);
log.info("原始查询分解为{}个子查询", subQueries.size());
// 并发执行每个子查询
List<CompletableFuture<List<TextSegment>>> futures = subQueries.stream()
.map(subQuery -> CompletableFuture.supplyAsync(() ->
vectorSearch(subQuery, topK)))
.collect(Collectors.toList());
// 合并所有子查询的结果,去重
Set<String> seenContents = new HashSet<>();
List<TextSegment> mergedResults = new ArrayList<>();
for (CompletableFuture<List<TextSegment>> future : futures) {
for (TextSegment segment : future.join()) {
String key = segment.text().substring(0, Math.min(50, segment.text().length()));
if (seenContents.add(key)) {
mergedResults.add(segment);
}
}
}
// 限制最终结果数量
return mergedResults.stream().limit(topK * 2).collect(Collectors.toList());
}
private List<String> decomposeQuery(String query) {
String decompositionPrompt = String.format("""
将以下复杂问题分解为2-4个简单的子问题,每个子问题聚焦一个方面。
输出格式:每行一个子问题,不要编号。
原始问题:%s
子问题:
""", query);
String response = llm.generate(decompositionPrompt);
List<String> subQueries = Arrays.stream(response.split("\n"))
.map(String::trim)
.filter(s -> !s.isEmpty() && s.length() > 5)
.collect(Collectors.toList());
// 保险起见,至少包含原始查询
if (subQueries.isEmpty()) {
return List.of(query);
}
return subQueries;
}
private List<TextSegment> vectorSearch(String query, int topK) {
float[] embedding = embeddingModel.embed(query);
EmbeddingSearchRequest request = EmbeddingSearchRequest.builder()
.queryEmbedding(Embedding.from(embedding))
.maxResults(topK)
.minScore(0.6)
.build();
return vectorStore.search(request).matches().stream()
.map(EmbeddingMatch::embedded)
.collect(Collectors.toList());
}
}技术三:步退提示(Step-Back Prompting)
有些查询太具体,导致检索时找不到足够的相关文档。步退提示是让LLM把具体问题"提升"到更通用的层次:
/**
* 步退提示:把具体问题提升到更抽象的层次
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class StepBackQueryOptimizer {
private final ChatLanguageModel llm;
private final EmbeddingModel embeddingModel;
private final EmbeddingStore<TextSegment> vectorStore;
/**
* 步退提示检索
* 先用抽象问题检索背景知识,再回答具体问题
*/
public RagContext retrieveWithStepBack(String specificQuery, int topK) {
// 生成步退查询(更抽象的版本)
String stepBackQuery = generateStepBackQuery(specificQuery);
log.info("步退查询: {} -> {}", specificQuery, stepBackQuery);
// 并发检索:具体查询 + 步退查询
CompletableFuture<List<TextSegment>> specificFuture = CompletableFuture
.supplyAsync(() -> vectorSearch(specificQuery, topK));
CompletableFuture<List<TextSegment>> stepBackFuture = CompletableFuture
.supplyAsync(() -> vectorSearch(stepBackQuery, topK));
List<TextSegment> specificResults = specificFuture.join();
List<TextSegment> backgroundResults = stepBackFuture.join();
return new RagContext(specificQuery, specificResults,
stepBackQuery, backgroundResults);
}
private String generateStepBackQuery(String specificQuery) {
String stepBackPrompt = String.format("""
给定一个具体的问题,生成一个更通用的"步退"问题,
用于检索回答该问题所需的背景知识。
例子:
具体问题:Transformer的注意力头数量选择有什么规律?
步退问题:Transformer架构的核心超参数如何设计?
具体问题:为什么我的Spring Boot应用启动时内存占用很高?
步退问题:Spring Boot应用的内存使用由哪些因素决定?
具体问题:%s
步退问题:
""", specificQuery);
return llm.generate(stepBackPrompt).trim();
}
private List<TextSegment> vectorSearch(String query, int topK) {
float[] embedding = embeddingModel.embed(query);
EmbeddingSearchRequest request = EmbeddingSearchRequest.builder()
.queryEmbedding(Embedding.from(embedding))
.maxResults(topK)
.minScore(0.55)
.build();
return vectorStore.search(request).matches().stream()
.map(EmbeddingMatch::embedded)
.collect(Collectors.toList());
}
public record RagContext(
String specificQuery,
List<TextSegment> specificResults,
String stepBackQuery,
List<TextSegment> backgroundResults
) {}
}
/**
* 使用步退提示的RAG查询服务
*/
@Service
@RequiredArgsConstructor
public class StepBackRagService {
private final ChatLanguageModel llm;
private final StepBackQueryOptimizer stepBackOptimizer;
public String query(String userQuestion) {
StepBackQueryOptimizer.RagContext ctx =
stepBackOptimizer.retrieveWithStepBack(userQuestion, 3);
// 构建包含背景知识和具体信息的Prompt
String backgroundContext = ctx.backgroundResults().stream()
.map(TextSegment::text)
.collect(Collectors.joining("\n\n"));
String specificContext = ctx.specificResults().stream()
.map(TextSegment::text)
.collect(Collectors.joining("\n\n"));
String prompt = String.format("""
请基于以下信息回答用户问题。
背景知识(%s):
%s
具体相关内容:
%s
用户问题:%s
""",
ctx.stepBackQuery(),
backgroundContext,
specificContext,
userQuestion);
return llm.generate(prompt);
}
}三种技术的选用场景
| 技术 | 最适合的查询类型 | 额外LLM调用成本 | 延迟影响 |
|---|---|---|---|
| HyDE | 开放性问题、语义检索 | 1次 | +200-500ms |
| 查询分解 | 复杂多方面问题 | 1次(分解)+ 并发检索 | +300-800ms |
| 步退提示 | 需要背景知识的具体问题 | 1次 | +200-400ms |
三种技术可以组合使用,但要注意额外的LLM调用会增加成本和延迟。建议的组合策略:
@Service
@RequiredArgsConstructor
public class AdaptiveQueryOptimizer {
private final QueryComplexityClassifier classifier;
private final HydeQueryOptimizer hyde;
private final QueryDecomposer decomposer;
private final StepBackQueryOptimizer stepBack;
/**
* 根据查询特征自适应选择优化策略
*/
public List<TextSegment> search(String query, int topK) {
QueryType type = classifier.classify(query);
return switch (type) {
case SIMPLE_FACTUAL -> basicVectorSearch(query, topK);
case OPEN_ENDED -> hyde.searchWithHyde(query, topK);
case COMPLEX_MULTI_ASPECT -> decomposer.searchWithDecomposition(query, topK);
case SPECIFIC_NEED_BACKGROUND -> stepBack.retrieveWithStepBack(query, topK)
.specificResults();
};
}
}查询优化是RAG提升的重要方向,但不是唯一方向。在优化查询之前,先确保基础的chunk切分和embedding模型选择是对的——那两个做好了,再叠加查询优化,效果提升会更明显。
