Alibaba Cloud Milvus combines its GEOMETRY geospatial type with AI Functions so that a single search performs both geospatial range filtering and semantic recall over one Collection. This topic describes how to run "nearby" filtering and "on-taste" recall together, and then layer sentiment analysis and summary generation on top to build an explainable local lifestyle leaderboard.
Solution overview
"What is good to eat nearby" is the most frequent request in local lifestyle scenarios. To answer it well, a system must satisfy three conditions at the same time:
Nearby: stores within a given radius of the user's current location, or within the business district the user is in.
On taste: requests such as "a family-friendly Sichuan restaurant" or "a quiet cafe where I can talk business", which cannot be enumerated precisely with tags.
Trustworthy: what real reviews say, how high the positive-review rate is, and which strengths and complaints come up repeatedly.
A traditional implementation usually stitches three systems together: a spatial database for geospatial filtering, a vector database for semantic recall, and an NLP service for sentiment classification and summarization. Each system keeps its own copy of the data, so cross-system joins, data synchronization, and consistency maintenance are expensive, and one query has to chain multiple services and several network round trips.
Dimension | Traditional solution (spatial database + vector database + NLP service) | Alibaba Cloud Milvus |
Number of systems | 3 or more, each with its own copy of the data | 1, with a single source of data |
Geospatial and semantic fusion | Cross-system joins or multiple recall rounds | One |
Review understanding | Self-built or external sentiment and summarization models |
|
Vectorization path | The application calls embedding first, then writes the data | Vectorized on insert |
Consistency and synchronization | Maintained by yourself | Naturally consistent in a single database |
Alibaba Cloud Milvus converges these capabilities into one Collection and one search:
Capability | Implementation |
Geospatial range filtering | Store longitude and latitude in a |
Semantic search over reviews | Review text is vectorized automatically at insert time by |
Relevance reranking | Recalled candidates are reordered by query relevance with |
Reputation quantification | Use |
Geospatial filtering and semantic search run in a single search() call: the ANN vector search handles "on taste", the spatial functions in filter handle "nearby", both are solved jointly within one request, and reranking is applied on top to produce leaderboard candidates directly.
This topic uses the following AI Functions:
Function | What it does | Role in the leaderboard | How it is called |
| Converts review text into vectors automatically at insert time, so the application does not need to call a model first. | Lets semantic requests such as "a family-friendly Sichuan restaurant" be matched by vector search. | Declared as a Function in the Collection schema ( |
| Reorders recalled candidates by relevance to the query. | Moves the stores that best fit the request to the top of the leaderboard. | Attached as a ranker in |
| Determines whether a review is positive, negative, or neutral. | Aggregates the positive-review rate of a store. | Called through the REST endpoint |
| Compresses a large number of reviews into a short summary. | Generates the one-line comment shown in the leaderboard. | Called through the REST endpoint |
AI_EMBEDDING and AI_RERANK can be declared in the Collection or attached to a search, while AI_SENTIMENT and AI_SUMMARIZE are available only through the REST interface.
Prerequisites
A Milvus 2.6 instance is created. The
GEOMETRYtype and AI Functions require the 2.6 kernel. After you create the instance, you do not need to bind a model service separately—declare the Function in the Collection directly.To access the instance over the Internet, Public Network Access is enabled on the Security Configuration tab of the instance details page, and the client egress IP address is added to the public access whitelist.
pymilvus is installed. The examples in this topic are verified with pymilvus 3.0.0.
Limitations and usage notes
Review the following behaviors before you copy the code, because several of them produce silent or empty results rather than errors:
Geospatial boundary semantics — When a geometry object lies exactly on the boundary of a polygon,
ST_WITHINreturns false andST_INTERSECTSreturns true. For example, when a radius circle is approximated by a polygon, a store located exactly on the circumference is not matched byST_WITHIN. If your business logic must include the boundary, useST_INTERSECTSinstead. For aMULTIPOINT,ST_WITHINreturns false as soon as one of its points lies on the boundary.Radius approximation accuracy —
circle_to_wktconverts longitude and latitude with a planar approximation, so the error grows as the latitude increases. This method suits coarse radius filtering. If your business logic is sensitive to boundary precision, increase thenum_segsegment count or verify the exact distance again on the application side.REST interface port — The RESTful interface shares port 19530 with
gRPC, so you must include the port explicitly in the address when you call the REST interface, for examplehttp://c-xxx.milvus.aliyuncs.com:19530. If you omit the port, the request goes to port 80 by default and the connection times out.REST rerank parameters — For the REST rerank interface,
documentsmust be an array of strings andquerymust be a top-level string. Thetop_nparameter is not supported—passing it returnsparam [top_n] is not supported. The interface always returns one score per candidate, so if you need to truncate the list, sort by score and take the top N on the application side.Undetermined sentiment —
AI_SENTIMENTmay return an empty value, which means the model could not determine the sentiment. Filter out empty values before you aggregate the positive-review rate, so that undetermined samples are not counted in the denominator.
Procedure
Run the steps in order within the same session. The code snippets form one continuous script: later steps reuse the client, helper functions, and variables that earlier steps define (for example, circle from Step 3 and QUERY and geo_filter from Step 4). If you copy a single step in isolation, define its upstream variables first.
Step 0: Prepare the shared code (required)
The following code contains the connection settings, a REST interface wrapper, and a utility function that approximates a radius circle as a polygon. All later steps depend on this code. Replace MILVUS_HOST and MILVUS_TOKEN with the information of your own instance.
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
# ==================== Connection settings ====================
MILVUS_HOST = "http://c-xxx.milvus.aliyuncs.com"
MILVUS_TOKEN = "root:xxx"
# The RESTful interface shares port 19530 with gRPC. Include the port explicitly,
# otherwise the request goes to port 80 by default and the connection times out.
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]]:
"""Shared wrapper for the AI_RERANK / AI_SENTIMENT / AI_SUMMARIZE REST interfaces."""
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:
"""Approximate the circle centered at (lng, lat) with a radius of radius_km as a regular polygon in WKT.
Note: longitude and latitude are angles. This conversion is a planar approximation,
so the error grows as the latitude increases."""
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)}))"The MILVUS_HOST value is used as the client URI, while MILVUS_REST_BASE_URL appends port 19530 for the REST calls in later steps. The explicit-port requirement applies to the REST interface.
Step 1: Create a collection with a geospatial field and an embedding function
The location field uses DataType.GEOMETRY to store the coordinate point of a store. The review field carries an AI_EMBEDDING function that generates a 1,024-dimensional vector automatically at insert time. At the same time, create an HNSW index for the vector field and an R-Tree index for the geospatial field.
The following code runs drop_collection before it creates the Collection, which permanently deletes any existing Collection named shop_reviews and all of its data. On an instance that already holds business data, use a separate test Collection name or remove the drop_collection branch.
# ==================== Step 1: Create the 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) # Store coordinates, WKT: POINT(lng lat)
schema.add_field("review", DataType.VARCHAR, max_length=4096) # Raw review text, used as the embedding input
schema.add_field("review_embedding", DataType.FLOAT_VECTOR, dim=1024) # Review vector
# Vectorize review automatically at insert time
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},
)
# Create an R-Tree geospatial index for the GEOMETRY field to speed up spatial queries
index_params.add_index(
field_name="location",
index_type="RTREE",
)
client.create_collection(
collection_name=collection_name,
schema=schema,
index_params=index_params,
)The dimension of review_embedding must match the dim value in the Function parameters. Creating an RTREE index for the GEOMETRY field speeds up ST_ spatial queries and is recommended when the data volume is large.
The model_name values in this topic (qwen3.7-text-embedding here, and the rerank, sentiment, and summarization models in later steps) are examples. Replace each with a model that is available on your own instance.
Step 2: Insert store and review data
Insert the store coordinates and the raw review text. review_embedding is generated automatically by the Function.
# ==================== Step 2: Insert store and review data ====================
# review_embedding is generated automatically by the Function, so you do not pass vectors manually
rows = [
{"shop_id": 1001, "shop_name": "Bashu Homestead Family Sichuan", "category": "Sichuan cuisine",
"location": "POINT(116.4612 39.9088)",
"review": "Came with my kid. High chairs and small bowls are available, the maoxuewang is not too spicy, the servers are patient, and the place is clean."},
{"shop_id": 1001, "shop_name": "Bashu Homestead Family Sichuan", "category": "Sichuan cuisine",
"location": "POINT(116.4612 39.9088)",
"review": "You wait for a table on weekends, but the dishes are consistent. Good for a family meal, and my kid enjoyed it."},
{"shop_id": 1002, "shop_name": "Mala Jianghu", "category": "Sichuan cuisine",
"location": "POINT(116.4501 39.9101)",
"review": "A paradise for bold flavors, the shuizhuyu is punchy, but the space is small and noisy, so it does not suit kids."},
{"shop_id": 1003, "shop_name": "Quiet Lane Coffee", "category": "Coffee",
"location": "POINT(116.4550 39.9075)",
"review": "Quiet enough to talk business and the pour-over is good, but it has nothing to do with a Sichuan food request."},
]
client.insert(collection_name, rows)
client.flush(collection_name)
client.load_collection(collection_name)Call flush() after inserting data, otherwise a search that runs immediately afterwards may return empty results. load_collection() loads the Collection into memory, which is required before you can search it.
Step 3: Filter by geospatial range
Choose the range method based on the shape of your target area:
Administrative district or business district — Represent the area as a
POLYGONand useST_WITHIN.Area around the user's current location — Approximate a radius circle as a polygon with
circle_to_wkt, then useST_WITHIN.Logic that must include the boundary or detect overlapping areas — Use
ST_INTERSECTSinstead, because a geometry located exactly on the boundary is not matched byST_WITHIN.The following code shows both the business district polygon and the radius circle. The radius circle is approximated as a polygon by
circle_to_wkt.
# ==================== Step 3: Geospatial range filtering ====================
# 3.1 Query the stores within a business district polygon
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("Review records within the business district:")
for h in hits:
print(f" - {h['shop_name']} ({h['category']}) {h['location']}")
# 3.2 Query within a 3 km radius: approximate the circle as a polygon, then check whether stores fall inside it
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"Review records within 3 km of the center point ({center_lng}, {center_lat}):")
for n in nearby:
print(f" - {n['shop_name']} ({n['category']}) {n['location']}")The sample data has 4 review records across 3 stores (Bashu Homestead has 2 reviews), so both queries return 4 review records. To output one row per store, deduplicate by shop_id on the application side.
Step 4: Combine semantic search with geospatial filtering
This step fuses "nearby" (the spatial functions in filter) and "on taste" (the ANN vector search) into a single search() call. It reuses circle from Step 3.
# ==================== Step 4: Combine semantic search with geospatial filtering ====================
# The ANN search over review vectors handles "on taste", the spatial functions in filter handle "nearby",
# and both are solved jointly in one request
QUERY = "family-friendly Sichuan restaurants nearby"
geo_filter = f"category == 'Sichuan cuisine' && 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: {QUERY}")
for rank, hit in enumerate(results[0], 1):
e = hit["entity"]
print(f" {rank}. [similarity {hit['distance']:.4f}] {e['shop_name']} | {e['review'][:24]}")The following example shows the returned results. "Bashu Homestead Family Sichuan" ranks first because its reviews mention "high chairs" and "the servers are patient", which are semantically close to "family-friendly":
1. [similarity 0.6926] Bashu Homestead Family Sichuan | Came with my kid. High c
2. [similarity 0.6591] Mala Jianghu | A paradise for bold flav
3. [similarity 0.6091] Bashu Homestead Family Sichuan | You wait for a table onStep 5: Rerank candidates with AI_RERANK
Vector similarity reflects semantic closeness, which is not exactly the same as how relevant a result is to the query. Reranking the recalled candidates with AI_RERANK moves the stores that better fit the query intent to the front. This step reuses QUERY and geo_filter from Step 4.
Two options are available; choose one for your scenario:
Rerank within the retrieval pipeline — Attach a ranker in
search()(used in 5.1). The model reranks the candidates that the ANN search recalls from the Collection.Rerank an existing list of texts — Call the stateless REST interface
/v2/vectordb/ai/rerank(used in 5.2). This path takes a text list directly and does not depend on a search.The two subsections below are alternatives, not sequential steps.
# ==================== Step 5: Rerank candidates with AI_RERANK ====================
RERANK_MODEL = "qwen3-rerank"
# 5.1 Attach a RERANK ranker in search(): after ANN recall, the model reranks the candidates
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}. [rerank score {hit['distance']:.4f}] {e['shop_name']} | {e['review'][:24]}")
# 5.2 You can also use the stateless REST interface /v2/vectordb/ai/rerank
rerank_docs = [
"Came with my kid. High chairs and small bowls are available, the maoxuewang is not too spicy, the servers are patient, and the place is clean.",
"A paradise for bold flavors, the shuizhuyu is punchy, but the space is small and noisy, so it does not suit kids.",
]
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}. [relevance {item['relevance_score']:.4f}] {rerank_docs[item['index']][:28]}")The following example shows the output after reranking. Both reviews of "Bashu Homestead Family Sichuan" are ranked ahead of "Mala Jianghu":
1. [rerank score 0.6986] Bashu Homestead Family Sichuan | Came with my kid. High c
2. [rerank score 0.5389] Bashu Homestead Family Sichuan | You wait for a table on
3. [rerank score 0.5214] Mala Jianghu | A paradise for bold flavThe REST interface returns its results in data.output.results, where each item corresponds to one candidate and contains index (the position of the candidate in the documents request parameter) and relevance_score (the relevance score). Sorting by score in descending order gives the reranked result:
1. [relevance 0.6986] Came with my kid. High chairs
2. [relevance 0.5214] A paradise for bold flavors,Candidate text must cover every semantic dimension of the query. The query in this example, "family-friendly Sichuan restaurants nearby", covers both the "family-friendly" dimension and the "Sichuan cuisine" dimension, and a candidate that misses either one loses a significant amount of score. Take the first candidate as an example:
Candidate text | Relevance score |
Came with my kid. High chairs and small bowls are available, the servers are patient. (no Sichuan cuisine information) | 0.4723 |
Came with my kid. High chairs and small bowls are available, the maoxuewang is not too spicy, the servers are patient. | 0.6666 |
Came with my kid. High chairs and small bowls are available, the maoxuewang is not too spicy, the servers are patient, and the place is clean. | 0.6986 |
Adding a single Sichuan dish name raises the score from 0.4723 to 0.6666. By contrast, "A paradise for bold flavors, the shuizhuyu is punchy ... it does not suit kids." scores on the Sichuan cuisine dimension because it mentions "shuizhuyu", and its relevance stays at 0.5214. So if the first candidate had no Sichuan cuisine information, it would instead be ranked lower.
In production, therefore, send the full original text as the candidate for reranking, and do not trim away key information that may carry relevance, such as dish names or category words, just to shorten the text. When you evaluate reranking quality, also use the same full text that you use online.
Step 6: Compute the positive-review rate and generate review summaries
Use AI_SENTIMENT to classify the sentiment of all reviews of a store and aggregate the positive-review rate on the application side. Then use AI_SUMMARIZE to compress the reviews into a one-line comment. This step reads the data inserted in Step 2.
# ==================== Step 6: Sentiment analysis for the positive-review rate + summary generation ====================
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 Classify sentiment and aggregate the positive-review rate
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] # Empty values count as undetermined and are excluded from the aggregation
positive = sum(1 for x in valid if x == "positive")
good_rate = positive / len(valid) if valid else 0.0
print(f"Positive-review rate: {good_rate:.0%} (valid samples {len(valid)}/{len(labels)})")
# 6.2 Generate the review summary of the store
status, summ = post_json("/v2/vectordb/ai/summarize", {
"model_name": "qwen3.7-max",
"texts": ["\n".join(reviews)],
"params": {"max_words": 40, "temperature": 0,
"prompt": "Summarize the overall reviews of this store in English, highlighting the signature dishes, the diners it suits, and the atmosphere."},
})
assert status == 200 and summ.get("code") == 0, summ
print(f"Store summary: {summ['data']['output']['outputs'][0]}")Take shop_id 1001 as an example: both reviews are classified as positive, the positive-review rate is 100%, and the generated summary is "The place is clean and the maoxuewang is mild and palatable. The service is patient and children's tableware is available, so it suits family meals with kids, and you wait for a table on weekends."
The aggregation matches each label against the values you pass in the categories request parameter. AI_SENTIMENT may also return an empty value when the model cannot determine the sentiment; filter out empty values before you aggregate, as shown by the valid list.
Geometry types and spatial functions
This section describes the GEOMETRY type and the ST_ spatial relationship functions used in the procedure.
GEOMETRY and WKT
If you store longitude and latitude as two DOUBLE fields, the database treats them as two numbers and cannot determine "which one is inside which" or "whether two areas intersect". GEOMETRY is the geospatial type provided by Milvus 2.6. It stores points, lines, and polygons as geometry objects, which enables spatial relationship operations and lets a geospatial index accelerate queries.
Values of a GEOMETRY field use the WKT (Well-Known Text) format. The common types are as follows:
WKT type | Example | Meaning | Counterpart in this topic |
|
| A single point | The coordinates of one store |
|
| A polyline | A road segment or a delivery route |
|
| A closed area whose first and last coordinates are identical | A business district or a delivery area |
|
| Multiple points | Multiple stores of one brand |
|
| Multiple areas | Multiple administrative districts |
Coordinates are ordered longitude first and latitude second, that is POINT(lng lat), which is the reverse of the spoken order "latitude ... longitude ...". All coordinates in this topic use the WGS84 coordinate system.
ST_ spatial relationship functions
Functions that start with ST_ determine the spatial relationship between two geometry objects. You write them in a filter expression and they return true or false. This topic mainly uses the following three:
Function | Description | Use in this topic |
| Whether A lies entirely inside B. | Determines whether a store is inside a business district polygon, or within a given radius. |
| Whether A entirely contains B, the inverse of | Determines whether a business district contains a given store, for attribution analysis. |
| Whether A and B have any intersection or contact. | Determines whether a store's delivery area intersects the grid cell the user is in. |
The following functions are also supported and can be used as needed:
Function | Description | Scenario |
| Two geometry objects are identical. | Geometry deduplication. |
| Only the boundaries touch and the interiors do not overlap. | Determines whether adjacent business districts border each other. |
| Partial overlap without containment. | Determines whether delivery areas cross. |
| Crossing intersection. | Determines whether a road passes through a business district. |
Leaderboard ranking logic
Chaining the preceding steps gives one complete leaderboard generation flow: geospatial filtering narrows the candidates down to "nearby", semantic search matches "on taste", reranking adjusts the order, and sentiment plus summarization fill in the "reputation". To produce one row per store, deduplicate the candidates by shop_id on the application side.
The final position can fuse three signals:
Geospatial signal: whether a store falls within the radius or the business district acts as the admission threshold, and stores that do not qualify are filtered out directly. Qualified stores then get a base weight according to distance.
Semantic relevance: the
relevance_scorereturned byAI_RERANK, which measures how well a store's reviews fit the user's request.Reputation: the positive-review rate aggregated by
AI_SENTIMENT.You can combine these signals with a weighted fusion in the form of
w1 · relevance score + w2 · positive-review rate + w3 · distance decay, and tune the weights to your business goals. Under this fusion, a store can rank high overall because it strongly matches the "family-friendly" semantics and because that match is combined with a high positive-review rate and a shorter distance, rather than by rating alone.
Further extensions
Multi-modal store search: use
qwen3-vl-embeddingto vectorize store photos and dish photos to support image-based store search such as "a nearby place with this kind of decor", which can also be fused with geospatial filtering in a single search.Offline precomputation of reputation: when a store has a large number of reviews, use batch methods such as
AI_BATCHto precompute the positive-review rate and the review summary offline and persist them, so that leaderboard queries only read the results, which balances cost and latency.Richer spatial relationships: use
ST_INTERSECTSto determine whether a store's delivery area intersects the grid cell the user is in, and useST_CONTAINSfor business district attribution analysis, extending the "geospatial + AI" fusion from finding stores to dispatching, site selection, and other steps.