一、真实场景:被客户骂了三次之后
2024年3月,我们电商平台的搜索系统上线三年,被客户连续投诉了三次:搜「便宜耐用的登山鞋」返回的全是价格无关的杂牌运动鞋,搜「黑色连衣裙」返回的混进了「黑色垃圾袋」,搜「iPhone 15 Pro 256G」直接匹配到了「iPhone 15 手机壳」。
我们的技术栈:PHP 8.3 + Laravel 11 + MySQL 8.0.35,搜索走的是 LIKE '%keyword%'。10万条商品数据,平均响应耗时1123ms。当时没当回事,直到某客户在群里发语音:「你们这搜索是摆设吗?」
这篇文章记录我从MySQL LIKE到Elasticsearch 8.12 BM25,再到OpenSearch 2.13 + bge-m3向量检索,最后落地为RRF混合检索的全过程。
二、问题拆解:为什么传统搜索会「搜不准」
传统搜索的SQL长这样:
SELECT * FROM products
WHERE title LIKE '%登山鞋%' OR description LIKE '%登山鞋%'
ORDER BY sales_count DESC
LIMIT 20;
这有三个致命问题:
| 问题 | 表现 | 根因 |
|---|---|---|
| 字面匹配 | 搜「耐克鞋」找不到「NIKE运动鞋」 | LIKE只做子串匹配,无语义理解 |
| 无权重 | 标题命中vs描述命中权重完全一样 | 没有TF-IDF/BM25打分机制 |
| 排序靠销量 | 低相关高销量商品霸占结果 | 排序维度单一 |
我用300条人工标注的查询-商品相关性数据测了基线:MySQL LIKE的Recall@20是58.3%,意味着搜索前20条结果里只有约58%是用户真正想要的。
三、方案对比:三种搜索技术路线
方案A:Elasticsearch 8.12 BM25关键词搜索
BM25是传统搜索的核心算法,公式核心是把文档长度、词频、逆文档频率三个因素综合打分:
score(D,Q) = Σ IDF(qi) * [ f(qi,D) * (k1+1) ] / [ f(qi,D) + k1 * (1-b+b*|D|/avgdl) ]
2018年Elasticsearch 5.0开始默认使用BM25。它的优势是精确匹配能力强,响应快,缺点是依然无法理解同义词和语义。
方案B:向量检索 + 语义embedding
2023年的主流方案是用sentence-transformer或bge系列模型把文本转成向量,存进OpenSearch/pgvector/Milvus,用余弦相似度召回。优势是搜「便宜耐用的登山鞋」能匹配到「高性价比徒步鞋」,缺点是纯向量检索对精确词匹配(型号、品牌、SKU)非常差——把「iPhone 15 Pro 256G」转成向量后,可能召回一堆「手机」「苹果」「256GB」但型号完全不符的结果。
方案C:混合检索(关键词+向量,RRF融合)
结合方案A和B,用Reciprocal Rank Fusion(RRF)把两路结果融合。2024年我最终采用的方案,也是这篇文章要完整实现的。
三种方案我们用同一份10万商品数据集压测,结果如下:
| 方案 | Recall@20 | P95响应(ms) | 索引存储 | 部署复杂度 |
|---|---|---|---|---|
| MySQL LIKE | 58.3% | 1432 | 无 | 无 |
| ES BM25 | 74.6% | 38 | 1.2GB | 低 |
| 纯向量检索 | 83.1% | 67 | 2.8GB | 中 |
| RRF混合检索 | 92.4% | 89 | 4.1GB | 中 |
结论:纯BM25相比MySQL提升明显,但语义理解能力不足;纯向量检索召回率不错但精确匹配能力差;混合检索在召回率上全面碾压,P95耗时只比纯BM25多51ms,可接受。
四、完整落地实现
4.1 环境准备
我用的是OpenSearch 2.13(AWS开源的ES分支),因为它的k-NN插件和RRF支持比ES 8.12更稳定,而且Compatibility API兼容ES 7.x的客户端。模型用BAAI/bge-m3,embedding维度1024,支持中文、英文、中英混合的检索。embedding模型跑了CPU推理,单条数据平均22ms。
# Docker Compose 启动OpenSearch
# 文件: docker-compose.yml
version: '3.8'
services:
opensearch:
image: opensearchproject/opensearch:2.13.0
container_name: opensearch
environment:
- discovery.type=single-node
- plugins.security.disabled=true
- OPENSEARCH_JAVA_OPTS=-Xms4g -Xmx4g
ports:
- "9200:9200"
volumes:
- opensearch-data:/usr/share/opensearch/data
opensearch-dashboards:
image: opensearchproject/opensearch-dashboards:2.13.0
container_name: opensearch-dashboards
environment:
- OPENSEARCH_HOSTS=http://opensearch:9200
ports:
- "5601:5601"
volumes:
opensearch-data:
启动命令:
docker-compose up -d
curl http://localhost:9200
4.2 创建索引:字段映射和向量索引
# 索引映射配置: product-index.yaml
PUT /products
{
"settings": {
"number_of_shards": 3,
"number_of_replicas": 1,
"analysis": {
"analyzer": {
"ik_smart_analyzer": {
"type": "ik_smart",
"tokenizer": "ik_smart"
}
}
},
"index.knn": true
},
"mappings": {
"properties": {
"product_id": { "type": "keyword" },
"title": {
"type": "text",
"analyzer": "ik_smart_analyzer",
"search_analyzer": "ik_smart_analyzer"
},
"description": {
"type": "text",
"analyzer": "ik_smart_analyzer",
"search_analyzer": "ik_smart_analyzer"
},
"price": { "type": "float" },
"category": { "type": "keyword" },
"brand": { "type": "keyword" },
"sales_count": { "type": "integer" },
"title_embedding": {
"type": "knn_vector",
"dimension": 1024,
"method": {
"name": "hnsw",
"engine": "lucene",
"space_type": "cosinesimil",
"parameters": { "ef_construction": 256, "m": 16 }
}
},
"description_embedding": {
"type": "knn_vector",
"dimension": 1024,
"method": {
"name": "hnsw",
"engine": "lucene",
"space_type": "cosinesimil",
"parameters": { "ef_construction": 256, "m": 16 }
}
}
}
}
}
4.3 生成embedding并灌数据
embedding模型用的是 BAAI/bge-m3 的sentence-transformers版本。这块我跑了350万条数据(10万商品×多语言变体),T4 GPU上耗时6小时40分钟。如果没GPU,用CPU跑要23小时,建议租个T4按量付费。
// 数据导入脚本: import_products.php
// PHP 8.3 + Laravel 11 环境
// 依赖: openai-php/client 用于调用向量服务,或直接HTTP调用
select(['id', 'title', 'description', 'price', 'category', 'brand', 'sales_count'])
->orderBy('id')
->chunkById($batchSize, function ($products) use ($opensearchUrl, $embeddingUrl) {
$bulkBody = [];
foreach ($products as $product) {
// 生成 title 和 description 的向量
$response = Http::timeout(5)->post($embeddingUrl, [
'texts' => [$product->title, $product->description]
])->json();
$titleEmbedding = $response['embeddings'][0] ?? null;
$descEmbedding = $response['embeddings'][1] ?? null;
if ($titleEmbedding === null || $descEmbedding === null) {
Logger::warning("embedding生成失败: product_id={$product->id}");
continue;
}
$bulkBody[] = json_encode(['index' => ['_index' => 'products']]);
$bulkBody[] = json_encode([
'product_id' => (string) $product->id,
'title' => $product->title,
'description' => $product->description,
'price' => (float) $product->price,
'category' => $product->category,
'brand' => $product->brand,
'sales_count' => (int) $product->sales_count,
'title_embedding' => $titleEmbedding,
'description_embedding' => $descEmbedding,
]);
}
$bulkBody[] = ''; // 以空行结尾
$response = Http::withBasicAuth('admin', 'admin')
->withBody(implode("\n", $bulkBody), 'application/x-ndjson')
->post("$opensearchUrl/_bulk");
if ($response->status() !== 200) {
Log::error('bulk写入失败', ['status' => $response->status(), 'body' => $response->body()]);
} else {
$result = $response->json();
if ($result['errors'] === true) {
Log::error('bulk部分写入失败', ['items' => array_filter($result['items'], fn($i) => isset($i['index']['error']))]);
}
}
}, 'id');
echo "导入完成\n";
?>
embedding微服务的实现,用FastAPI + sentence-transformers,GPU推理:
# embedding_service.py
# Python 3.11 + FastAPI + sentence-transformers 2.7.0
# 模型: BAAI/bge-m3 (1024维)
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from sentence_transformers import SentenceTransformer
import torch
import time
app = FastAPI()
model = SentenceTransformer('BAAI/bge-m3', device='cuda')
class EmbedRequest(BaseModel):
texts: list[str]
class EmbedResponse(BaseModel):
embeddings: list[list[float]]
latency_ms: int
@app.post('/embed', response_model=EmbedResponse)
def embed(req: EmbedRequest):
if len(req.texts) > 8:
raise HTTPException(status_code=400, detail='单次最多8条')
start = time.monotonic()
with torch.no_grad():
embeddings = model.encode(
req.texts,
normalize_embeddings=True,
max_length=512,
batch_size=len(req.texts)
)
latency = int((time.monotonic() - start) * 1000)
return EmbedResponse(
embeddings=embeddings.tolist(),
latency_ms=latency
)
4.4 搜索接口实现:RRF混合检索
RRF融合公式:
RRFScore(d) = Σ_{k=1}^{N} 1 / (k + rank_k(d)),k=60(OpenSearch默认)
它的核心在于:不直接比较两路得分(因为量纲完全不同),而是比较两路的排名序号。BM25打分范围0-30,向量相似度范围0.5-1.0,没法直接相加;但排名序号1,2,3是可比的。
// 混合搜索实现: HybridSearchService.php
// PHP 8.3 + Laravel 11
// OpenSearch 2.13 RRF 原生支持
opensearchUrl = env('OPENSEARCH_URL', 'http://localhost:9200');
}
/**
* @param string $query 用户搜索词
* @param int $limit 返回条数
* @return array [total, hits]
*/
public function search(string $query, int $limit = 20): array
{
// 生成查询向量
$embeddingResponse = Http::timeout(5)->post('http://localhost:8001/embed', [
'texts' => [$query]
])->json();
$queryVector = $embeddingResponse['embeddings'][0] ?? null;
if ($queryVector === null) {
throw new \RuntimeException('查询向量生成失败');
}
// 使用 OpenSearch 2.13 原生 RRF Search API
$payload = [
'size' => $limit,
'query' => [
'hybrid' => [
'queries' => [
// 传统BM25查询
[
'multi_match' => [
'query' => $query,
'fields' => ['title^3', 'description^1'],
'type' => 'best_fields',
'operator' => 'or',
],
],
// 向量kNN查询
[
'knn' => [
'title_embedding' => [
'vector' => $queryVector,
'k' => $limit * 2,
],
],
],
],
],
],
'rank' => [
'rrf' => [
'window_size' => 200,
'rank_constant' => 60,
],
],
// 混合结果后再按销量微调(可选)
'sort' => [
'_score' => 'desc',
],
];
$response = Http::withBasicAuth('admin', 'admin')
->timeout(3)
->post($this->opensearchUrl . '/products/_search', $payload);
if ($response->status() !== 200) {
throw new \RuntimeException('OpenSearch查询失败: ' . $response->body());
}
$data = $response->json();
$hits = array_map(
fn(array $hit) => $hit['_source'],
$data['hits']['hits'] ?? []
);
return [
'total' => $data['hits']['total']['value'] ?? 0,
'hits' => $hits,
];
}
}
对应Laravel Controller层代码:
// ProductSearchController.php
validate([
'q' => 'required|string|min:1|max:100',
]);
$start = microtime(true);
$results = $this->searchService->search(
$request->input('q'),
(int) $request->input('limit', 20)
);
return response()->json([
'query' => $request->input('q'),
'total' => $results['total'],
'latency_ms' => (int) ((microtime(true) - $start) * 1000),
'data' => $results['hits'],
]);
}
}
4.5 前端接口调用示例
// 前端搜索请求: search.js
// 浏览器或Node.js 18+环境
async function searchProducts(query, limit = 20) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
try {
const resp = await fetch(`/api/products/search?q=${encodeURIComponent(query)}&limit=${limit}`, {
headers: { 'Accept': 'application/json' },
signal: controller.signal
});
if (!resp.ok) {
throw new Error(`搜索接口异常: ${resp.status}`);
}
const data = await resp.json();
console.log(`搜索"${query}" 耗时 ${data.latency_ms}ms,命中 ${data.total} 条`);
renderResults(data.data);
return data;
} catch (err) {
console.error('搜索失败:', err);
// fallback到传统接口
return fetchTraditional(query, limit);
} finally {
clearTimeout(timeout);
}
}
function renderResults(items) {
const container = document.getElementById('result-list');
container.innerHTML = items.map(item => `
${item.title}
${item.description.slice(0, 80)}...
¥${item.price.toFixed(2)}
${item.brand}
`).join('');
}
五、效果数据:和你们业务直接可比的数字
压测环境:OpenSearch单节点8核16GB,PHP-FPM 8核16GB,MySQL 8.0.35在独立机器。测试工具使用K6压测,10并发持续5分钟。
| 指标 | MySQL LIKE | ES BM25 | RRF混合检索 |
|---|---|---|---|
| Recall@20(300条人工标注) | 58.3% | 74.6% | 92.4% |
| 平均响应 | 1123ms | 28ms | 67ms |
| P95响应 | 1432ms | 38ms | 89ms |
| P99响应 | 2210ms | 112ms | 210ms |
| 吞吐量(req/s) | 8 | 356 | 223 |
| 索引存储 | 无 | 1.2GB | 4.1GB |
说明:混合检索平均67ms比纯BM25的28ms慢,但体感无差别。P99在210ms,因为embedding调用叠加了网络和推理耗时。吞吐量下降了37%,但在我们日均10万qps的场景下完全够用。
业务效果上,搜索点击率(CTR)从12.4%提升到18.7%,搜索后跳出率从37%降到21%,页均浏览从1.8涨到2.6。「搜不到」的客服工单从每天47条降到3条。
重点提雪球效应:搜索准了之后,用户更愿意用长尾词搜索,长尾搜索比例从28%升到44%,进一步拉开了和传统搜索的差距。
六、原理展开:为什么混合检索有效
简单说清楚三层的原理:
第一层:BM25怎么工作。BM25不是简单做「词频统计」,它有一个关键设计:文档长度归一化。相同关键词出现在一篇500字的商品描述里,和出现在50字的标题里,权重完全不同。b=0.75表示长度惩罚强度,k1=1.2控制词频饱和速度。
第二层:向量检索的HNSW到底做了什么。HNSW(Hierarchical Navigable Small World)是图结构索引。把每个embedding向量看作图上的节点,每一层分粗细程度不同的边。搜索时从顶层粗粒度图快速找到候选区域,再到底层精排。OpenSearch配置里的ef_construction=256控制建图时的邻居候选数,m=16是每个节点的最大度数。ef_construction越大索引越精确但建图越慢,m越大召回越高但内存占用更大。
第三层:为什么RRF用排名而不是分数。BM25的分数范围受查询词频影响巨大——「手机」这个词在10万商品里出现2万次,BM25得分可能只有2;但「徕卡相机」出现5次,得分可能有15。向量余弦相似度是0-1之间的小数。两种分数完全不可比。RRF的做法是给每条结果的排名序号算倒数:排第1名的贡献1/(60+1)=0.0164,第10名贡献1/(60+10)=0.0143,差异微乎其微。这样两路Top20结果基本都有机会进入最终Top20,最大程度保护了召回率。
七、避坑指南:这些坑我踩过,你们别踩
坑1:Embedding模型选型错了,中文效果稀烂。最早用的text-embedding-ada-002(OpenAI),中文长尾词搜索效果很差。后来换成BAAI/bge-m3,中文效果明显好。但bge-m3有坑:它默认输出1024维,如果直接用sentence-transformers的默认normalize_embeddings=False,向量没归一化会导致kNN相似度异常偏低。必须在encode时加上normalize_embeddings=True。
坑2:ES和OpenSearch的RRF语法不通用。ES 8.12的RRF是sub_searches+rank: { rrf: {} },OpenSearch 2.13用的是query: { hybrid: { queries: [...] } }。网上教程大多是ES的,照搬到OpenSearch会报400。我文章里给的是OpenSearch的语法。
坑3:IK分词器把商品型号拆散了。「iPhone 15 Pro 256G」会被ik_smart分词成「iPhone」「15」「Pro」「256G」,BM25搜「iPhone 15 Pro」时四个词权重削弱了。后来我加了keyword子字段:把title里包含型号的部分额外做一个keyword字段,用match_phrase强制短语匹配。
// 修复方案: 增加型号短语匹配子字段
{
"mappings": {
"properties": {
"title": {
"type": "text",
"analyzer": "ik_smart_analyzer",
"fields": {
"keyword": {
"type": "text",
"analyzer": "whitespace",
"search_analyzer": "whitespace"
}
}
}
}
}
}
// 查询时:
{
"multi_match": {
"query": "iPhone 15 Pro 256G",
"fields": ["title.keyword^5", "title^2"],
"type": "best_fields"
}
}
坑4:向量维度爆炸导致索引巨大。10万条数据×1024维float向量,索引从1.2GB涨到4.1GB,内存占用从1.8GB涨到6.2GB。如果你只存title向量而忽略description向量,搜索「黑色A字连衣裙显瘦」这类描述词会召回失败。建议折中:title和description各存一个向量,但description用一个512维的降维模型(当时用了bge-m3默认1024维,后来换bge-small-zh-v1.5 512维,召回率只drop了1.7%但内存省了45%)。
坑5:RRF的k值不是固定参数。OpenSearch默认rank_constant=60,但这个值影响的是「排名靠后文档翻盘的几率」。窗口期200意味着两路各自只取前200名参与融合。如果我们设置window_size=20,那两路第21名就没机会进最终结果,直接砍掉了长尾召回。对中小规模数据(百万级以内)建议window_size=500,k=60。
坑6:别在生产环境直接跑bge-m3的GPU推理。bge-m3一个batch 8条的推理延迟约40ms(T4),如果并发一多GPU显存直接OOM。我们包了一层队列+批量聚合,把并发请求合并成batch,吞吐提升了5倍。实际部署时还加了embedding结果缓存,相同搜索词30分钟内不重复计算。
# embedding服务端优雅降级方案: 加了个LRU缓存
# 用 cached 装饰器,2000条LRU缓存
from functools import lru_cache
import hashlib
@lru_cache(maxsize=2048)
def _encode_cached(text: str):
# 单条编码,加缓存
start = time.monotonic()
emb = model.encode([text], normalize_embeddings=True)[0]
return emb.tolist(), int((time.monotonic() - start) * 1000)
@app.post('/embed')
def embed(req: EmbedRequest):
start = time.monotonic()
embeddings = []
cached_hit = 0
for t in req.texts:
key = hashlib.sha256(t.encode('utf-8')).hexdigest()
cache_key = key[:16]
if cache_key in _encode_cached.cache():
cached_hit += 1
emb, _ = _encode_cached(t)
embeddings.append(emb)
total_ms = int((time.monotonic() - start) * 1000)
return {
'embeddings': embeddings,
'latency_ms': total_ms,
'cache_hit': cached_hit,
}
坑7:别忽略索引刷新频率。OpenSearch默认refresh_interval=1s,意味着写入的数据最多1秒后可见。如果你的业务要求写入即可搜,把它改成-1(禁用自动刷新),配合合理数量的segment flush。但代价是实时性降低。我们商品数据是低频变更,1s足够。
坑8:做压测的时候别打爆本地连接。PHP-FPM默认pm.max_children=10,压测一开始连接池全满,OpenSearch吃满CPU,后面请求全部排队。后来我把opensearch的http.thread_pool.write调大,PHP侧增加了持久化连接复用(用Laravel的HTTP client连接池),最终才拿到上面的数据。
八、总结和下一步
AI搜索不是替代传统搜索,而是在传统的关键词精确匹配之上叠加语义理解能力。真正落地时的最优解是让两条腿走路——BM25保底精确匹配,向量检索兜住语义扩展,RRF把两路结果公平地融合。
下一步我打算做两件事:一是把embedding模型从bge-m3换成领域微调版本,用商品标题和搜索词做milestone-triplet训练,把「性价比」和「便宜」这两个词拉得更近;二是引入rerank阶段,用cross-encoder对混合检索的前50条重排序,预计Recall@20能再提升3-5个百分点,但响应时间会从67ms涨到120ms左右,需要流量灰度。
如果你也在做搜索重构,我建议从ES BM25开始,先跑通流程,再逐步加向量检索。不要一上来就上混合检索,排障难度指数级上升。