作者:周弘懿(锦琛)
业务背景
「附近有什么好吃的?」——这是本地生活场景里最高频的一句发问。高德「扫街榜」这类产品之所以有说服力,核心在于两点:它离你近,且它是被真实吃过的人夸出来的,而不是靠商家刷分堆出来的。
把这类榜单拆开看,它需要回答三个层层递进的问题:
就近:以我当前位置为圆心,方圆几公里内、或者我正身处的这个商圈范围内,有哪些店?
对味:这些店里,哪些符合我此刻的需求?比如「适合亲子的川菜馆」「安静能聊事的咖啡馆」「深夜还开着的苍蝇馆子」——这是一种语义需求,无法用标签精确枚举。
靠谱:在符合前两条的店里,真实评价的口碑如何?好评率多少?大家反复提到的优点/槽点是什么?
它的业务价值也就落在这三层上:帮用户发现附近的好店、把「近 对味 口碑好」融合成一张可解释的榜单、并且基于真实评价的语义与情感而非刷分数据。要把这三层能力捏合到一次查询里,传统技术栈会相当吃力。
技术挑战
要做出这样一张榜单,系统必须同时具备以下能力,缺一不可:
地理空间过滤。既要支持「距离我某坐标 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_EMBEDDING
| 写入时自动把评价文本转成向量(无需应用侧先调模型) | 让「适合亲子的川菜馆」这类语义需求能被向量检索命中 |
AI_RERANK
| 对召回候选按与查询的相关性重新排序(精排) | 把「最对味」的店顶到榜单前面 |
AI_SENTIMENT
| 判断每条评价是好评 / 差评 / 中性 | 聚合出门店「好评率」 |
AI_SUMMARIZE
| 把成百上千条评价压成一句话摘要 | 生成榜单里「一句话点评」 |
","margin":true,"hideBorder":false,"id":"yh1OW"}">具体AI Function用法参考官方文档
架构流程如下:

