第2078篇:向量检索的精度陷阱——余弦相似度的局限和改进
大约 8 分钟
第2078篇:向量检索的精度陷阱——余弦相似度的局限和改进
适读人群:正在遇到RAG召回质量问题的工程师 | 阅读时长:约18分钟 | 核心价值:深入理解向量检索的局限性,掌握BM25混合检索、重排序、元数据过滤等改进手段
很多工程师反映:RAG系统的向量检索结果很奇怪——明明相关的文档没检索到,反而返回了一些语义相似但内容无关的文档。
这不是bug,是余弦相似度这个度量方式的固有局限。理解这些局限,才能有针对性地优化。
余弦相似度的局限
/**
* 理解余弦相似度的几个典型失效场景
*/
public class CosineSimilarityLimitations {
/*
* 失效场景1:反义词陷阱
*
* 在同一语境下,"涨价"和"降价"是反义词
* 但它们共享大量上下文(商品、市场、消费者等)
* 所以它们的向量可能高度相似!
*
* 例子:
* 查询:有没有降价的商品
* 可能返回:最近多款商品涨价(相似度0.87)
* 真正相关:本周促销降价商品列表(相似度0.82)
*
* 原因:两者都包含"价格变动"的语义
*/
/*
* 失效场景2:格式一致性问题
*
* "用Python实现快速排序"和"Python快速排序代码"
* 语义相同,但一个是疑问句一个是名词短语
* 向量距离可能比你预期的要大
*/
/*
* 失效场景3:专业术语稀释
*
* 在通用嵌入模型中,专业领域的术语往往被"稀释"
* 医学术语"心肌梗死"和通俗说法"心脏病发作"
* 在通用模型中可能相似度不高
* 但在专业语境下它们指的是同一件事
*/
/*
* 失效场景4:数字精确匹配问题
*
* 查询:API限流配置1000 req/s
* 文档1:API限流示例,设置500 req/s(相似度0.91)
* 文档2:API限流配置1000每秒请求(相似度0.85)
*
* 向量相似度认为文档1更相关,但明显文档2才对
* 因为向量无法区分具体数字
*/
}解决方案一:混合检索(已有代码优化)
/**
* 多路检索融合
* 结合语义向量检索和关键词精确匹配
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class EnhancedHybridRetriever {
private final EmbeddingModel embeddingModel;
private final EmbeddingStore<TextSegment> vectorStore;
private final BM25Index bm25Index; // 关键词索引
/**
* 三路检索融合:向量 + BM25 + 精确匹配
*/
public List<RankedResult> retrieve(String query, int topK) {
// 1. 向量检索(语义相似)
List<ScoredResult> vectorResults = vectorSearch(query, topK * 2);
// 2. BM25检索(关键词匹配)
List<ScoredResult> bm25Results = bm25Search(query, topK * 2);
// 3. 精确匹配(针对数字、代码、专有名词)
List<ScoredResult> exactResults = exactMatchSearch(query, topK);
// 4. RRF融合
Map<String, Double> fusedScores = new LinkedHashMap<>();
int k = 60; // RRF的k参数
addRrfScores(fusedScores, vectorResults, k, 1.0); // 向量检索权重1.0
addRrfScores(fusedScores, bm25Results, k, 0.8); // BM25权重0.8
addRrfScores(fusedScores, exactResults, k, 1.5); // 精确匹配最高权重
// 5. 按融合分数排序
return fusedScores.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.limit(topK)
.map(e -> new RankedResult(e.getKey(), e.getValue(), getContent(e.getKey())))
.toList();
}
private void addRrfScores(Map<String, Double> scores,
List<ScoredResult> results, int k, double weight) {
for (int rank = 0; rank < results.size(); rank++) {
String id = results.get(rank).id();
double rrfScore = weight / (k + rank + 1);
scores.merge(id, rrfScore, Double::sum);
}
}
private List<ScoredResult> vectorSearch(String query, int maxResults) {
float[] embedding = embeddingModel.embed(query);
return vectorStore.search(EmbeddingSearchRequest.builder()
.queryEmbedding(Embedding.from(embedding))
.maxResults(maxResults)
.build())
.matches().stream()
.map(m -> new ScoredResult(m.embeddingId(), m.score(), m.embedded().text()))
.toList();
}
private List<ScoredResult> bm25Search(String query, int maxResults) {
return bm25Index.search(query, maxResults);
}
private List<ScoredResult> exactMatchSearch(String query, int maxResults) {
// 提取查询中的精确匹配目标(数字、引号内容、专有名词)
List<String> exactTerms = extractExactTerms(query);
if (exactTerms.isEmpty()) return List.of();
return bm25Index.exactMatch(exactTerms, maxResults);
}
private List<String> extractExactTerms(String query) {
List<String> terms = new ArrayList<>();
// 数字
Pattern numberPattern = Pattern.compile("\\d+(\\.\\d+)?");
Matcher numberMatcher = numberPattern.matcher(query);
while (numberMatcher.find()) terms.add(numberMatcher.group());
// 引号内容
Pattern quotePattern = Pattern.compile("[\"']([^\"']+)[\"']");
Matcher quoteMatcher = quotePattern.matcher(query);
while (quoteMatcher.find()) terms.add(quoteMatcher.group(1));
return terms;
}
private String getContent(String id) { return ""; } // 从存储获取
public record ScoredResult(String id, double score, String content) {}
public record RankedResult(String id, double score, String content) {}
}解决方案二:重排序(Reranking)
/**
* 使用交叉编码器重排序
*
* 原理:
* - 双编码器(普通向量检索):query和document独立编码,然后比较
* - 交叉编码器(Reranker):query和document一起输入,联合计算相关性
*
* 交叉编码器精度更高,但慢(不能做索引)
* 所以做两阶段:先用向量检索候选集,再用交叉编码器精排
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class RerankingService {
private final CrossEncoderModel crossEncoder; // 交叉编码器模型
private final ChatLanguageModel llm; // LLM作为备选重排序
/**
* 使用交叉编码器重排序
*/
public List<RerankResult> rerank(String query, List<String> candidates, int topK) {
if (candidates.isEmpty()) return List.of();
// 批量评分
List<RerankResult> scored = new ArrayList<>();
for (int i = 0; i < candidates.size(); i++) {
double score = crossEncoder.score(query, candidates.get(i));
scored.add(new RerankResult(i, candidates.get(i), score));
}
// 按分数排序
return scored.stream()
.sorted(Comparator.comparingDouble(RerankResult::score).reversed())
.limit(topK)
.toList();
}
/**
* 使用LLM进行相关性判断(当没有交叉编码器时的备选)
* 精度高但成本高,只在关键场景使用
*/
public List<RerankResult> rerankWithLlm(String query, List<String> candidates) {
if (candidates.size() <= 3) {
// 候选少时,LLM直接排序
return llmDirectRank(query, candidates);
}
// 候选多时,先用规则过滤,再用LLM精排
List<String> filtered = rulesPrefilter(query, candidates);
return llmDirectRank(query, filtered);
}
private List<RerankResult> llmDirectRank(String query, List<String> candidates) {
StringBuilder prompt = new StringBuilder();
prompt.append("请评估以下文档片段与问题的相关性,给出1-10分(10分最相关)。\n\n");
prompt.append("问题:").append(query).append("\n\n");
for (int i = 0; i < candidates.size(); i++) {
prompt.append("文档").append(i + 1).append(":\n");
prompt.append(candidates.get(i)).append("\n\n");
}
prompt.append("请输出JSON,格式:[{\"index\":1,\"score\":8,\"reason\":\"相关原因\"}, ...]");
try {
String response = llm.generate(prompt.toString());
String json = extractJsonArray(response);
ObjectMapper mapper = new ObjectMapper();
List<Map<String, Object>> rankings = mapper.readValue(json, new TypeReference<>() {});
return rankings.stream()
.map(r -> {
int idx = ((Number) r.get("index")).intValue() - 1;
double score = ((Number) r.get("score")).doubleValue();
return new RerankResult(idx, candidates.get(idx), score / 10.0);
})
.sorted(Comparator.comparingDouble(RerankResult::score).reversed())
.toList();
} catch (Exception e) {
log.warn("LLM重排序解析失败: {}", e.getMessage());
return candidates.stream()
.mapToObj(c -> new RerankResult(candidates.indexOf(c), c, 0.5))
.toList();
}
}
private List<String> rulesPrefilter(String query, List<String> candidates) {
// 简单的规则过滤:去掉明显不相关的
return candidates.stream()
.filter(c -> hasKeywordOverlap(query, c))
.toList();
}
private boolean hasKeywordOverlap(String query, String doc) {
String[] queryWords = query.split("\\s+");
for (String word : queryWords) {
if (word.length() > 1 && doc.contains(word)) return true;
}
return false;
}
private String extractJsonArray(String text) {
int start = text.indexOf('[');
int end = text.lastIndexOf(']');
return start >= 0 && end > start ? text.substring(start, end + 1) : "[]";
}
public record RerankResult(int originalIndex, String content, double score) {}
}解决方案三:元数据过滤
/**
* 向量检索 + 元数据过滤
* 在语义搜索的基础上,用结构化条件缩小范围
*/
@Service
@RequiredArgsConstructor
public class FilteredVectorSearch {
private final EmbeddingModel embeddingModel;
private final EmbeddingStore<TextSegment> vectorStore;
/**
* 带过滤条件的向量检索
*
* 场景:用户查询特定产品线的文档
*/
public List<String> searchWithFilters(
String query,
SearchFilters filters,
int topK) {
float[] embedding = embeddingModel.embed(query);
// 构建元数据过滤条件
Filter metadataFilter = buildFilter(filters);
EmbeddingSearchRequest request = EmbeddingSearchRequest.builder()
.queryEmbedding(Embedding.from(embedding))
.maxResults(topK)
.minScore(0.6)
.filter(metadataFilter) // 元数据过滤
.build();
return vectorStore.search(request).matches().stream()
.map(m -> m.embedded().text())
.toList();
}
private Filter buildFilter(SearchFilters filters) {
List<Filter> conditions = new ArrayList<>();
// 文档类型过滤
if (filters.documentType() != null) {
conditions.add(metadataKey("documentType").isEqualTo(filters.documentType()));
}
// 时间范围过滤
if (filters.updatedAfter() != null) {
conditions.add(metadataKey("updateTime").isGreaterThan(
filters.updatedAfter().toString()));
}
// 权限等级过滤
if (filters.maxSecurityLevel() != null) {
conditions.add(metadataKey("securityLevel").isLessThanOrEqualTo(
filters.maxSecurityLevel()));
}
// 语言过滤
if (filters.language() != null) {
conditions.add(metadataKey("language").isEqualTo(filters.language()));
}
if (conditions.isEmpty()) return null;
if (conditions.size() == 1) return conditions.get(0);
return and(conditions.toArray(new Filter[0]));
}
public record SearchFilters(
String documentType,
LocalDate updatedAfter,
Integer maxSecurityLevel,
String language
) {}
}解决方案四:查询扩展
/**
* 查询扩展:一个查询变多个查询
* 提高召回率,减少遗漏相关文档
*/
@Service
@RequiredArgsConstructor
public class QueryExpansionService {
private final ChatLanguageModel llm;
private final EmbeddingModel embeddingModel;
private final EmbeddingStore<TextSegment> vectorStore;
/**
* 生成查询变体
* 用LLM生成多个语义相近但措辞不同的查询
*/
public List<String> expandQuery(String originalQuery) {
String prompt = String.format("""
生成3个语义相近但措辞不同的查询,用于提高文档检索召回率。
原始查询:%s
要求:
1. 每个变体用不同的词汇表达同一个意图
2. 涵盖可能的同义词和相关表达
3. 每行一个变体
变体列表:
""", originalQuery);
String response = llm.generate(prompt);
List<String> variants = Arrays.stream(response.split("\n"))
.map(String::trim)
.filter(s -> !s.isEmpty())
.limit(3)
.toList();
List<String> allQueries = new ArrayList<>();
allQueries.add(originalQuery);
allQueries.addAll(variants);
return allQueries;
}
/**
* 多查询并行检索,结果去重融合
*/
public List<String> retrieveWithExpansion(String query, int topK) {
List<String> expandedQueries = expandQuery(query);
// 并行检索
Map<String, Double> allResults = new ConcurrentHashMap<>();
expandedQueries.parallelStream().forEach(q -> {
float[] embedding = embeddingModel.embed(q);
vectorStore.search(EmbeddingSearchRequest.builder()
.queryEmbedding(Embedding.from(embedding))
.maxResults(topK)
.build())
.matches()
.forEach(m -> {
// 保留每个文档的最高分
allResults.merge(m.embeddingId(), m.score(), Math::max);
});
});
// 按分数排序,返回内容
return allResults.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.limit(topK)
.map(e -> getContent(e.getKey()))
.filter(c -> !c.isEmpty())
.toList();
}
private String getContent(String id) { return ""; }
}向量检索质量的快速诊断
/**
* 检索质量诊断工具
* 帮助快速找出检索质量问题的根源
*/
@Service
@RequiredArgsConstructor
public class RetrievalQualityDiagnostic {
private final EmbeddingModel embeddingModel;
private final EmbeddingStore<TextSegment> vectorStore;
/**
* 诊断一个检索案例
* 输出:为什么召回了这些文档,为什么没召回期望文档
*/
public DiagnosticReport diagnose(String query, String expectedDocId) {
float[] queryEmbedding = embeddingModel.embed(query);
// 实际检索结果
List<EmbeddingMatch<TextSegment>> retrieved = vectorStore.search(
EmbeddingSearchRequest.builder()
.queryEmbedding(Embedding.from(queryEmbedding))
.maxResults(10)
.build())
.matches();
// 期望文档的相似度
double expectedDocScore = getDocumentScore(expectedDocId, queryEmbedding);
// 找出为什么期望文档排名低
List<String> insights = new ArrayList<>();
if (!retrieved.isEmpty()) {
double topScore = retrieved.get(0).score();
if (expectedDocScore < topScore * 0.9) {
insights.add(String.format(
"期望文档相似度(%.3f)比最高匹配(%.3f)低%.0f%%,可能词汇差距较大",
expectedDocScore, topScore, (1 - expectedDocScore/topScore) * 100
));
}
}
// 检查前5个结果的相关性
List<String> retrievedPreviews = retrieved.stream()
.limit(5)
.map(m -> String.format("[%.3f] %s",
m.score(),
m.embedded().text().substring(0, Math.min(100, m.embedded().text().length()))))
.toList();
return new DiagnosticReport(
query, expectedDocScore, retrievedPreviews, insights
);
}
private double getDocumentScore(String docId, float[] queryEmbedding) {
return 0.7; // 简化实现
}
public record DiagnosticReport(
String query,
double expectedDocScore,
List<String> topRetrievedPreviews,
List<String> insights
) {}
}向量检索不是银弹,它在语义层面的优势恰恰是它在精确匹配层面的软肋。
好的RAG系统,是向量检索 + BM25 + 重排序的组合,加上根据具体场景设计的元数据过滤——这才是生产级别的检索系统。
