共计 2655 个字符,预计需要花费 7 分钟才能阅读完成。
背景痛点:为什么需要语义检索?
传统的关键词检索(如 TF-IDF、BM25)在电商搜索、客服问答等场景中经常遇到这些问题:

- 同义不同词:搜索 ” 笔记本电脑 ” 无法匹配含 ” 手提电脑 ” 的商品
- 语义鸿沟:查询 ” 适合程序员用的轻薄本 ” 可能返回无关结果
- 词序依赖:”Java 编程 ” 和 ” 编程 Java” 被当作完全不同查询
我们曾遇到一个典型案例:某电商平台搜索 ” 儿童雨鞋 ”,系统因缺乏语义理解,漏掉了标题含 ” 宝宝防水靴 ” 的高相关商品,导致转化率损失 15%。
技术选型:向量检索为何胜出?
传统 vs 现代方法对比
- TF-IDF/BM25
- 基于词频统计
- 无法捕捉词语间语义关系
-
对拼写错误敏感
-
向量检索
- 将文本映射到高维空间
- 相似语义聚集在相近坐标
- 支持语义级相似度计算
为什么选择 BERT?
- 上下文感知:能区分 ” 苹果手机 ” 和 ” 吃的苹果 ”
- 预训练优势:无需从头训练即可获得良好表征
- 丰富生态:Hugging Face 提供多种轻量级变体(如 MiniLM)
Elasticsearch 的向量支持
从 7.0 版本开始支持 dense_vector 字段类型,配合 script_score 查询可实现:
- 最近邻搜索(kNN)
- 余弦 / 欧式距离计算
- 与传统查询条件的混合使用
核心实现:三步构建语义搜索引擎
第一步:生成文本嵌入
推荐使用 sentence-transformers 库,相比原生 BERT 有这些优化:
- 专为句子级嵌入设计
- 预置适合检索的模型(如 multi-qa-MiniLM-L6-cos-v1)
- 自动处理归一化输出
from sentence_transformers import SentenceTransformer
model = SentenceTransformer('multi-qa-MiniLM-L6-cos-v1')
embeddings = model.encode(["儿童雨鞋"], convert_to_tensor=True)
第二步:设计 ES 索引
关键映射配置:
{
"mappings": {
"properties": {
"title": {"type": "text"},
"title_vector": {
"type": "dense_vector",
"dims": 384,
"index": true,
"similarity": "cosine"
}
}
}
}
注意:
– dims需与模型输出维度一致
– 启用 index 才能使用近似 kNN 搜索
– 相似度度量根据场景选择(余弦适合语义检索)
第三步:执行混合查询
结合语义搜索与传统过滤条件:
{
"query": {
"script_score": {
"query": {
"bool": {
"filter": [{"term": {"category": "shoes"}}
]
}
},
"script": {"source": "cosineSimilarity(params.query_vector,'title_vector') + 1.0",
"params": {"query_vector": [0.12, -0.45, ..., 0.67]
}
}
}
}
}
加分项:
– +1.0将分数调整到正数区间
– 可添加 boost 参数调整权重
完整代码示例
import elasticsearch
from sentence_transformers import SentenceTransformer
from tqdm import tqdm
# 初始化
model = SentenceTransformer('multi-qa-MiniLM-L6-cos-v1')
es = elasticsearch.Elasticsearch()
# 批量生成向量
def batch_encode(texts, batch_size=32):
return [model.encode(texts[i:i + batch_size])
for i in range(0, len(texts), batch_size)
]
# 索引数据
def index_docs(docs):
for doc in tqdm(docs):
vector = model.encode(doc["title"])[0].tolist()
es.index(
index="products",
body={"title": doc["title"], "title_vector": vector}
)
# 语义搜索
def semantic_search(query, top_k=5):
query_vector = model.encode(query)[0].tolist()
resp = es.search(
index="products",
body={
"size": top_k,
"query": {
"script_score": {"query": {"match_all": {}},
"script": {"source": "cosineSimilarity(params.query_vector,'title_vector') + 1.0",
"params": {"query_vector": query_vector}
}
}
}
}
)
return [hit["_source"] for hit in resp["hits"]["hits"]]
生产环境优化策略
性能平衡
- 索引时:
- 开启
index_threads提高写入吞吐 -
使用
bulkAPI 减少网络开销 -
查询时:
- 限制
num_candidates(通常 100-200) - 对高频查询使用缓存
资源管理
- 384 维向量在 100 万文档时约占用 1.5GB 内存
- 可通过量化压缩减少 50%+ 存储
模型更新
推荐方案:
- 新模型创建新索引
products_v2 - 双写新旧索引
- 流量逐步切到新索引
- 验证效果后下线旧索引
常见问题避坑指南
混合查询陷阱
错误做法:
{
"should": [{"match": {"title": "雨鞋"}},
{"vector": {...}}
]
}
正确做法:
– 先用 bool.filter 缩小范围
– 再用 script_score 计算语义分
冷启动方案
- 初始阶段混合传统搜索:
final_score = 0.7 * semantic_score + 0.3 * bm25_score - 收集用户点击数据后训练排序模型
多语言处理
- 单语言场景:选用特定语言模型(如 paraphrase-multilingual-MiniLM-L12-v2)
- 跨语言搜索:建议使用 mBERT 等多语言模型
开放思考:结果可解释性
当前方案的 ” 黑盒 ” 特性可能导致:
– 业务方难以理解排序逻辑
– 难以调试 bad case
可能的解决方向:
1. 返回匹配片段的高亮
2. 可视化向量空间分布
3. 结合传统相关性分数作为参考
期待大家在评论区分享实战经验!
正文完
发表至: 人工智能
近三天内