关键概念:GEOMETRY、WKT 与 ST_ 空间函数
在进入实战代码前,先把几个贯穿全文的地理概念讲清楚。
GEOMETRY —— 把「地点」当成几何图形存起来。 普通做法是把经纬度存成两个 double 字段,数据库只把它当两个数字,并不理解「谁在谁里面、两块区域是否相交」。GEOMETRY 是 Milvus 2.6 的地理空间类型,它把「点 / 线 / 面」当作真正的几何对象存储,于是就能用 ST_* 函数做空间运算;Milvus 2.6 还为它提供了基于 R-Tree 的空间索引来加速。
WKT —— 描述几何图形的标准文本写法。 GEOMETRY 字段里存的值用 WKT 表示,常见几种:
WKT 类型 | 写法示例 | 通俗含义 | 扫街榜里对应 |
POINT
| POINT(116.4612 39.9088)
| 一个点 | 一家门店的经纬度 |
LINESTRING
| LINESTRING(116.46 39.90, 116.47 39.91)
| 一条折线 | 一段道路 / 配送路径 |
POLYGON
| POLYGON((116.448 39.905, ..., 116.448 39.905))
| 一片闭合区域(首尾坐标相同) | 一个商圈 / 配送范围 |
MULTIPOINT / MULTIPOLYGON
| MULTIPOLYGON(((...)),((...)))
| 多个点 / 多片区域 | 一个品牌多家门店、多块行政区 |
","margin":true,"hideBorder":false,"id":"ZG3FR"}">坐标顺序是「经度在前、纬度在后」(POINT(lng lat)),和口语「北纬…东经…」相反。本文坐标均为 WGS84(GPS 经纬度)。
ST_ 开头的都是空间关系函数:写进 filter 里判断门店几何与给定区域的关系,返回真 / 假。本文重点用到三个:
函数 | 一句话解释 | 扫街榜里的用途 |
ST_WITHIN(A, B)
| A 是否完全落在 B 内部 | 门店是否在商圈多边形内 / 是否在 3km 半径圆内 |
ST_CONTAINS(A, B)
| A 是否完全包住 B(与 WITHIN 相反) | 某商圈是否包含这家店,做归属分析 |
ST_INTERSECTS(A, B)
| A 与 B 是否有任意交集 / 接触 | 门店配送范围是否与用户所在网格相交 |
","margin":true,"hideBorder":false,"id":"crmEg"}">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替换成自己的集群就可以直接看到效果
tuple[int, dict[str, Any]]:n """AI_RERANK / AI_SENTIMENT / AI_SUMMARIZE 的 REST 即时接口统一封装(走大模型,加重试更稳)。"""n last = Nonen for _ in range(retries):n request = Request(n f"{MILVUS_REST_BASE_URL.rstrip('/')}{path}",n data=json.dumps(body, ensure_ascii=False).encode("utf-8"),n headers={"Authorization": f"Bearer {MILVUS_TOKEN}", "Content-Type": "application/json"},n method="POST",n )n try:n with urlopen(request, timeout=timeout) as response:n status, data = response.status, json.loads(response.read().decode("utf-8"))n except HTTPError as exc:n status, data = exc.code, json.loads(exc.read().decode("utf-8"))n last = (status, data)n if status == 200 and data.get("code") == 0:n return status, datan time.sleep(1)n return lastnnndef circle_to_wkt(lng: float, lat: float, radius_km: float, num_seg: int = 32) -> str:n """把以 (lng,lat) 为圆心、radius_km 为半径的圆,近似成正多边形的 WKT(用于半径粗过滤)。n 注意:经纬度是角度,此处按平面近似换算,纬度越高误差越大。"""n d_lat = radius_km / 111.0n d_lng = radius_km / (111.0 * math.cos(math.radians(lat)))nn pts = [ ]nn for i in range(num_seg + 1):n theta = 2 * math.pi * i / num_segn pts.append(f"{lng + d_lng * math.cos(theta):.6f} {lat + d_lat * math.sin(theta):.6f}")n return f"POLYGON(({', '.join(pts)}))"nnn# ==================== 步骤 1:建带地理字段 + embedding Function 的 Collection ====================n# location 用 DataType.GEOMETRY 存 POI 点;review 挂 AI_EMBEDDING,写入时自动生成 1024 维向量nif client.has_collection(collection_name):n client.drop_collection(collection_name)nnschema = MilvusClient.create_schema(auto_id=True, enable_dynamic_field=False)nschema.add_field("id", DataType.INT64, is_primary=True)nschema.add_field("shop_id", DataType.INT64)nschema.add_field("shop_name", DataType.VARCHAR, max_length=256)nschema.add_field("category", DataType.VARCHAR, max_length=64)nschema.add_field("location", DataType.GEOMETRY) # POI 经纬度点(WKT: POINT(lng lat))nschema.add_field("review", DataType.VARCHAR, max_length=4096) # 评价原文,作为 embedding 输入nschema.add_field("review_embedding", DataType.FLOAT_VECTOR, dim=1024) # 评价向量,维度需与 dim 一致nn# 写入时自动把 review 向量化nschema.add_function(n Function(n name="embed_review",n function_type=FunctionType.TEXTEMBEDDING,n input_field_names=["review"],n output_field_names=["review_embedding"],n params={n "provider": "aliyun_milvus",n "model_name": "qwen3.7-text-embedding",n "dim": 1024,n "max_client_batch_size": 20, # qwen3.7-text-embedding 百炼官方文档批量大小为 20n "max_concurrency": 1,n },n )n)nnindex_params = client.prepare_index_params()nindex_params.add_index(n field_name="review_embedding",n index_type="HNSW",n metric_type="COSINE",n params={"M": 16, "efConstruction": 200},n)nclient.create_collection(n collection_name=collection_name,n schema=schema,n index_params=index_params,n)nnn# ==================== 步骤 2:POI 与评价数据入库(review_embedding 由 Function 自动生成,无需手工传向量) ====================nrows = [n {"shop_id": 1001, "shop_name": "巴蜀人家·亲子川菜", "category": "川菜",n "location": "POINT(116.4612 39.9088)",n "review": "带娃来的,有儿童座椅和小碗餐具,毛血旺不算太辣,服务员很有耐心,环境干净。"},n {"shop_id": 1001, "shop_name": "巴蜀人家·亲子川菜", "category": "川菜",n "location": "POINT(116.4612 39.9088)",n "review": "周末人多要等位,但菜品稳定,适合一家人聚餐,孩子吃得开心。"},n {"shop_id": 1002, "shop_name": "麻辣江湖", "category": "川菜",n "location": "POINT(116.4501 39.9101)",n "review": "重口味爱好者天堂,水煮鱼够味,但空间偏小、比较吵,不太适合带小孩。"},n {"shop_id": 1003, "shop_name": "静巷咖啡", "category": "咖啡",n "location": "POINT(116.4550 39.9075)",n "review": "环境安静适合谈事,手冲不错,不过跟川菜需求无关。"},n]nclient.insert(collection_name, rows)nclient.flush(collection_name)nclient.load_collection(collection_name)nnn# ==================== 步骤 3:地理过滤查询(ST_WITHIN 商圈多边形 / 半径近似圆) ====================n# 3.1 查询某商圈多边形范围内的门店ncbd_polygon = "POLYGON((116.448 39.905, 116.465 39.905, 116.465 39.914, 116.448 39.914, 116.448 39.905))"nhits = client.query(n collection_name=collection_name,n filter=f"ST_WITHIN(location, '{cbd_polygon}')",n output_fields=["shop_name", "category", "location"],n limit=100,n)nprint("\n" + "=" * 64)nprint("步骤 3 | 地理过滤查询 ST_WITHIN(location, 多边形 / 半径圆)")nprint("=" * 64)nprint("[3.1] CBD 商圈多边形范围内的门店:")nfor h in hits:n print(f" · {h['shop_name']}({h['category']}) {h['location']}")nn# 3.2 附近 3km 半径查询:把圆近似成多边形,再用 ST_WITHIN 判断 POI 是否落在圈内ncenter_lng, center_lat = 116.4600, 39.9090ncircle = circle_to_wkt(center_lng, center_lat, radius_km=3.0)nnearby = client.query(n collection_name=collection_name,n filter=f"ST_WITHIN(location, '{circle}')",n output_fields=["shop_name", "category", "location"],n limit=200,n)nprint(f"[3.2] 中心点 ({center_lng}, {center_lat}) 附近 3km 半径内的门店:")nfor n in nearby:n print(f" · {n['shop_name']}({n['category']}) {n['location']}")nnn# ==================== 步骤 4:语义检索 + 地理 filter 融合进一次 search() ====================n# ANN 走评价向量负责「对味」,filter 里的 ST_ 空间函数负责「就近」,一次请求联合求解nQUERY = "附近适合亲子的川菜馆"ngeo_filter = f"category == '川菜' && ST_WITHIN(location, '{circle}')"nresults = client.search(n collection_name=collection_name,n data=[QUERY],n anns_field="review_embedding",n filter=geo_filter,n limit=20,n output_fields=["shop_id", "shop_name", "category", "location", "review"],n)nprint("\n" + "=" * 64)nprint("步骤 4 | 语义检索 + 地理过滤 融合查询(一次 search 联合求解)")nprint("=" * 64)nprint(f"查询语句:{QUERY}")nprint("过滤条件:category == '川菜' 且落在附近 3km 圈内")nprint("-" * 64)nfor rank, hit in enumerate(results[0], 1):n e = hit["entity"]n print(f" {rank}. [相似度 {hit['distance']:.4f}] {e['shop_name']} | {e['review'][:24]}")nnn# ==================== 步骤 5:AI_RERANK 重排候选 ====================nRERANK_MODEL = "qwen3-rerank"nn# 5.1 在 search() 中挂 RERANK ranker(reranker="model"),ANN 召回后由模型二次精排nreranker = Function(n name="rerank_reviews",n function_type=FunctionType.RERANK,n input_field_names=["review"],n params={n "reranker": "model",n "provider": "aliyun_milvus",n "model_name": RERANK_MODEL,n "queries": [QUERY],n "timeout_sec": 10,n },n)nresults = client.search(n collection_name=collection_name,n data=[QUERY],n anns_field="review_embedding",n filter=geo_filter,n limit=20,n output_fields=["shop_id", "shop_name", "review"],n ranker=reranker,n)nprint("\n" + "=" * 64)nprint("步骤 5 | AI_RERANK 大模型重排(qwen3-rerank)")nprint("=" * 64)nprint("[5.1] search() 内置 ranker:ANN 召回后由大模型二次精排")nprint("-" * 64)nfor rank, hit in enumerate(results[0], 1):n e = hit["entity"]n print(f" {rank}. [重排分 {hit['distance']:.4f}] {e['shop_name']} | {e['review'][:24]}")nn# 5.2 也可用无状态 REST 即时接口 /v2/vectordb/ai/rerankn# (documents 必须是 string 数组、query 为顶层 string、不支持 top_n,每个 doc 返回一个分)nrerank_docs = [n "带娃来的,有儿童座椅和小碗餐具,服务员很有耐心。",n "重口味爱好者天堂,水煮鱼够味,但空间偏小、比较吵,不太适合带小孩。",n]nstatus, data = post_json(n "/v2/vectordb/ai/rerank",n {n "model_name": RERANK_MODEL,n "query": QUERY,n "documents": rerank_docs,n "params": {"timeout_sec": 10},n },n)nassert status == 200 and data.get("code") == 0, datanprint(f"\n[5.2] REST 即时接口 /v2/vectordb/ai/rerank(query:{QUERY})")nprint("-" * 64)nranked = sorted(data["data"]["output"]["results"], key=lambda x: x["relevance_score"], reverse=True)nfor rank, item in enumerate(ranked, 1):n print(f" {rank}. [相关度 {item['relevance_score']:.4f}] {rerank_docs[item['index']][:28]}")nnn# ==================== 步骤 6:AI_SENTIMENT 算好评率 + AI_SUMMARIZE 生成店铺摘要 ====================nshop_id = 1001nreviews = [r["review"] for r in client.query(n collection_name=collection_name,n filter=f"shop_id == {shop_id}",n output_fields=["review"],n limit=1000,n)]nn# 6.1 情感分类 → 好评率nstatus, sent = post_json("/v2/vectordb/ai/sentiment", {n "model_name": "qwen3.7-max",n "texts": reviews,n "params": {"categories": ["positive", "negative", "neutral"], "temperature": 0},n})nassert status == 200 and sent.get("code") == 0, sentnlabels = sent["data"]["output"]["outputs"]nvalid = [x for x in labels if x] # null 计入「未判定」,不参与聚合npositive = sum(1 for x in valid if x == "positive")ngood_rate = positive / len(valid) if valid else 0.0nprint("\n" + "=" * 64)nprint(f"步骤 6 | AI_SENTIMENT 好评率 + AI_SUMMARIZE 摘要(shop_id={shop_id})")nprint("=" * 64)nprint(f"[6.1] 好评率:{good_rate:.0%} (有效样本 {len(valid)}/{len(labels)})")nn# 6.2 摘要 → 店铺评价一句话nstatus, summ = post_json("/v2/vectordb/ai/summarize", {n "model_name": "qwen3.7-max",n "texts": ["\n".join(reviews)],n "params": {"max_words": 40, "temperature": 0,n "prompt": "用中文概括这家店的整体评价,突出招牌菜、适合人群与环境。"},n})nassert status == 200 and summ.get("code") == 0, summnprint(f"[6.2] 店铺摘要:{summ['data']['output']['outputs'][0]}")","id":"EaStA"}">实战效果
把上面的地理过滤 → 语义检索 → rerank 精排 → 情感/摘要串起来,就是一次完整的扫街榜生成:地理过滤圈定「就近」候选 → 语义检索命中「对味」→ rerank 精排 → 情感/摘要补齐「口碑」。下面是一份示例榜单输出

