本文介紹如何基於阿里雲 Milvus 的地理空間類型 GEOMETRY 與 AI Function,在同一個 Collection、同一次檢索中完成「就近」的地理範圍過濾與「對味」的語義召回,併疊加情感分析與摘要產生,構建一張可解釋的本地生活榜單。
方案概述
「附近有什麼好吃的」是本地生活情境中最高頻的需求。要回答好這個問題,系統需要同時滿足三層條件:
就近:以使用者當前位置為圓心的一定半徑內,或使用者所在商圈範圍內的門店。
對味:符合「適合親子的川菜館」「安靜能聊事的咖啡館」這類語義需求,無法用標籤精確枚舉。
靠譜:真實評價的口碑如何,好評率多少,反覆被提到的優點與槽點是什麼。
傳統實現通常需要三套系統拼接:用空間資料庫做地理過濾,用向量資料庫做語義召回,再用一套 NLP 服務做情感分類與摘要。三套系統各存一份資料,跨系統關聯、資料同步與一致性維護成本高,一次查詢要串起多個服務和多輪網路往返。
阿里雲 Milvus 可以把上述能力收斂到一個 Collection、一次檢索中完成:
能力 | 實現方式 |
地理範圍過濾 | 門店經緯度用 |
評價語義檢索 | 評價文本通過 |
相關性精排 | 召回候選通過 |
口碑量化 | 用 |
其中最關鍵的一點是:地理過濾與語義檢索融合在同一次 search() 調用中——ANN 向量檢索負責「對味」,filter 中的空間函數負責「就近」,二者在一次請求內聯合求解,再疊加重排,直接產出榜單候選。
本文用到的 AI Function 如下:
函數 | 作用 | 在榜單中的用途 |
| 寫入時自動把評價文本轉成向量,無需應用側先調用模型。 | 讓「適合親子的川菜館」這類語義需求能被向量檢索命中。 |
| 對召回候選按與查詢的相關性重新排序。 | 把最貼合需求的門店排到榜單前面。 |
| 判斷每條評價屬於好評、差評還是中性。 | 彙總出門店好評率。 |
| 把大量評價壓縮成簡短摘要。 | 產生榜單中的一句話點評。 |
GEOMETRY 與 WKT
如果把經緯度存成兩個 DOUBLE 欄位,資料庫只會把它當兩個數字,無法判斷「誰在誰裡面」「兩塊地區是否相交」。GEOMETRY 是 Milvus 2.6 提供的地理空間類型,它把點、線、面作為幾何Object Storage Service,從而支援空間關係運算,並可通過地理位置索引加速查詢。
GEOMETRY 欄位的值使用 WKT(Well-Known Text)格式表示,常用類型如下:
WKT 類型 | 樣本 | 含義 | 本文中的對應 |
|
| 一個點 | 一家門店的經緯度 |
|
| 一條折線 | 一段道路或配送路徑 |
|
| 一片閉合地區,首尾座標相同 | 一個商圈或配送範圍 |
|
| 多個點 | 一個品牌下的多家門店 |
|
| 多片地區 | 多塊行政區 |
座標順序為經度在前、緯度在後,即 POINT(lng lat),與口語中「北緯…東經…」的順序相反。本文座標均為 WGS84 座標系。
ST_ 空間關係函數
以 ST_ 開頭的函數用於判斷兩個幾何對象之間的空間關係,寫在 filter 運算式中,返回真或假。本文主要用到以下三個:
函數 | 說明 | 本文中的用途 |
| A 是否完全落在 B 內部。 | 判斷門店是否在商圈多邊形內,或是否在指定半徑範圍內。 |
| A 是否完全包含 B,與 ST_WITHIN 相反。 | 判斷某商圈是否包含指定門店,用于歸屬分析。 |
| A 與 B 是否存在任意交集或接觸。 | 判斷門店配送範圍是否與使用者所在網格相交。 |
此外還支援以下函數,可按需選用:
函數 | 說明 | 適用情境 |
| 兩個幾何對象完全相同。 | 幾何去重。 |
| 僅邊界接觸,內部不重疊。 | 判斷相鄰商圈是否接壤。 |
| 部分重疊,且互不包含。 | 判斷配送地區是否交叉。 |
| 穿越式相交。 | 判斷道路是否穿過商圈。 |
邊界情況:當幾何對象正好落在多邊形邊界上時,ST_WITHIN 判定為假,ST_INTERSECTS 判定為真。例如用多邊形近似表示半徑圓時,正好位於圓周上的門店不會被 ST_WITHIN 命中;如果業務上需要包含邊界,請改用 ST_INTERSECTS。對於 MULTIPOINT,只要其中一個點落在邊界上,ST_WITHIN 即為假。
前提條件
已建立 Milvus 2.6 版本執行個體。
GEOMETRY類型與 AI Function 依賴 2.6 版本核心,建立後無需單獨綁定模型服務,直接在 Collection 中聲明 Function 即可。如需從公網訪問執行個體,已在執行個體詳情頁的 安全配置 頁簽開啟 公網訪問 並將用戶端出口 IP 加入公網訪問白名單。
已安裝 pymilvus。本文樣本基於 pymilvus 3.0.0 驗證。
RESTful 介面與 gRPC 共用 19530 連接埠,調用 REST 介面時必須在地址中顯式帶上連接埠,例如 http://c-xxx.milvus.aliyuncs.com:19530;若省略連接埠將預設訪問 80 連接埠並導致連線逾時。
操作步驟
準備公用代碼
以下程式碼封裝含串連配置、REST 介面封裝以及把半徑圓近似成多邊形的工具函數,後續各步驟共用。請將 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)}))"
步驟一:建立帶地理欄位與向量化 Function 的 Collection
location 欄位使用 DataType.GEOMETRY 儲存門店座標點;review 欄位掛載 AI_EMBEDDING Function,寫入時自動產生 1024 維向量。同時為向量欄位建立 HNSW 索引、為地理欄位建立 R-Tree 索引。
# ==================== 步驟 1:建立 Collection ====================
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) # 門店經緯度,WKT: POINT(lng lat)
schema.add_field("review", DataType.VARCHAR, max_length=4096) # 評價原文,作為 embedding 輸入
schema.add_field("review_embedding", DataType.FLOAT_VECTOR, dim=1024) # 評價向量
# 寫入時自動把 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,
"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},
)
# 為 GEOMETRY 欄位建立 R-Tree 地理位置索引,加速空間查詢
index_params.add_index(
field_name="location",
index_type="RTREE",
)
client.create_collection(
collection_name=collection_name,
schema=schema,
index_params=index_params,
)
review_embedding 的維度必須與 Function 參數中的 dim 一致。為 GEOMETRY 欄位建立 RTREE 索引可加速 ST_ 空間查詢,資料量較大時建議建立。
步驟二:寫入門店與評價資料
寫入門店座標與評價原文,review_embedding 由 Function 自動產生。
# ==================== 步驟 2:寫入門店與評價資料 ====================
# 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)
寫入後必須調用 flush(),否則緊接著執行檢索可能返回空結果。load_collection() 將 Collection 載入到記憶體後才能檢索。
步驟三:地理範圍過濾
通過 ST_WITHIN 判斷門店是否落在商圈多邊形內,或是否落在指定半徑的近似圓內。半徑圓由 circle_to_wkt 近似成多邊形。
# ==================== 步驟 3:地理範圍過濾 ====================
# 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("商圈範圍內的評價記錄:")
for h in hits:
print(f" · {h['shop_name']}({h['category']}) {h['location']}")
# 3.2 附近 3km 半徑查詢:把圓近似成多邊形,再判斷門店是否落在圈內
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"中心點 ({center_lng}, {center_lat}) 附近 3km 內的評價記錄:")
for n in nearby:
print(f" · {n['shop_name']}({n['category']}) {n['location']}")
樣本資料中 4 條評價記錄分屬 3 家門店(巴蜀人家有 2 條評價),因此上述查詢返回 4 條記錄。如需按門店維度輸出,請在應用側按 shop_id 去重。
circle_to_wkt 按平面近似換算經緯度,緯度越高誤差越大;該方法適用於半徑粗過濾,若業務對邊界精度敏感,建議增大 num_seg 分段數或在應用側用精確距離二次校正。
步驟四:語義檢索與地理過濾融合
這是本方案的核心:把「就近」(filter 中的空間函數)與「對味」(ANN 向量檢索)融合進同一次 search() 調用。
# ==================== 步驟 4:語義檢索與地理過濾融合 ====================
# ANN 檢索評價向量負責「對味」,filter 中的空間函數負責「就近」,一次請求聯合求解
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(f"查詢語句:{QUERY}")
for rank, hit in enumerate(results[0], 1):
e = hit["entity"]
print(f" {rank}. [相似性 {hit['distance']:.4f}] {e['shop_name']} | {e['review'][:24]}")
返回結果樣本如下,「巴蜀人家·親子川菜」因評價中「兒童座椅」「服務員很有耐心」等內容與「適合親子」語義高度相關而排在首位:
1. [相似性 0.6926] 巴蜀人家·親子川菜 | 帶娃來的,有兒童座椅和小碗餐具,
2. [相似性 0.6591] 麻辣江湖 | 重口味愛好者天堂,水煮魚夠味,但空間偏小、比較吵
3. [相似性 0.6091] 巴蜀人家·親子川菜 | 周末人多要等位,但菜品穩定,適合一家人聚餐,孩子步驟五:使用 AI_RERANK 精排候選
向量相似性反映的是語義接近程度,與「和查詢有多相關」並不完全等價。通過 AI_RERANK 對召回候選做二次精排,可以把更貼合查詢意圖的門店排到前面。下面給出兩種用法:在 search() 中掛載 ranker,以及調用無狀態 REST 介面。
# ==================== 步驟 5:AI_RERANK 重排候選 ====================
RERANK_MODEL = "qwen3-rerank"
# 5.1 在 search() 中掛載 RERANK ranker,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,
)
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
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
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]}")
精排後的輸出樣本如下,「巴蜀人家·親子川菜」的兩條評價均被排到「麻辣江湖」之前:
1. [重排分 0.6986] 巴蜀人家·親子川菜 | 帶娃來的,有兒童座椅和小碗餐具,
2. [重排分 0.5389] 巴蜀人家·親子川菜 | 周末人多要等位,但菜品穩定,適合一家人聚餐,孩子
3. [重排分 0.5214] 麻辣江湖 | 重口味愛好者天堂,水煮魚夠味,但空間偏小、比較吵REST 介面的返回結構為 data.output.results,其中每個候選對應一項,包含 index(候選在入參 documents 中的下標)與 relevance_score(相關性得分)。按得分降序排列即為精排結果:
1. [相關度 0.6986] 帶娃來的,有兒童座椅和小碗餐具,毛血旺不算太辣,服務員很有耐心,環境乾淨。
2. [相關度 0.5214] 重口味愛好者天堂,水煮魚夠味,但空間偏小、比較吵,不太適合帶小孩。REST 介面的 documents 必須是字串數組,query 為頂層字串,且不支援 top_n 參數——傳入會報param [top_n] is not supported,介面固定為每個候選各返回一個得分,需要截斷時請在應用側按得分排序後自行取前 N 條。
候選文本需覆蓋查詢的全部語義維度。本例的查詢「附近適合親子的川菜館」同時包含「親子」與「川菜」兩個維度,候選文本缺失任一維度都會顯著掉分。以第一條候選為例:
候選文本 | 相關性得分 |
帶娃來的,有兒童座椅和小碗餐具,服務員很有耐心。(無川菜相關資訊) | 0.4723 |
帶娃來的,有兒童座椅和小碗餐具,毛血旺不算太辣,服務員很有耐心。 | 0.6666 |
帶娃來的,有兒童座椅和小碗餐具,毛血旺不算太辣,服務員很有耐心,環境乾淨。 | 0.6986 |
僅僅補上一個川菜菜名,得分就從 0.4723 升到 0.6666。而作為對照的「重口味愛好者天堂,水煮魚夠味……不太適合帶小孩。」因含「水煮魚」而在川菜維度得分,相關性穩定為 0.5214。因此如果第一條候選缺失川菜資訊,它反而會被排到後面。
因此在實際業務中,送入重排的候選文本應盡量使用完整原文,不要為了縮短長度而裁剪掉可能承載相關性的關鍵資訊(如菜品名、品類詞)。如需評估重排效果,也應使用與線上一致的完整文本。
步驟六:計算好評率並產生評價摘要
用 AI_SENTIMENT 對門店的全部評價做情感分類,在應用側彙總出好評率;再用 AI_SUMMARIZE 把評價壓縮成一句話點評。
# ==================== 步驟 6:情感分析算好評率 + 摘要產生點評 ====================
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] # 空值計入未判定,不參與彙總
positive = sum(1 for x in valid if x == "positive")
good_rate = positive / len(valid) if valid else 0.0
print(f"好評率:{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"店鋪摘要:{summ['data']['output']['outputs'][0]}")
以 shop_id 1001 為例,兩條評價均被判定為 positive,好評率為 100%,產生的摘要為「環境乾淨,毛血旺溫和適口。服務耐心且備有兒童餐具,適合帶娃家庭聚餐,周末需等位。」
情感分類結果可能返回空值,表示模型未能判定。彙總好評率時應先過濾空值,避免把未判定樣本計入分母。
榜單排序邏輯
把上述步驟串起來,一次完整的榜單產生流程為:地理過濾圈定「就近」候選,語義檢索命中「對味」,重排精排調整順序,情感與摘要補齊「口碑」。
最終榜位可由三路訊號融合:
地理訊號:是否落在半徑或商圈範圍內作為準入門檻,不滿足直接過濾;滿足後再按距離遠近給基礎權重。
語義相關性:
AI_RERANK輸出的relevance_score,衡量門店評價與使用者需求的貼合度。口碑:
AI_SENTIMENT彙總出的好評率。
可採用形如 w1 · 相關性得分 + w2 · 好評率 + w3 · 距離衰減 的加權融合,權重按營運目標調整。這也是這類榜單與「單純按評分排序」的區別:一家門店可以因為「親子友好」的語義強命中、疊加高好評率與更近的距離而綜合排到前列。
與傳統多系統方案對比:
維度 | 傳統方案(空間資料庫 + 向量庫 + NLP 服務) | 阿里雲 Milvus |
系統數量 | 3 套以上,各存一份資料 | 1 套,資料同源 |
地理與語義融合 | 跨系統關聯或多輪迴撈 | 一次 |
評價理解 | 自建或外接情感、摘要模型 |
|
向量化鏈路 | 應用側調用 embedding 後再寫入 | 寫入即向量化 |
一致性與同步 | 需自我維護 | 單庫天然一致 |
擴充方向
多模態門店檢索:用
qwen3-vl-embedding把門店圖片、菜品圖向量化,支援「附近有這種裝修風格的店」的以圖搜店,同樣可與地理過濾融合在一次檢索中。離線預計算口碑:門店評價量較大時,可用
AI_BATCH等批量方式離線預計算好評率與評價摘要並落庫,榜單查詢唯讀結果,兼顧成本與延遲。更豐富的空間關係:用
ST_INTERSECTS判斷門店配送範圍與使用者所在網格是否相交,用ST_CONTAINS做商圈歸屬分析,把「地理 + AI」的融合從找店擴充到調度、選址等環節。