使用 Elasticsearch 中的地理语义搜索增强推荐功能
'# 使用 Elasticsearch 中的地理语义搜索增强推荐功能
一、背景与问题
在电商推荐系统、LBS(基于地理位置服务)场景中,单纯的地理位置过滤往往无法满足复杂的业务需求。例如:
- 一个用户在杭州西湖边想寻找周边的咖啡馆,但不仅仅要距离近的,还要推荐评分高、价格适中、且有户外座位的场所
- 一个旅游App需要根据用户当前位置,推荐既符合地理邻近性,又符合用户兴趣偏好的景点
传统方案的局限性:
- 仅使用
geo_distance或geo_bounding_box进行空间过滤,无法结合业务语义 - 无法实现"用户当前位置与推荐对象的地理语义相关性"的量化分析
- 缺乏对多维特征(如价格、评分、类别)与地理位置的联合建模能力
Elasticsearch的地理语义搜索通过以下机制解决上述问题:
- 将地理位置转化为向量化表示
- 通过
dense_vector字段存储业务特征向量 - 利用
knn(近似最近邻)算法实现地理+语义的联合检索 - 支持多维特征(价格、评分、类别)与地理坐标的联合排序
二、基本原理
Elasticsearch的地理语义搜索基于以下技术栈:
- Geo Point:存储经纬度坐标
- dense_vector:存储向量特征(如商品属性、用户偏好)
- knn_search:基于向量相似度的近似最近邻算法
- Geo Shape:支持多边形、多边形范围查询
- 脚本分数:自定义计算地理距离+语义相似度的评分函数
核心公式:
score = alpha * (1 - cosine_similarity(vector_a, vector_b)) +
beta * (1 / (1 + geo_distance_km))其中alpha和beta是权重参数,控制语义和地理因素的贡献比例。
三、环境准备
# 安装Elasticsearch 8.6.2(支持dense_vector)
curl -L https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-8.6.2-linux-x86_64.tar.gz | tar xz四、核心实现
1. 索引创建与数据存储
# Python示例(使用elasticsearch库)
from elasticsearch import Elasticsearch
from elasticsearch.helpers import bulk
# 创建索引
body = {
"settings": {
"number_of_shards": 1,
"number_of_replicas": 1,
"similarity": {
"default": {
"type": "script_score",
"script": {
"source": """
double geoDistance = 6371000 *
Math.acos(
Math.cos(radians(lat1)) *
Math.cos(radians(lat2)) *
Math.cos(radians(lon2 - lon1)) +
Math.sin(radians(lat1)) *
Math.sin(radians(lat2))
);
return geoDistance;
""",
"params": {
"lat1": 30.2441, # 假设用户当前位置
"lon1": 120.1469
}
}
}
}
},
"mappings": {
"properties": {
"location": {
"type": "geo_point"
},
"category": {
"type": "keyword"
},
"features": {
"type": "dense_vector",
"dims": 5 # 假设5维特征向量
},
"price": {
"type": "float"
},
"rating": {
"type": "float"
}
}
}
}
es.indices.create(index="locations", body=body)2. 地理语义查询实现
# 地理+语义混合查询
query = {
"query": {
"script_score": {
"script": {
"source": """
double geoDistance = 6371000 *
Math.acos(
Math.cos(radians(lat1)) *
Math.cos(radians(lat2)) *
Math.cos(radians(lon2 - lon1)) +
Math.sin(radians(lat1)) *
Math.sin(radians(lat2))
);
double cosSim = 1.0 - cosineSimilarity(params.vector, doc['features']);
double score = 0.7 * (1.0 / (1.0 + geoDistance)) +
0.3 * (1.0 - cosSim);
return score;
""",
"params": {
"lat1": 30.2441,
"lon1": 120.1469,
"vector": [0.8, 0.2, 0.5, 0.1, 0.4] # 用户特征向量
}
}
}
}
}
# 使用knn进行向量相似度查询
knn_query = {
"query": {
"knn": {
"field": "features",
"k": 5,
"num_candidates": 100
}
}
}3. 多维特征加权评分
# 自定义评分函数(结合价格、评分、地理距离)
query = {
"query": {
"script_score": {
"script": {
"source": """
double geoDistance = 6371000 *
Math.acos(
Math.cos(radians(lat1)) *
Math.cos(radians(lat2)) *
Math.cos(radians(lon2 - lon1)) +
Math.sin(radians(lat1)) *
Math.sin(radians(lat2))
);
double priceFactor = (1.0 - doc['price']) / 50.0; // 价格越低权重越高
double ratingFactor = doc['rating'] / 5.0; // 评分越高权重越高
double cosSim = 1.0 - cosineSimilarity(params.vector, doc['features']);
double score = 0.4 * (1.0 / (1.0 + geoDistance)) +
0.3 * priceFactor +
0.2 * ratingFactor +
0.1 * (1.0 - cosSim);
return score;
""",
"params": {
"lat1": 30.2441,
"lon1": 120.1469,
"vector": [0.8, 0.2, 0.5, 0.1, 0.4]
}
}
}
}
}五、完整案例
1. 电商推荐系统案例
业务需求:
用户在杭州西湖边(30.2441, 120.1469)寻找附近的咖啡馆,要求推荐:
- 距离不超过2公里
- 评分>=4.0
- 价格<=30元
- 同时与用户特征向量[0.8, 0.2, 0.5, 0.1, 0.4]相似度>0.8
索引设计:
{
"settings": {
"number_of_shards": 3,
"number_of_replicas": 1
},
"mappings": {
"properties": {
"location": { "type": "geo_point" },
"category": { "type": "keyword" },
"features": { "type": "dense_vector", "dims": 5 },
"price": { "type": "float" },
"rating": { "type": "float" }
}
}
}查询构建:
# 构建复合查询
query = {
"query": {
"bool": {
"must": [
{
"geo_distance": {
"location": {
"lat": 30.2441,
"lon": 120.1469
},
"distance": "2km"
}
},
{
"range": {
"price": {
"lte": 30
}
}
},
{
"range": {
"rating": {
"gte": 4.0
}
}
}
],
"should": [
{
"script_score": {
"script": {
"source": """
double geoDistance = 6371000 *
Math.acos(
Math.cos(radians(lat1)) *
Math.cos(radians(lat2)) *
Math.cos(radians(lon2 - lon1)) +
Math.sin(radians(lat1)) *
Math.sin(radians(lat2))
);
double cosSim = 1.0 - cosineSimilarity(params.vector, doc['features']);
return 0.8 * (1.0 / (1.0 + geoDistance)) +
0.2 * (1.0 - cosSim);
""",
"params": {
"lat1": 30.2441,
"lon1": 120.1469,
"vector": [0.8, 0.2, 0.5, 0.1, 0.4]
}
}
}
}
]
}
},
"sort": [
{
"_script": {
"type": "number",
"script": {
"source": """
double geoDistance = 6371000 *
Math.acos(
Math.cos(radians(lat1)) *
Math.cos(radians(lat2)) *
Math.cos(radians(lon2 - lon1)) +
Math.sin(radians(lat1)) *
Math.sin(radians(lat2))
);
double priceFactor = (1.0 - doc['price']) / 50.0;
double ratingFactor = doc['rating'] / 5.0;
double cosSim = 1.0 - cosineSimilarity(params.vector, doc['features']);
return 0.4 * (1.0 / (1.0 + geoDistance)) +
0.3 * priceFactor +
0.2 * ratingFactor +
0.1 * (1.0 - cosSim);
""",
"params": {
"lat1": 30.2441,
"lon1": 120.1469,
"vector": [0.8, 0.2, 0.5, 0.1, 0.4]
}
},
"order": "desc"
}
}
]
}六、源码解析
1. geo_distance计算原理
// Elasticsearch的GeoDistance计算核心逻辑
public static double computeGeoDistance(double lat1, double lon1, double lat2, double lon2) {
double lat1Rad = Math.toRadians(lat1);
double lon1Rad = Math.toRadians(lon1);
double lat2Rad = Math.toRadians(lat2);
double lon2Rad = Math.toRadians(lon2);
double cosLat1 = Math.cos(lat1Rad);
double cosLat2 = Math.cos(lat2Rad);
double cosLat1CosLat2 = cosLat1 * cosLat2;
double sinLat1SinLat2CosLonDiff = Math.sin(lat1Rad) * Math.sin(lat2Rad) * Math.cos(lon2Rad - lon1Rad);
double cosAngle = cosLat1CosLat2 + sinLat1SinLat2CosLonDiff;
cosAngle = Math.min(1.0, Math.max(-1.0, cosAngle)); // 防止数值误差
double angle = Math.acos(cosAngle);
return 6371000 * angle; // 地球半径
}2. dense_vector相似度计算
// 使用HNSW算法计算向量相似度
public static double cosineSimilarity(double[] vec1, double[] vec2) {
double dot = 0.0;
double norm1 = 0.0;
double norm2 = 0.0;
for (int i = 0; i < vec1.length; i++) {
dot += vec1[i] * vec2[i];
norm1 += Math.pow(vec1[i], 2);
norm2 += Math.pow(vec2[i], 2);
}
return dot / (Math.sqrt(norm1) * Math.sqrt(norm2));
}七、进阶使用
1. 多维特征归一化处理
# 在索引创建时进行特征归一化
body = {
"mappings": {
"properties": {
"features": {
"type": "dense_vector",
"dims": 5,
"similarity": "cosine",
"index": True,
"store": True
}
}
}
}2. 动态权重调整
# 基于用户历史行为动态调整权重
query = {
"query": {
"script_score": {
"script": {
"source": """
double geoDistance = 6371000 *
Math.acos(
Math.cos(radians(lat1)) *
Math.cos(radians(lat2)) *
Math.cos(radians(lon2 - lon1)) +
Math.sin(radians(lat1)) *
Math.sin(radians(lat2))
);
double cosSim = 1.0 - cosineSimilarity(params.vector, doc['features']);
double score = params.alpha * (1.0 / (1.0 + geoDistance)) +
params.beta * (1.0 - cosSim);
return score;
""",
"params": {
"lat1": 30.2441,
"lon1": 120.1469,
"vector": [0.8, 0.2, 0.5, 0.1, 0.4],
"alpha": 0.6,
"beta": 0.4
}
}
}
}
}八、性能与工程实践
1. 索引优化策略
- 使用
dense_vector时,设置similarity: cosine - 对价格、评分等字段添加
keyword类型索引 - 对地理字段使用
geo_point类型 - 启用
fielddata缓存提升排序性能
2. 查询性能优化
- 使用
filter上下文进行地理范围过滤 - 对价格、评分等静态字段使用
range查询 - 使用
script_score的cache参数缓存计算结果 - 对动态权重参数使用
script_params进行预计算
3. 安全风险控制
- 对地理位置数据进行脱敏处理
- 对敏感字段(如用户特征向量)进行加密存储
- 使用
search_type: dfs_query_and_fetch避免分片不均衡影响结果 - 对
script参数进行严格的类型校验
九、常见问题与踩坑
1. 地理坐标格式错误
# 错误示例:不规范的经纬度格式
{
"location": "30.2441,120.1469" # 正确格式
}2. 向量相似度计算不准确
# 错误示例:未进行归一化处理
def cosine_similarity(vec1, vec2):
return sum(a*b for a,b in zip(vec1, vec2))3. 索引创建失败
# 错误示例:未指定dense_vector的维度
{
"mappings": {
"properties": {
"features": { "type": "dense_vector" } # 错误:缺少dims参数
}
}
}4. 排序性能瓶颈
# 错误示例:未使用script_score的cache参数
{
"sort": [
{
"_script": {
"script": {
"source": "..." # 未启用cache
}
}
}
]
}十、最佳实践
数据预处理
- 地理坐标应存储为
geo_point类型 - 向量特征需进行标准化处理(0-1范围)
- 静态字段(价格、评分)应使用
keyword类型索引
- 地理坐标应存储为
查询设计
- 先使用
geo_distance过滤地理范围 - 再使用
script_score进行语义排序 - 对价格、评分等静态字段使用
range过滤
- 先使用
性能优化
- 使用
filter上下文进行地理范围过滤 - 对动态权重参数进行预计算
- 启用
fielddata缓存提升排序性能
- 使用
安全措施
- 对敏感数据进行加密存储
- 使用
search_type: dfs_query_and_fetch避免分片不均衡 - 对
script参数进行严格的类型校验
十一、总结
Elasticsearch的地理语义搜索通过结合地理坐标、向量特征和业务语义,为推荐系统提供了更丰富的查询维度。其核心价值在于:
- 实现地理邻近性与业务语义的联合建模
- 支持多维特征(价格、评分、类别)的联合排序
- 提供灵活的评分函数自定义能力
- 在保持高查询性能的同时实现复杂业务需求
但在实际应用中需要注意:
- 避免过度依赖
script_score导致性能下降 - 确保向量特征的归一化处理
- 合理设置索引的
number_of_shards - 对敏感数据进行脱敏处理
对于需要处理大量向量相似度计算的场景,建议结合Elasticsearch的knn搜索功能,或使用专用的向量数据库(如Milvus、Pinecone)。而在简单的地理位置过滤场景中,使用geo_distance配合bool查询即可满足需求。
评论已关闭