扫街榜的排序逻辑由三路信号融合:
地理分:在半径/商圈内为门槛(不满足直接过滤掉),再按距离由近到远给基础权重;
语义相关分:AI_RERANK 输出的 relevance_score,衡量门店评价与用户需求「适合亲子的川菜馆」的贴合度;
口碑分:AI_SENTIMENT 聚合出的好评率。
最终榜位可用形如 w1·相关分 w2·好评率 w3·距离衰减 的加权融合,权重按业务调参。「巴蜀人家」虽然不是最辣,但因「亲子友好」的语义强命中 高好评率 距离最近,综合排到第一——这正是「扫街榜」区别于「按评分排序」的地方。
与传统多系统方案对比,简化非常直观:
维度 | 传统方案(PostGIS + 向量库 + NLP 服务) | 阿里云 Milvus 一站式 |
系统数量 | 3+ 套,各存一份数据 | 1 套,数据同源 |
地理 + 语义融合 | 跨系统 JOIN / 多轮回捞 | 一次 search():ANN + ST_ filter |
评价理解 | 自建/外接情感、摘要模型 | AI_SENTIMENT / AI_SUMMARIZE 内置
|
向量化链路 | 应用侧调 embedding 再写入 | 写入即向量化(Collection Function) |
一致性/同步 | 需自行维护 | 单库天然一致 |
","margin":true,"hideBorder":false,"id":"u7skJ"}">总结
这套方案的价值可以收敛为一句话:把「空间」与「语义」放进同一个数据库、同一次检索里求解。地理空间类型让 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”。

郑重声明:本站发布内容宗旨在传播更多信息,仅提供查阅,与本站立场无关,不拥有所有权,不承担相关法律责任。不具有任何效益,仅供参考。如果需要专业知识建议,请咨询相关专业人士。如有侵权请联系邮箱。一经查实,立即删除!