第2089篇:AI增强搜索——向量检索与传统排序融合的工程实践
大约 8 分钟
第2089篇:AI增强搜索——向量检索与传统排序融合的工程实践
适读人群:正在改造搜索系统的工程师 | 阅读时长:约20分钟 | 核心价值:掌握向量语义搜索与BM25/业务排序规则的融合方案,在不重写现有系统的前提下大幅提升搜索质量
搜索系统的改造是最容易「半途而废」的AI项目之一。
原因是:纯换成向量搜索,老用户会投诉(精确匹配变差了);完全不动旧系统,新技术发挥不了价值。最终很多团队做了个向量检索的POC,发现A/B测试没有明显提升,就不了了之了。
核心问题是融合策略没做好。这篇文章把这个问题说清楚。
为什么纯向量搜索不够
/**
* 向量搜索的三个典型失效场景
*/
public class VectorSearchLimitations {
/*
* 场景1:精确查询退化
*
* 用户输入:"iPhone 15 Pro 256GB 黑色"
* 向量搜索:返回各种iPhone相关结果(语义相似)
* 期望结果:精确匹配这个型号和规格
*
* 问题:向量相似度无法区分规格参数
*/
/*
* 场景2:热度和新鲜度被忽略
*
* 向量搜索只考虑语义相似度,不考虑:
* - 商品销量、评分
* - 内容发布时间
* - 用户历史偏好
*
* 导致语义匹配但完全不相关的老旧内容排在前面
*/
/*
* 场景3:行业词汇理解差异
*
* 用户搜:"P图软件"(摄影爱好者的俗称)
* 向量可能返回:摄影相关内容
* 期望:Photoshop、Lightroom类软件
*
* 通用embedding模型对行业黑话理解不足
*/
}融合搜索架构
查询分析器:决定检索策略
/**
* 查询分析器
* 分析查询类型,决定各种检索策略的权重
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class QueryAnalyzer {
/**
* 分析查询类型
*
* 精确查询:有具体型号、编号、SKU等
* 语义查询:描述性、意图性的查询
* 混合查询:两者兼有
*/
public QueryAnalysisResult analyze(String query) {
// 1. 检测精确匹配特征
boolean hasSkuPattern = hasSku(query); // 产品编号
boolean hasExactQuote = hasExactQuote(query); // 引号精确匹配
boolean isVeryShort = query.trim().length() <= 3; // 极短查询通常是精确搜索
boolean hasNumbers = hasSpecificNumbers(query); // 有具体数字参数
// 2. 检测语义搜索特征
boolean isQuestion = isQuestion(query); // 问句形式
boolean isDescriptive = isDescriptive(query); // 描述性语言
boolean hasIntent = hasIntentKeywords(query); // 有意图词(想要、推荐等)
// 3. 计算各策略权重
double exactWeight = 0.0;
double semanticWeight = 0.0;
if (hasSkuPattern || hasExactQuote) {
exactWeight = 0.9;
semanticWeight = 0.1;
} else if (isQuestion || isDescriptive || hasIntent) {
exactWeight = 0.2;
semanticWeight = 0.8;
} else if (hasNumbers) {
exactWeight = 0.6;
semanticWeight = 0.4;
} else {
exactWeight = 0.4; // 默认均衡
semanticWeight = 0.6;
}
// 4. 查询改写(扩展同义词和缩写)
String expandedQuery = expandQuery(query);
log.debug("查询分析: query='{}', exactWeight={}, semanticWeight={}, expanded='{}'",
query, exactWeight, semanticWeight, expandedQuery);
return new QueryAnalysisResult(
query, expandedQuery, exactWeight, semanticWeight,
isQuestion, hasSkuPattern
);
}
private boolean hasSku(String query) {
// SKU模式:字母+数字的组合(如 A123456, SKU-001)
return query.matches(".*[A-Z]{1,5}[-]?\\d{3,}.*") ||
query.matches(".*\\d{6,}.*");
}
private boolean hasExactQuote(String query) {
return query.contains("\"") || query.contains(""") || query.contains(""");
}
private boolean hasSpecificNumbers(String query) {
// 带单位的数字(如 256GB, 6.7英寸, 8核)
return query.matches(".*(\\d+)(GB|TB|MB|英寸|寸|核|线程|Hz|MHz|GHz|W).*");
}
private boolean isQuestion(String query) {
return query.endsWith("?") || query.endsWith("?") ||
query.startsWith("什么") || query.startsWith("怎么") ||
query.startsWith("如何") || query.startsWith("哪些") ||
query.startsWith("有没有") || query.startsWith("推荐");
}
private boolean isDescriptive(String query) {
return query.length() > 15; // 长查询通常是描述性的
}
private boolean hasIntentKeywords(String query) {
List<String> intentWords = List.of("推荐", "适合", "性价比", "好用", "找", "需要");
return intentWords.stream().anyMatch(query::contains);
}
private String expandQuery(String query) {
// 同义词扩展(实际应该用词典,这里简化)
Map<String, String> synonyms = Map.of(
"P图", "图片编辑 修图",
"拍照", "摄影 相机",
"手机", "智能手机 移动端",
"电脑", "笔记本 台式机"
);
String expanded = query;
for (Map.Entry<String, String> entry : synonyms.entrySet()) {
if (query.contains(entry.getKey())) {
expanded += " " + entry.getValue();
}
}
return expanded.trim();
}
public record QueryAnalysisResult(
String originalQuery, String expandedQuery,
double exactWeight, double semanticWeight,
boolean isQuestion, boolean hasSkuPattern
) {}
}融合检索执行器
/**
* 执行多路检索并融合结果
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class HybridSearchExecutor {
private final BM25SearchClient bm25Client;
private final VectorSearchClient vectorClient;
private final BusinessFilterService filterService;
/**
* 执行混合检索
*/
public List<SearchResult> search(SearchQuery searchQuery) {
QueryAnalyzer.QueryAnalysisResult analysis = searchQuery.getAnalysis();
// 并行执行多路检索
CompletableFuture<List<ScoredItem>> bm25Future =
CompletableFuture.supplyAsync(() ->
bm25Client.search(analysis.expandedQuery(), searchQuery.getTopK() * 3));
CompletableFuture<List<ScoredItem>> vectorFuture =
CompletableFuture.supplyAsync(() ->
vectorClient.search(analysis.originalQuery(), searchQuery.getTopK() * 3));
// 等待两路结果
List<ScoredItem> bm25Results;
List<ScoredItem> vectorResults;
try {
bm25Results = bm25Future.get(2, TimeUnit.SECONDS);
vectorResults = vectorFuture.get(2, TimeUnit.SECONDS);
} catch (TimeoutException e) {
log.warn("检索超时,使用已完成的结果");
bm25Results = bm25Future.isDone() ? bm25Future.join() : List.of();
vectorResults = vectorFuture.isDone() ? vectorFuture.join() : List.of();
} catch (Exception e) {
log.error("检索失败: {}", e.getMessage());
bm25Results = List.of();
vectorResults = List.of();
}
// RRF融合
Map<String, Double> fusedScores = rrfFusion(
bm25Results, analysis.exactWeight(),
vectorResults, analysis.semanticWeight()
);
// 业务过滤(库存、状态、权限等)
Map<String, Double> filteredScores = filterService.filter(
fusedScores, searchQuery.getFilters());
// 排序并返回
return filteredScores.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.limit(searchQuery.getTopK())
.map(e -> buildSearchResult(e.getKey(), e.getValue()))
.toList();
}
/**
* RRF融合(带权重)
*
* 标准RRF公式:1/(k + rank)
* 加权RRF:weight * 1/(k + rank)
*/
private Map<String, Double> rrfFusion(
List<ScoredItem> list1, double weight1,
List<ScoredItem> list2, double weight2) {
int k = 60; // RRF参数,控制排名差异的影响程度
Map<String, Double> scores = new LinkedHashMap<>();
// 处理list1(BM25)
for (int i = 0; i < list1.size(); i++) {
String id = list1.get(i).itemId();
double rrfScore = weight1 / (k + i + 1);
scores.merge(id, rrfScore, Double::sum);
}
// 处理list2(向量)
for (int i = 0; i < list2.size(); i++) {
String id = list2.get(i).itemId();
double rrfScore = weight2 / (k + i + 1);
scores.merge(id, rrfScore, Double::sum);
}
return scores;
}
private SearchResult buildSearchResult(String itemId, double score) {
// 从缓存或DB获取完整信息
return new SearchResult(itemId, score, null);
}
public record ScoredItem(String itemId, double score) {}
}学习排序(LTR):引入业务信号
/**
* 学习排序特征提取
* 把业务信号转化为排序特征
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class LearningToRankService {
private final ItemStatisticsService statsService;
private final UserBehaviorService userBehaviorService;
/**
* 为每个候选结果计算排序特征
* 这些特征输入到排序模型(LightGBM/XGBoost或简单的线性组合)
*/
public RankingFeatures extractFeatures(
String itemId,
String query,
String userId,
double baseScore) {
ItemStats stats = statsService.getStats(itemId);
UserItemInteraction history = userBehaviorService.getUserItemHistory(userId, itemId);
return RankingFeatures.builder()
// 检索分数(融合后的基础分)
.retrievalScore(baseScore)
// 热度特征
.clickRate7d(stats.getClickRate7d())
.conversionRate7d(stats.getConversionRate7d())
.salesVolume30d(normalizeLog(stats.getSalesVolume30d()))
// 质量特征
.avgRating(stats.getAvgRating() / 5.0) // 归一化到0-1
.reviewCount(normalizeLog(stats.getReviewCount()))
.completenessScore(stats.getCompletenessScore()) // 信息完整度
// 时效特征
.freshnessDays(Math.max(0, 30 - stats.getDaysSinceUpdate()) / 30.0)
// 个性化特征
.userClickBefore(history.hasClicked() ? 1.0 : 0.0)
.userBuyBefore(history.hasPurchased() ? 1.0 : 0.0)
.categoryAffinityScore(history.getCategoryAffinity())
// 查询-文档相关性特征(由检索层提供)
.bm25Score(stats.getBm25Score(query))
.titleMatchScore(calculateTitleMatch(query, stats.getTitle()))
.build();
}
/**
* 简单的线性组合排序(当没有训练好的LTR模型时)
*/
public double linearRankScore(RankingFeatures features) {
return features.retrievalScore() * 0.35 +
features.clickRate7d() * 0.15 +
features.conversionRate7d() * 0.20 +
features.avgRating() * 0.10 +
features.freshnessDays() * 0.05 +
features.categoryAffinityScore() * 0.10 +
features.titleMatchScore() * 0.05;
}
private double normalizeLog(double value) {
return value > 0 ? Math.log1p(value) / 10.0 : 0;
}
private double calculateTitleMatch(String query, String title) {
if (title == null) return 0;
// 计算查询词在标题中的覆盖率
String[] queryWords = query.split("\\s+");
long matched = Arrays.stream(queryWords)
.filter(title::contains)
.count();
return (double) matched / queryWords.length;
}
@Builder
public record RankingFeatures(
double retrievalScore, double clickRate7d, double conversionRate7d,
double salesVolume30d, double avgRating, double reviewCount,
double completenessScore, double freshnessDays,
double userClickBefore, double userBuyBefore, double categoryAffinityScore,
double bm25Score, double titleMatchScore
) {}
}搜索质量评估:离线评估体系
/**
* 搜索质量离线评估
* 基于人工标注的相关性判断计算NDCG/MRR
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class SearchQualityEvaluator {
/**
* 计算NDCG@K(Normalized Discounted Cumulative Gain)
*
* 相关性分级:
* 3 = 完全相关(用户正是想要这个)
* 2 = 相关(有帮助)
* 1 = 部分相关(有一点关系)
* 0 = 不相关
*/
public double calculateNdcg(List<SearchResult> results,
Map<String, Integer> relevanceJudgments, int k) {
// 实际结果的DCG
double dcg = calculateDcg(results.stream()
.limit(k)
.map(r -> relevanceJudgments.getOrDefault(r.itemId(), 0))
.toList(), k);
// 理想排序的IDCG(所有相关文档排在最前面)
double idcg = calculateDcg(
relevanceJudgments.values().stream()
.sorted(Comparator.reverseOrder())
.limit(k)
.toList(),
k
);
return idcg > 0 ? dcg / idcg : 0;
}
private double calculateDcg(List<Integer> relevances, int k) {
double dcg = 0;
for (int i = 0; i < Math.min(relevances.size(), k); i++) {
int rel = relevances.get(i);
// 标准DCG公式:(2^rel - 1) / log2(i+2)
dcg += (Math.pow(2, rel) - 1) / (Math.log(i + 2) / Math.log(2));
}
return dcg;
}
/**
* MRR(Mean Reciprocal Rank)
* 第一个相关结果的排名倒数
*/
public double calculateMrr(List<SearchResult> results,
Map<String, Integer> relevanceJudgments) {
for (int i = 0; i < results.size(); i++) {
int relevance = relevanceJudgments.getOrDefault(results.get(i).itemId(), 0);
if (relevance >= 2) { // 相关或完全相关
return 1.0 / (i + 1);
}
}
return 0.0;
}
/**
* A/B测试对比:两组结果的质量对比
*/
public ABTestResult compareSearchStrategies(
List<EvaluationQuery> queries,
SearchStrategy strategyA,
SearchStrategy strategyB) {
double ndcgSumA = 0, ndcgSumB = 0;
double mrrSumA = 0, mrrSumB = 0;
for (EvaluationQuery query : queries) {
List<SearchResult> resultsA = strategyA.search(query.getText());
List<SearchResult> resultsB = strategyB.search(query.getText());
ndcgSumA += calculateNdcg(resultsA, query.getRelevanceJudgments(), 10);
ndcgSumB += calculateNdcg(resultsB, query.getRelevanceJudgments(), 10);
mrrSumA += calculateMrr(resultsA, query.getRelevanceJudgments());
mrrSumB += calculateMrr(resultsB, query.getRelevanceJudgments());
}
int n = queries.size();
double avgNdcgA = ndcgSumA / n, avgNdcgB = ndcgSumB / n;
double avgMrrA = mrrSumA / n, avgMrrB = mrrSumB / n;
log.info("搜索策略对比: NDCG@10: A={:.4f}, B={:.4f}, Δ={:+.4f}",
avgNdcgA, avgNdcgB, avgNdcgB - avgNdcgA);
log.info("搜索策略对比: MRR: A={:.4f}, B={:.4f}, Δ={:+.4f}",
avgMrrA, avgMrrB, avgMrrB - avgMrrA);
return new ABTestResult(avgNdcgA, avgNdcgB, avgMrrA, avgMrrB,
(avgNdcgB - avgNdcgA) / avgNdcgA * 100);
}
public record ABTestResult(
double ndcgA, double ndcgB, double mrrA, double mrrB,
double ndcgImprovementPct
) {}
}上线策略建议
第一阶段:保守混合(不影响现有用户)
先用向量检索作为补充,只在BM25检索结果为空时触发:
// 保守策略:BM25优先,只有BM25无结果时才用向量
if (bm25Results.isEmpty() || bm25Results.size() < 3) {
List<SearchResult> vectorResults = vectorClient.search(query, topK);
return mergeResults(bm25Results, vectorResults, 0.3, 0.7);
}
return bm25Results;第二阶段:影子模式验证
两套策略并行运行,只展示BM25结果,但后台记录向量检索结果,离线对比质量:
// 主链路返回BM25结果(用户看到的)
List<SearchResult> mainResults = bm25Client.search(query, topK);
// 影子检索(不影响用户,只做记录)
CompletableFuture.runAsync(() -> {
List<SearchResult> shadowResults = hybridSearch(query, topK);
qualityLogger.logShadowComparison(query, mainResults, shadowResults);
});
return mainResults;第三阶段:灰度开放
把前两个阶段的数据作为依据,对10% → 30% → 100%用户开放混合检索。
评估混合搜索效果时,有一个简单的指标很好用:无结果率。用户搜索但没有任何结果的查询比例,这个指标在引入语义搜索后通常会显著下降,是很直观的质量提升证明。
