使用 Elasticsearch 中的地理语义搜索增强推荐功能

'# 使用 Elasticsearch 中的地理语义搜索增强推荐功能

一、背景与问题

在电商推荐系统、LBS(基于地理位置服务)场景中,单纯的地理位置过滤往往无法满足复杂的业务需求。例如:

  • 一个用户在杭州西湖边想寻找周边的咖啡馆,但不仅仅要距离近的,还要推荐评分高、价格适中、且有户外座位的场所
  • 一个旅游App需要根据用户当前位置,推荐既符合地理邻近性,又符合用户兴趣偏好的景点

传统方案的局限性:

  1. 仅使用geo_distance或geo_bounding_box进行空间过滤,无法结合业务语义
  2. 无法实现"用户当前位置与推荐对象的地理语义相关性"的量化分析
  3. 缺乏对多维特征(如价格、评分、类别)与地理位置的联合建模能力

Elasticsearch的地理语义搜索通过以下机制解决上述问题:

  • 将地理位置转化为向量化表示
  • 通过dense_vector字段存储业务特征向量
  • 利用knn(近似最近邻)算法实现地理+语义的联合检索
  • 支持多维特征(价格、评分、类别)与地理坐标的联合排序

二、基本原理

Elasticsearch的地理语义搜索基于以下技术栈:

  1. Geo Point:存储经纬度坐标
  2. dense_vector:存储向量特征(如商品属性、用户偏好)
  3. knn_search:基于向量相似度的近似最近邻算法
  4. Geo Shape:支持多边形、多边形范围查询
  5. 脚本分数:自定义计算地理距离+语义相似度的评分函数

核心公式:

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
                }
            }
        }
    ]
}

十、最佳实践

  1. 数据预处理

    • 地理坐标应存储为geo_point类型
    • 向量特征需进行标准化处理(0-1范围)
    • 静态字段(价格、评分)应使用keyword类型索引
  2. 查询设计

    • 先使用geo_distance过滤地理范围
    • 再使用script_score进行语义排序
    • 对价格、评分等静态字段使用range过滤
  3. 性能优化

    • 使用filter上下文进行地理范围过滤
    • 对动态权重参数进行预计算
    • 启用fielddata缓存提升排序性能
  4. 安全措施

    • 对敏感数据进行加密存储
    • 使用search_type: dfs_query_and_fetch避免分片不均衡
    • 对script参数进行严格的类型校验

十一、总结

Elasticsearch的地理语义搜索通过结合地理坐标、向量特征和业务语义,为推荐系统提供了更丰富的查询维度。其核心价值在于:

  1. 实现地理邻近性与业务语义的联合建模
  2. 支持多维特征(价格、评分、类别)的联合排序
  3. 提供灵活的评分函数自定义能力
  4. 在保持高查询性能的同时实现复杂业务需求

但在实际应用中需要注意:

  • 避免过度依赖script_score导致性能下降
  • 确保向量特征的归一化处理
  • 合理设置索引的number_of_shards
  • 对敏感数据进行脱敏处理

对于需要处理大量向量相似度计算的场景,建议结合Elasticsearch的knn搜索功能,或使用专用的向量数据库(如Milvus、Pinecone)。而在简单的地理位置过滤场景中,使用geo_distance配合bool查询即可满足需求。

评论已关闭

推荐阅读

AIGC实战——Transformer模型
2024年12月01日
Socket TCP 和 UDP 编程基础(Python)
2024年11月30日
python , tcp , udp
如何使用 ChatGPT 进行学术润色?你需要这些指令
2024年12月01日
AI
最新 Python 调用 OpenAi 详细教程实现问答、图像合成、图像理解、语音合成、语音识别(详细教程)
2024年11月24日
ChatGPT 和 DALL·E 2 配合生成故事绘本
2024年12月01日
omegaconf,一个超强的 Python 库!
2024年11月24日
【视觉AIGC识别】误差特征、人脸伪造检测、其他类型假图检测
2024年12月01日
[超级详细]如何在深度学习训练模型过程中使用 GPU 加速
2024年11月29日
Python 物理引擎pymunk最完整教程
2024年11月27日
MediaPipe 人体姿态与手指关键点检测教程
2024年11月27日
深入了解 Taipy:Python 打造 Web 应用的全面教程
2024年11月26日
基于Transformer的时间序列预测模型
2024年11月25日
Python在金融大数据分析中的AI应用(股价分析、量化交易)实战
2024年11月25日
AIGC Gradio系列学习教程之Components
2024年12月01日
Python3 `asyncio` — 异步 I/O,事件循环和并发工具
2024年11月30日
llama-factory SFT系列教程:大模型在自定义数据集 LoRA 训练与部署
2024年12月01日
Python 多线程和多进程用法
2024年11月24日
Python socket详解,全网最全教程
2024年11月27日
python之plot()和subplot()画图
2024年11月26日
理解 DALL·E 2、Stable Diffusion 和 Midjourney 工作原理
2024年12月01日