作者:周弘懿(锦琛)
业务背景
「附近有什么好吃的?」——这是本地生活场景里最高频的一句发问。高德「扫街榜」这类产品之所以有说服力,核心在于两点:它离你近,且它是被真实吃过的人夸出来的,而不是靠商家刷分堆出来的。
把这类榜单拆开看,它需要回答三个层层递进的问题:
- 就近:以我当前位置为圆心,方圆几公里内、或者我正身处的这个商圈范围内,有哪些店?
- 对味:这些店里,哪些符合我此刻的需求?比如「适合亲子的川菜馆」「安静能聊事的咖啡馆」「深夜还开着的苍蝇馆子」——这是一种语义需求,无法用标签精确枚举。
- 靠谱:在符合前两条的店里,真实评价的口碑如何?好评率多少?大家反复提到的优点/槽点是什么?
它的业务价值也就落在这三层上:帮用户发现附近的好店、把「近 + 对味 + 口碑好」融合成一张可解释的榜单、并且基于真实评价的语义与情感而非刷分数据。要把这三层能力捏合到一次查询里,传统技术栈会相当吃力。
技术挑战
要做出这样一张榜单,系统必须同时具备以下能力,缺一不可:
- 地理空间过滤。既要支持「距离我某坐标 N 公里内」的半径查询,也要支持「是否落在某商圈多边形内」的范围判断,还要能判断门店与配送区域是否相交。这需要把 POI 的经纬度当作真正的几何对象来存储与运算。
- 评价文本的语义检索。用户输入「适合亲子的川菜馆」,系统要能理解语义、召回语义相近的评价与门店,而不是做关键词精确匹配——「带孩子」「亲子友好」「有儿童椅」都应该被命中。
- 海量评价的情感与摘要理解。要在店铺维度上把成千上万条评价聚合成一个好评率,并生成一段店铺评价摘要,让榜单结果可读、可信。
- 相关性精排。语义召回的候选集里,排序还需要按 Query 的真实相关性重排,把「最对味」的店顶到前面。
传统方案通常是三套系统拼接:用 PostGIS / 空间数据库做地理过滤,用向量数据库做语义召回,再用一套 NLP 服务(情感分类、摘要)做评价理解。三套系统各存一份数据,跨系统 JOIN、数据同步、一致性维护的成本极高,一次榜单查询要串起多个服务、多轮网络往返,工程复杂度和延迟都难以接受。
解决方案
阿里云 Milvus 可以把上面四件事收敛到一个 Collection、一次检索里完成:
- 地理:POI 经纬度用 Milvus 的地理空间类型 GEOMETRY 存储、商圈为
POLYGON。检索时把地理约束写进filter表达式,用ST_WITHIN/ST_CONTAINS/ST_INTERSECTS等空间关系函数完成范围/相交过滤。Milvus 2.6 为 GEOMETRY 字段提供了基于 R-Tree 的地理位置索引来加速空间查询。 - 语义:评价文本通过
AI_EMBEDDING在写入时自动向量化,检索时对同一模型生成的 Query 向量做 ANN 检索。 - 精排:召回候选用
AI_RERANK,按 Query 相关性重排。 - 口碑:用
AI_SENTIMENT对评价做情感分类,应用侧聚合出好评率;用AI_SUMMARIZE把门店评价压缩成一段评价摘要。
最关键的一点:地理过滤与语义检索融合在同一次 search() 调用里——ANN 向量检索负责「对味」,filter 里的 ST_ 空间函数负责「就近」,二者在一次请求内联合求解,再叠加 rerank 精排,直接产出榜单候选。
先对齐一下文中几个 AI Function 的作用:
函数 |
作用 |
在扫街榜里 |
|
写入时自动把评价文本转成向量(无需应用侧先调模型) |
让「适合亲子的川菜馆」这类语义需求能被向量检索命中 |
|
对召回候选按与查询的相关性重新排序(精排) |
把「最对味」的店顶到榜单前面 |
|
判断每条评价是好评 / 差评 / 中性 |
聚合出门店「好评率」 |
|
把成百上千条评价压成一句话摘要 |
生成榜单里「一句话点评」 |
具体AI Function用法参考官方文档
架构流程如下:
关键概念:GEOMETRY、WKT 与 ST_ 空间函数
在进入实战代码前,先把几个贯穿全文的地理概念讲清楚。
GEOMETRY —— 把「地点」当成几何图形存起来。 普通做法是把经纬度存成两个 double 字段,数据库只把它当两个数字,并不理解「谁在谁里面、两块区域是否相交」。GEOMETRY 是 Milvus 2.6 的地理空间类型,它把「点 / 线 / 面」当作真正的几何对象存储,于是就能用 ST_* 函数做空间运算;Milvus 2.6 还为它提供了基于 R-Tree 的空间索引来加速。
WKT —— 描述几何图形的标准文本写法。 GEOMETRY 字段里存的值用 WKT 表示,常见几种:
WKT 类型 |
写法示例 |
通俗含义 |
扫街榜里对应 |
|
|
一个点 |
一家门店的经纬度 |
|
|
一条折线 |
一段道路 / 配送路径 |
|
|
一片闭合区域(首尾坐标相同) |
一个商圈 / 配送范围 |
|
|
多个点 / 多片区域 |
一个品牌多家门店、多块行政区 |
坐标顺序是「经度在前、纬度在后」(POINT(lng lat)),和口语「北纬…东经…」相反。本文坐标均为 WGS84(GPS 经纬度)。
ST_ 开头的都是空间关系函数:写进 filter 里判断门店几何与给定区域的关系,返回真 / 假。本文重点用到三个:
函数 |
一句话解释 |
扫街榜里的用途 |
|
A 是否完全落在 B 内部 |
门店是否在商圈多边形内 / 是否在 3km 半径圆内 |
|
A 是否完全包住 B(与 WITHIN 相反) |
某商圈是否包含这家店,做归属分析 |
|
A 与 B 是否有任意交集 / 接触 |
门店配送范围是否与用户所在网格相交 |
Milvus 2.6 还有其余几个函数,可按需选用:ST_EQUALS(完全相同,去重)、ST_TOUCHES(只碰边界,如相邻商圈接壤)、ST_OVERLAPS(部分重叠,如配送区交叉)、ST_CROSSES(穿越式相交,如道路穿过商圈)。一句话记区别:WITHIN=在里面、CONTAINS=把它装住、INTERSECTS=有任何交集(最宽松)、TOUCHES=只碰边、OVERLAPS=叠一部分、CROSSES=穿过去、EQUALS=一模一样。
为了让 POINT、POLYGON、ST_WITHIN 更直观,下面把上面用到的示意坐标画到真实地图上(底图为高德地图,正文的 WGS84 坐标已做坐标系转换以精确对齐)。图一为 3km 半径圆与商圈范围的总览;图二放大到商圈级别,可见三家门店都落在商圈 POLYGON 内——正是 ST_WITHIN 判定为「真」的情形。
实战操作
下面用代码表示「建库 → 入库 → 地理过滤 → 语义融合 → 精排 → 情感/摘要」全流程。
- 建带地理字段 + embedding Function 的 Collection:
location用GEOMETRY存 POI 点,review挂AI_EMBEDDING,写入即向量化。 - POI 与评价数据入库:写入门店
POINT与评价原文,review_embedding由 Function 自动生成,无需手工传向量。 - 地理过滤查询:用
ST_WITHIN判断门店是否落在商圈POLYGON或 3km 近似圆内(半径圆由circle_to_wkt近似成多边形)。 - 语义检索 + 地理 filter 融合:把「就近」(
filter里的ST_)与「对味」(ANN 向量检索)融合进同一次search()。 - AI_RERANK 重排候选:按 Query 相关性精排,给出
search()内挂 ranker 与 REST 即时接口两种写法。 - AI_SENTIMENT + AI_SUMMARIZE:情感分类聚合出好评率,摘要生成店铺一句话点评。
将MILVUS_HOST和MILVUS_TOKEN替换成自己的集群就可以直接看到效果
from __future__ import annotations import json import math import time from typing import Any from urllib.error import HTTPError from urllib.request import Request, urlopen from pymilvus import DataType, Function, FunctionType, MilvusClient # ==================== 连接配置 ==================== MILVUS_HOST = "http://c-xxx.milvus.aliyuncs.com" MILVUS_TOKEN = "root:xxx" # RESTful 接口与 gRPC 同在 19530 端口,需显式带端口(否则默认 80 端口会连接超时) MILVUS_REST_BASE_URL = f"{MILVUS_HOST}:19530" client = MilvusClient(uri=MILVUS_HOST, token=MILVUS_TOKEN) collection_name = "shop_reviews" def post_json(path: str, body: dict[str, Any], timeout: int = 120, retries: int = 3) -> tuple[int, dict[str, Any]]: """AI_RERANK / AI_SENTIMENT / AI_SUMMARIZE 的 REST 即时接口统一封装(走大模型,加重试更稳)。""" last = None for _ in range(retries): request = Request( f"{MILVUS_REST_BASE_URL.rstrip('/')}{path}", data=json.dumps(body, ensure_ascii=False).encode("utf-8"), headers={"Authorization": f"Bearer {MILVUS_TOKEN}", "Content-Type": "application/json"}, method="POST", ) try: with urlopen(request, timeout=timeout) as response: status, data = response.status, json.loads(response.read().decode("utf-8")) except HTTPError as exc: status, data = exc.code, json.loads(exc.read().decode("utf-8")) last = (status, data) if status == 200 and data.get("code") == 0: return status, data time.sleep(1) return last def circle_to_wkt(lng: float, lat: float, radius_km: float, num_seg: int = 32) -> str: """把以 (lng,lat) 为圆心、radius_km 为半径的圆,近似成正多边形的 WKT(用于半径粗过滤)。 注意:经纬度是角度,此处按平面近似换算,纬度越高误差越大。""" d_lat = radius_km / 111.0 d_lng = radius_km / (111.0 * math.cos(math.radians(lat))) pts = [ ] for i in range(num_seg + 1): theta = 2 * math.pi * i / num_seg pts.append(f"{lng + d_lng * math.cos(theta):.6f} {lat + d_lat * math.sin(theta):.6f}") return f"POLYGON(({', '.join(pts)}))" # ==================== 步骤 1:建带地理字段 + embedding Function 的 Collection ==================== # location 用 DataType.GEOMETRY 存 POI 点;review 挂 AI_EMBEDDING,写入时自动生成 1024 维向量 if client.has_collection(collection_name): client.drop_collection(collection_name) schema = MilvusClient.create_schema(auto_id=True, enable_dynamic_field=False) schema.add_field("id", DataType.INT64, is_primary=True) schema.add_field("shop_id", DataType.INT64) schema.add_field("shop_name", DataType.VARCHAR, max_length=256) schema.add_field("category", DataType.VARCHAR, max_length=64) schema.add_field("location", DataType.GEOMETRY) # POI 经纬度点(WKT: POINT(lng lat)) schema.add_field("review", DataType.VARCHAR, max_length=4096) # 评价原文,作为 embedding 输入 schema.add_field("review_embedding", DataType.FLOAT_VECTOR, dim=1024) # 评价向量,维度需与 dim 一致 # 写入时自动把 review 向量化 schema.add_function( Function( name="embed_review", function_type=FunctionType.TEXTEMBEDDING, input_field_names=["review"], output_field_names=["review_embedding"], params={ "provider": "aliyun_milvus", "model_name": "qwen3.7-text-embedding", "dim": 1024, "max_client_batch_size": 20, # qwen3.7-text-embedding 百炼官方文档批量大小为 20 "max_concurrency": 1, }, ) ) index_params = client.prepare_index_params() index_params.add_index( field_name="review_embedding", index_type="HNSW", metric_type="COSINE", params={"M": 16, "efConstruction": 200}, ) client.create_collection( collection_name=collection_name, schema=schema, index_params=index_params, ) # ==================== 步骤 2:POI 与评价数据入库(review_embedding 由 Function 自动生成,无需手工传向量) ==================== rows = [ {"shop_id": 1001, "shop_name": "巴蜀人家·亲子川菜", "category": "川菜", "location": "POINT(116.4612 39.9088)", "review": "带娃来的,有儿童座椅和小碗餐具,毛血旺不算太辣,服务员很有耐心,环境干净。"}, {"shop_id": 1001, "shop_name": "巴蜀人家·亲子川菜", "category": "川菜", "location": "POINT(116.4612 39.9088)", "review": "周末人多要等位,但菜品稳定,适合一家人聚餐,孩子吃得开心。"}, {"shop_id": 1002, "shop_name": "麻辣江湖", "category": "川菜", "location": "POINT(116.4501 39.9101)", "review": "重口味爱好者天堂,水煮鱼够味,但空间偏小、比较吵,不太适合带小孩。"}, {"shop_id": 1003, "shop_name": "静巷咖啡", "category": "咖啡", "location": "POINT(116.4550 39.9075)", "review": "环境安静适合谈事,手冲不错,不过跟川菜需求无关。"}, ] client.insert(collection_name, rows) client.flush(collection_name) client.load_collection(collection_name) # ==================== 步骤 3:地理过滤查询(ST_WITHIN 商圈多边形 / 半径近似圆) ==================== # 3.1 查询某商圈多边形范围内的门店 cbd_polygon = "POLYGON((116.448 39.905, 116.465 39.905, 116.465 39.914, 116.448 39.914, 116.448 39.905))" hits = client.query( collection_name=collection_name, filter=f"ST_WITHIN(location, '{cbd_polygon}')", output_fields=["shop_name", "category", "location"], limit=100, ) print("\n" + "=" * 64) print("步骤 3 | 地理过滤查询 ST_WITHIN(location, 多边形 / 半径圆)") print("=" * 64) print("[3.1] CBD 商圈多边形范围内的门店:") for h in hits: print(f" · {h['shop_name']}({h['category']}) {h['location']}") # 3.2 附近 3km 半径查询:把圆近似成多边形,再用 ST_WITHIN 判断 POI 是否落在圈内 center_lng, center_lat = 116.4600, 39.9090 circle = circle_to_wkt(center_lng, center_lat, radius_km=3.0) nearby = client.query( collection_name=collection_name, filter=f"ST_WITHIN(location, '{circle}')", output_fields=["shop_name", "category", "location"], limit=200, ) print(f"[3.2] 中心点 ({center_lng}, {center_lat}) 附近 3km 半径内的门店:") for n in nearby: print(f" · {n['shop_name']}({n['category']}) {n['location']}") # ==================== 步骤 4:语义检索 + 地理 filter 融合进一次 search() ==================== # ANN 走评价向量负责「对味」,filter 里的 ST_ 空间函数负责「就近」,一次请求联合求解 QUERY = "附近适合亲子的川菜馆" geo_filter = f"category == '川菜' && ST_WITHIN(location, '{circle}')" results = client.search( collection_name=collection_name, data=[QUERY], anns_field="review_embedding", filter=geo_filter, limit=20, output_fields=["shop_id", "shop_name", "category", "location", "review"], ) print("\n" + "=" * 64) print("步骤 4 | 语义检索 + 地理过滤 融合查询(一次 search 联合求解)") print("=" * 64) print(f"查询语句:{QUERY}") print("过滤条件:category == '川菜' 且落在附近 3km 圈内") print("-" * 64) for rank, hit in enumerate(results[0], 1): e = hit["entity"] print(f" {rank}. [相似度 {hit['distance']:.4f}] {e['shop_name']} | {e['review'][:24]}") # ==================== 步骤 5:AI_RERANK 重排候选 ==================== RERANK_MODEL = "qwen3-rerank" # 5.1 在 search() 中挂 RERANK ranker(reranker="model"),ANN 召回后由模型二次精排 reranker = Function( name="rerank_reviews", function_type=FunctionType.RERANK, input_field_names=["review"], params={ "reranker": "model", "provider": "aliyun_milvus", "model_name": RERANK_MODEL, "queries": [QUERY], "timeout_sec": 10, }, ) results = client.search( collection_name=collection_name, data=[QUERY], anns_field="review_embedding", filter=geo_filter, limit=20, output_fields=["shop_id", "shop_name", "review"], ranker=reranker, ) print("\n" + "=" * 64) print("步骤 5 | AI_RERANK 大模型重排(qwen3-rerank)") print("=" * 64) print("[5.1] search() 内置 ranker:ANN 召回后由大模型二次精排") print("-" * 64) for rank, hit in enumerate(results[0], 1): e = hit["entity"] print(f" {rank}. [重排分 {hit['distance']:.4f}] {e['shop_name']} | {e['review'][:24]}") # 5.2 也可用无状态 REST 即时接口 /v2/vectordb/ai/rerank # (documents 必须是 string 数组、query 为顶层 string、不支持 top_n,每个 doc 返回一个分) rerank_docs = [ "带娃来的,有儿童座椅和小碗餐具,服务员很有耐心。", "重口味爱好者天堂,水煮鱼够味,但空间偏小、比较吵,不太适合带小孩。", ] status, data = post_json( "/v2/vectordb/ai/rerank", { "model_name": RERANK_MODEL, "query": QUERY, "documents": rerank_docs, "params": {"timeout_sec": 10}, }, ) assert status == 200 and data.get("code") == 0, data print(f"\n[5.2] REST 即时接口 /v2/vectordb/ai/rerank(query:{QUERY})") print("-" * 64) ranked = sorted(data["data"]["output"]["results"], key=lambda x: x["relevance_score"], reverse=True) for rank, item in enumerate(ranked, 1): print(f" {rank}. [相关度 {item['relevance_score']:.4f}] {rerank_docs[item['index']][:28]}") # ==================== 步骤 6:AI_SENTIMENT 算好评率 + AI_SUMMARIZE 生成店铺摘要 ==================== shop_id = 1001 reviews = [r["review"] for r in client.query( collection_name=collection_name, filter=f"shop_id == {shop_id}", output_fields=["review"], limit=1000, )] # 6.1 情感分类 → 好评率 status, sent = post_json("/v2/vectordb/ai/sentiment", { "model_name": "qwen3.7-max", "texts": reviews, "params": {"categories": ["positive", "negative", "neutral"], "temperature": 0}, }) assert status == 200 and sent.get("code") == 0, sent labels = sent["data"]["output"]["outputs"] valid = [x for x in labels if x] # null 计入「未判定」,不参与聚合 positive = sum(1 for x in valid if x == "positive") good_rate = positive / len(valid) if valid else 0.0 print("\n" + "=" * 64) print(f"步骤 6 | AI_SENTIMENT 好评率 + AI_SUMMARIZE 摘要(shop_id={shop_id})") print("=" * 64) print(f"[6.1] 好评率:{good_rate:.0%} (有效样本 {len(valid)}/{len(labels)})") # 6.2 摘要 → 店铺评价一句话 status, summ = post_json("/v2/vectordb/ai/summarize", { "model_name": "qwen3.7-max", "texts": ["\n".join(reviews)], "params": {"max_words": 40, "temperature": 0, "prompt": "用中文概括这家店的整体评价,突出招牌菜、适合人群与环境。"}, }) assert status == 200 and summ.get("code") == 0, summ print(f"[6.2] 店铺摘要:{summ['data']['output']['outputs'][0]}")
实战效果
把上面的**地理过滤 → 语义检索 → rerank 精排 → 情感/摘要**串起来,就是一次完整的扫街榜生成:**地理过滤圈定「就近」候选 → 语义检索命中「对味」→ rerank 精排 → 情感/摘要补齐「口碑」**。下面是一份示例榜单输出
扫街榜的排序逻辑由三路信号融合:
- 地理分:在半径/商圈内为门槛(不满足直接过滤掉),再按距离由近到远给基础权重;
- 语义相关分:
AI_RERANK输出的relevance_score,衡量门店评价与用户需求「适合亲子的川菜馆」的贴合度; - 口碑分:
AI_SENTIMENT聚合出的好评率。
最终榜位可用形如 w1·相关分 + w2·好评率 + w3·距离衰减 的加权融合,权重按业务调参。「巴蜀人家」虽然不是最辣,但因「亲子友好」的语义强命中 + 高好评率 + 距离最近,综合排到第一——这正是「扫街榜」区别于「按评分排序」的地方。
与传统多系统方案对比,简化非常直观:
维度 |
传统方案(PostGIS + 向量库 + NLP 服务) |
阿里云 Milvus 一站式 |
系统数量 |
3+ 套,各存一份数据 |
1 套,数据同源 |
地理 + 语义融合 |
跨系统 JOIN / 多轮回捞 |
一次 |
评价理解 |
自建/外接情感、摘要模型 |
|
向量化链路 |
应用侧调 embedding 再写入 |
写入即向量化(Collection Function) |
一致性/同步 |
需自行维护 |
单库天然一致 |
总结
这套方案的价值可以收敛为一句话:把「空间」与「语义」放进同一个数据库、同一次检索里求解。地理空间类型让 Milvus 理解「哪里」,AI Function 让它理解「什么」和「好不好」,二者融合直接产出可解释的本地生活榜单,省掉了 PostGIS + 向量库 + NLP 三套系统的拼接与同步成本。
顺着这条路还有不少想象空间:
- 多模态门店检索:用
qwen3-vl-embedding把门店图片/菜品图向量化,支持「附近有这种装修风格的店」的以图搜店,与地理过滤同样融合在一次检索里。 - 批量口碑加工:门店评价体量大时,用批量方式(如 AI_BATCH / 批量摘要)离线把好评率、评价摘要预计算落库,榜单查询只读结果,兼顾成本与延迟。
- 更丰富的空间关系:用
ST_INTERSECTS判断门店配送多边形与用户所在网格是否相交、用ST_CONTAINS做商圈归属分析,把「地理 + AI」的融合从「找店」扩展到调度、选址、履约等更多本地生活环节。
地理让 AI「落到地面」,AI 让地理「读懂内容」——当这两者在一个系统里协同,本地生活的每一次「附近有什么好吃的」,都能被更快、更准、更可信地回答。
让 AI 落地,不必再从复杂链路开始
阿里云 Milvus 是目前唯一支持 AI Native 的 Milvus 云服务。它不仅提供高性能、全托管的向量检索能力,更将 AI Function 与 AI-Gateway 原生嵌入数据链路,让模型能力真正成为数据库的一部分。
从数据进入阿里云Milvus 的那一刻起,理解、加工、检索和生成便可以在一条原生链路中完成。阿里云 Milvus 正在把“接入 AI”变成“原生拥有 AI”。