Skip to content

Commit 5b155f8

Browse files
agourlayzzzz-vincentgenerall
committed
Geo polygons support (#325)
* regenerate client * regen grpc client * document build process * work from Zein in #272 * update rest client with optional field * code refactor and better naming * remove shapely as a dependency * fix typing --------- Co-authored-by: zzzz-vincent <wenzishen.vincent@hotmail.com> Co-authored-by: generall <andrey@vasnetsov.com>
1 parent d2a8b43 commit 5b155f8

File tree

13 files changed

+310
-59
lines changed

13 files changed

+310
-59
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ htmlcov
1111
dist
1212
*.tar.gz
1313
local_cache/*/*
14+
.python-version

poetry.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

qdrant_client/grpc/points_pb2.py

Lines changed: 46 additions & 36 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

qdrant_client/http/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from qdrant_client._pydantic_compat import update_forward_refs
55
from qdrant_client.http.api_client import ( # noqa F401
66
ApiClient as ApiClient,
7+
AsyncApiClient as AsyncApiClient,
78
AsyncApis as AsyncApis,
89
SyncApis as SyncApis,
910
)

qdrant_client/http/api_client.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ def request_sync(self, *, type_: Any, **kwargs: Any) -> Any: # noqa F811
8989

9090
def send(self, request: Request, type_: Type[T]) -> T:
9191
response = self.middleware(request, self.send_inner)
92-
if response.status_code in [200, 201]:
92+
if response.status_code in [200, 201, 202]:
9393
try:
9494
return parse_as_type(response.json(), type_)
9595
except ValidationError as e:
@@ -161,7 +161,7 @@ def request_sync(self, *, type_: Any, **kwargs: Any) -> Any: # noqa F811
161161

162162
async def send(self, request: Request, type_: Type[T]) -> T:
163163
response = await self.middleware(request, self.send_inner)
164-
if response.status_code in [200, 201]:
164+
if response.status_code in [200, 201, 202]:
165165
try:
166166
return parse_as_type(response.json(), type_)
167167
except ValidationError as e:

qdrant_client/http/models/models.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,9 @@ class FieldCondition(BaseModel, extra="forbid"):
432432
default=None, description="Check if points geo location lies in a given area"
433433
)
434434
geo_radius: Optional["GeoRadius"] = Field(default=None, description="Check if geo point is within a given radius")
435+
geo_polygon: Optional["GeoPolygon"] = Field(
436+
default=None, description="Check if geo point is within a given polygon"
437+
)
435438
values_count: Optional["ValuesCount"] = Field(default=None, description="Check number of values of the field")
436439

437440

@@ -462,6 +465,14 @@ class GeoBoundingBox(BaseModel, extra="forbid"):
462465
)
463466

464467

468+
class GeoLineString(BaseModel, extra="forbid"):
469+
"""
470+
Ordered sequence of GeoPoints representing the line
471+
"""
472+
473+
points: List["GeoPoint"] = Field(..., description="Ordered sequence of GeoPoints representing the line")
474+
475+
465476
class GeoPoint(BaseModel, extra="forbid"):
466477
"""
467478
Geo point payload schema
@@ -471,6 +482,21 @@ class GeoPoint(BaseModel, extra="forbid"):
471482
lat: float = Field(..., description="Geo point payload schema")
472483

473484

485+
class GeoPolygon(BaseModel, extra="forbid"):
486+
"""
487+
Geo filter request Matches coordinates inside the polygon, defined by `exterior` and `interiors`
488+
"""
489+
490+
exterior: "GeoLineString" = Field(
491+
...,
492+
description="Geo filter request Matches coordinates inside the polygon, defined by `exterior` and `interiors`",
493+
)
494+
interiors: Optional[List["GeoLineString"]] = Field(
495+
default=None,
496+
description="Interior lines (if present) bound holes within the surface each GeoLineString must consist of a minimum of 4 points, and the first and last points must be the same.",
497+
)
498+
499+
474500
class GeoRadius(BaseModel, extra="forbid"):
475501
"""
476502
Geo filter request Matches coordinates inside the circle of `radius` and center with coordinates `center`
@@ -1618,7 +1644,7 @@ class UpdateCollection(BaseModel, extra="forbid"):
16181644

16191645
vectors: Optional["VectorsConfigDiff"] = Field(
16201646
default=None,
1621-
description="Vector data parameters to update. It is possible to provide one config for single vector mode and list of configs for multiple vectors mode.",
1647+
description="Map of vector data parameters to update for each named vector. To update parameters in a collection having a single unnamed vector, use an empty string as name.",
16221648
)
16231649
optimizers_config: Optional["OptimizersConfigDiff"] = Field(
16241650
default=None,

qdrant_client/local/geo.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from math import asin, cos, radians, sin, sqrt
2+
from typing import List, Tuple
23

34

45
def geo_distance(lon1: float, lat1: float, lon2: float, lat2: float) -> float:
@@ -38,3 +39,51 @@ def test_geo_distance() -> None:
3839
assert geo_distance(moscow["lon"], moscow["lat"], london["lon"], london["lat"]) < 2600 * 1000
3940
assert geo_distance(moscow["lon"], moscow["lat"], berlin["lon"], berlin["lat"]) > 1600 * 1000
4041
assert geo_distance(moscow["lon"], moscow["lat"], berlin["lon"], berlin["lat"]) < 1650 * 1000
42+
43+
44+
def boolean_point_in_polygon(
45+
point: Tuple[float, float],
46+
exterior: List[Tuple[float, float]],
47+
interiors: List[List[Tuple[float, float]]],
48+
) -> bool:
49+
inside_poly = False
50+
51+
if in_ring(point, exterior, True):
52+
in_hole = False
53+
k = 0
54+
while k < len(interiors) and not in_hole:
55+
if in_ring(point, interiors[k], False):
56+
in_hole = True
57+
k += 1
58+
if not in_hole:
59+
inside_poly = True
60+
61+
return inside_poly
62+
63+
64+
def in_ring(
65+
pt: Tuple[float, float], ring: List[Tuple[float, float]], ignore_boundary: bool
66+
) -> bool:
67+
is_inside = False
68+
if ring[0][0] == ring[len(ring) - 1][0] and ring[0][1] == ring[len(ring) - 1][1]:
69+
ring = ring[0 : len(ring) - 1]
70+
j = len(ring) - 1
71+
for i in range(0, len(ring)):
72+
xi = ring[i][0]
73+
yi = ring[i][1]
74+
xj = ring[j][0]
75+
yj = ring[j][1]
76+
on_boundary = (
77+
(pt[1] * (xi - xj) + yi * (xj - pt[0]) + yj * (pt[0] - xi) == 0)
78+
and ((xi - pt[0]) * (xj - pt[0]) <= 0)
79+
and ((yi - pt[1]) * (yj - pt[1]) <= 0)
80+
)
81+
if on_boundary:
82+
return not ignore_boundary
83+
intersect = ((yi > pt[1]) != (yj > pt[1])) and (
84+
pt[0] < (xj - xi) * (pt[1] - yi) / (yj - yi) + xi
85+
)
86+
if intersect:
87+
is_inside = not is_inside
88+
j = i
89+
return is_inside

qdrant_client/local/payload_filters.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import numpy as np
44

55
from qdrant_client.http import models
6-
from qdrant_client.local.geo import geo_distance
6+
from qdrant_client.local.geo import boolean_point_in_polygon, geo_distance
77
from qdrant_client.local.payload_value_extractor import value_by_key
88

99

@@ -70,6 +70,22 @@ def check_geo_bounding_box(condition: models.GeoBoundingBox, values: Any) -> boo
7070
return False
7171

7272

73+
def check_geo_polygon(condition: models.GeoPolygon, values: Any) -> bool:
74+
if isinstance(values, dict) and "lat" in values and "lon" in values:
75+
lat = values["lat"]
76+
lon = values["lon"]
77+
exterior = [(point.lat, point.lon) for point in condition.exterior.points]
78+
interiors = []
79+
if condition.interiors is not None:
80+
interiors = [
81+
[(point.lat, point.lon) for point in interior.points]
82+
for interior in condition.interiors
83+
]
84+
return boolean_point_in_polygon(point=(lat, lon), exterior=exterior, interiors=interiors)
85+
86+
return False
87+
88+
7389
def check_range(condition: models.Range, value: Any) -> bool:
7490
if not isinstance(value, (int, float)):
7591
return False
@@ -141,6 +157,10 @@ def check_condition(
141157
if condition.values_count is not None:
142158
values = value_by_key(payload, condition.key, flat=False)
143159
return check_values_count(condition.values_count, values)
160+
if condition.geo_polygon is not None:
161+
if values is None:
162+
return False
163+
return any(check_geo_polygon(condition.geo_polygon, v) for v in values)
144164
elif isinstance(condition, models.NestedCondition):
145165
values = value_by_key(payload, condition.nested.key)
146166
if values is None:

qdrant_client/local/tests/test_payload_filters.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,50 @@ def test_nested_payload_filters():
140140

141141
res = check_filter(query, payload, 0)
142142
assert res is False
143+
144+
145+
def test_geo_polygon_filter_query():
146+
payload = {
147+
"location": [
148+
{
149+
"lon": 70.0,
150+
"lat": 70.0,
151+
},
152+
]
153+
}
154+
155+
query = models.Filter(
156+
**{
157+
"must": [
158+
{
159+
"key": "location",
160+
"geo_polygon": {
161+
"exterior": {
162+
"points": [
163+
{"lon": 55.455868, "lat": 55.495862},
164+
{"lon": 86.455868, "lat": 55.495862},
165+
{"lon": 86.455868, "lat": 86.495862},
166+
{"lon": 55.455868, "lat": 86.495862},
167+
{"lon": 55.455868, "lat": 55.495862},
168+
]
169+
},
170+
},
171+
}
172+
]
173+
}
174+
)
175+
176+
res = check_filter(query, payload, 0)
177+
assert res is True
178+
179+
payload = {
180+
"location": [
181+
{
182+
"lon": 30.693738,
183+
"lat": 30.502165,
184+
},
185+
]
186+
}
187+
188+
res = check_filter(query, payload, 0)
189+
assert res is False

qdrant_client/proto/collections.proto

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ message OptimizersConfigDiff {
159159
Do not create segments larger this size (in kilobytes).
160160
Large segments might require disproportionately long indexation times,
161161
therefore it makes sense to limit the size of segments.
162-
162+
163163
If indexing speed is more important - make this parameter lower.
164164
If search speed is more important - make this parameter higher.
165165
Note: 1Kb = 1 vector of size 256
@@ -179,11 +179,11 @@ message OptimizersConfigDiff {
179179
optional uint64 memmap_threshold = 5;
180180
/*
181181
Maximum size (in kilobytes) of vectors allowed for plain index, exceeding this threshold will enable vector indexing
182-
182+
183183
Default value is 20,000, based on <https://github.com/google-research/google-research/blob/master/scann/docs/algorithms.md>.
184-
184+
185185
To disable vector indexing, set to `0`.
186-
186+
187187
Note: 1kB = 1 vector of size 256.
188188
*/
189189
optional uint64 indexing_threshold = 6;
@@ -412,7 +412,7 @@ message ShardTransferInfo {
412412
}
413413

414414
message CollectionClusterInfoResponse {
415-
uint64 peer_id = 1; // ID of this peer
415+
uint64 peer_id = 1; // ID of this peer
416416
uint64 shard_count = 2; // Total number of shards
417417
repeated LocalShardInfo local_shards = 3; // Local shards
418418
repeated RemoteShardInfo remote_shards = 4; // Remote shards

0 commit comments

Comments
 (0)