Skip to content

Commit 01aa7ba

Browse files
feat(index): add scalar inverted index support (#342)
Add scalar INVERTED index creation, persistence, recovery, and query/search filter acceleration. Extend index spec handling, sidecar file management, snapshot references, and gRPC index parameter translation to support scalar indexes alongside existing vector indexes. Also add scalar index tests, snapshot coverage, and a demo for indexed scalar filter queries. Signed-off-by: junjie.jiang <junjie.jiang@zilliz.com>
1 parent 15bcd61 commit 01aa7ba

27 files changed

Lines changed: 1746 additions & 176 deletions

examples/m11_scalar_index_demo.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""M11 demo — scalar INVERTED index.
2+
3+
Shows scalar indexes on query() and search() filters:
4+
5+
insert → flush → create_index(age/category) → query/search with filters
6+
→ inspect .sidx sidecars → reopen → load → query again
7+
8+
Run:
9+
python examples/m11_scalar_index_demo.py
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import os
15+
import shutil
16+
import tempfile
17+
18+
import numpy as np
19+
20+
from milvus_lite import CollectionSchema, DataType, FieldSchema, MilvusLite
21+
22+
23+
def _schema() -> CollectionSchema:
24+
return CollectionSchema(fields=[
25+
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True),
26+
FieldSchema(name="vec", dtype=DataType.FLOAT_VECTOR, dim=4),
27+
FieldSchema(name="age", dtype=DataType.INT64, nullable=True),
28+
FieldSchema(name="category", dtype=DataType.VARCHAR, nullable=True, max_length=64),
29+
FieldSchema(name="active", dtype=DataType.BOOL, nullable=True),
30+
])
31+
32+
33+
def _rows() -> list[dict]:
34+
categories = ["tech", "news", "blog", "ai", None]
35+
rows = []
36+
for i in range(20):
37+
rows.append({
38+
"id": i,
39+
"vec": [float(i == j) for j in range(4)],
40+
"age": None if i % 9 == 0 else 18 + i,
41+
"category": categories[i % len(categories)],
42+
"active": None if i % 7 == 0 else i % 2 == 0,
43+
})
44+
return rows
45+
46+
47+
def _ids(rows: list[dict]) -> list[int]:
48+
return [int(row["id"]) for row in rows]
49+
50+
51+
def _index_files(data_dir: str) -> list[str]:
52+
index_dir = os.path.join(data_dir, "collections", "docs", "partitions", "_default", "indexes")
53+
if not os.path.isdir(index_dir):
54+
return []
55+
return sorted(os.listdir(index_dir))
56+
57+
58+
def main() -> None:
59+
data_dir = tempfile.mkdtemp(prefix="milvus_lite_m11_scalar_index_")
60+
print(f"data_dir = {data_dir}")
61+
62+
try:
63+
with MilvusLite(data_dir) as db:
64+
col = db.create_collection("docs", _schema())
65+
col.insert(_rows())
66+
col.flush()
67+
print("\n[1] inserted and flushed 20 rows")
68+
69+
print("\n[2] create scalar INVERTED indexes")
70+
col.create_index("age", {"index_type": "INVERTED"})
71+
col.create_index("category", {"index_type": "INVERTED"})
72+
print(f" indexes = {col.list_indexes()}")
73+
print(f" age index info = {col.get_index_info('age')}")
74+
print(f" category index info = {col.get_index_info('category')}")
75+
76+
print("\n[3] query() uses indexed scalar predicates")
77+
adults = col.query("age >= 25 and age < 32", output_fields=["age", "category"])
78+
print(f" age >= 25 and age < 32 ids = {_ids(adults)}")
79+
assert _ids(adults) == [7, 8, 10, 11, 12, 13]
80+
81+
tech_or_ai = col.query(
82+
"category in ['tech', 'ai']",
83+
output_fields=["category"],
84+
limit=6,
85+
)
86+
print(f" category in ['tech', 'ai'] first ids = {_ids(tech_or_ai)}")
87+
assert all(row["category"] in {"tech", "ai"} for row in tech_or_ai)
88+
89+
print("\n[4] search() combines vector ranking with indexed scalar filter")
90+
query = np.asarray([[1, 0, 0, 0]], dtype=np.float32).tolist()
91+
results = col.search(
92+
query,
93+
top_k=5,
94+
metric_type="L2",
95+
expr="category == 'tech' and age is not null",
96+
output_fields=["age", "category"],
97+
)
98+
for hit in results[0]:
99+
entity = hit["entity"]
100+
print(
101+
f" id={hit['id']:2d} dist={hit['distance']:.1f} "
102+
f"age={entity['age']} category={entity['category']}"
103+
)
104+
assert entity["category"] == "tech"
105+
assert entity["age"] is not None
106+
107+
print("\n[5] .sidx sidecar files were written")
108+
sidecars = _index_files(data_dir)
109+
for filename in sidecars:
110+
print(f" {filename}")
111+
assert any(filename.endswith(".age.inverted.sidx") for filename in sidecars)
112+
assert any(filename.endswith(".category.inverted.sidx") for filename in sidecars)
113+
114+
print("\n[6] reopen collection and load existing scalar index sidecars")
115+
with MilvusLite(data_dir) as db:
116+
reopened = db.get_collection("docs")
117+
reopened.load()
118+
rows = reopened.query("category == 'news'", output_fields=["category"])
119+
print(f" category == 'news' ids after reopen = {_ids(rows)}")
120+
assert _ids(rows) == [1, 6, 11, 16]
121+
122+
print("\nOK — M11 demo passed: scalar INVERTED indexes work end-to-end")
123+
finally:
124+
shutil.rmtree(data_dir, ignore_errors=True)
125+
126+
127+
if __name__ == "__main__":
128+
main()

milvus_lite/adapter/grpc/servicer.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -493,11 +493,17 @@ def Search(self, request, context):
493493
from milvus_lite.function.types import ID_FIELD, SCORE_FIELD
494494

495495
col = self._db.get_collection(request.collection_name)
496-
# Pull the canonical metric from the first IndexSpec if any.
497-
first_spec = col._index_specs.get(col._vector_name) if col._index_specs else None # noqa: SLF001
498-
if first_spec is None and col._index_specs:
499-
first_spec = next(iter(col._index_specs.values()))
500-
default_metric = first_spec.metric_type if first_spec else "COSINE"
496+
# Pull the canonical metric from a vector index if any.
497+
first_spec = (
498+
col._index_specs.get(col._vector_name) # noqa: SLF001
499+
if col._index_specs and col._vector_name is not None # noqa: SLF001
500+
else None
501+
)
502+
default_metric = (
503+
first_spec.metric_type
504+
if first_spec is not None and first_spec.metric_type != "NONE"
505+
else "COSINE"
506+
)
501507
parsed = parse_search_request(request, default_metric_type=default_metric)
502508

503509
group_by_field = parsed.get("group_by_field")
@@ -645,9 +651,7 @@ def CreateIndex(self, request, context):
645651
"""
646652
try:
647653
col = self._db.get_collection(request.collection_name)
648-
params = kv_pairs_to_index_params_dict(
649-
request.extra_params, field_name=request.field_name
650-
)
654+
params = kv_pairs_to_index_params_dict(request.extra_params)
651655
col.create_index(request.field_name, params)
652656
return common_pb2.Status(**success_status_kwargs())
653657
except MilvusLiteError as e:

milvus_lite/adapter/grpc/translators/index.py

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -28,40 +28,28 @@
2828
from milvus_lite.index.spec import IndexSpec
2929

3030

31-
def kv_pairs_to_index_params_dict(
32-
extra_params: Iterable,
33-
field_name: str = "",
34-
) -> dict:
31+
def kv_pairs_to_index_params_dict(extra_params: Iterable) -> dict:
3532
"""Decode the CreateIndexRequest.extra_params KeyValuePair list
3633
into a dict suitable for ``Collection.create_index(field, params)``.
3734
3835
Args:
3936
extra_params: iterable of common_pb2.KeyValuePair messages
40-
field_name: fallback field_name from the outer request, used
41-
if the KV list doesn't carry one
4237
4338
Returns:
4439
``{"index_type": ..., "metric_type": ..., "params": {...},
45-
"search_params": {...}}`` — the shape Collection.create_index
46-
consumes.
40+
"search_params": {...}}`` — the index_params shape
41+
Collection.create_index consumes.
4742
4843
Raises:
49-
SchemaValidationError: required keys (index_type, metric_type)
50-
missing or malformed
44+
SchemaValidationError: required keys are missing or malformed
5145
"""
5246
kv: dict[str, str] = {p.key: p.value for p in extra_params}
5347

5448
index_type = kv.get("index_type")
55-
metric_type = kv.get("metric_type")
56-
5749
if not index_type:
5850
raise SchemaValidationError(
5951
"create_index missing required 'index_type' parameter"
6052
)
61-
if not metric_type:
62-
raise SchemaValidationError(
63-
"create_index missing required 'metric_type' parameter"
64-
)
6553

6654
# `params` is a JSON-encoded string in the wire format. pymilvus
6755
# uses utils.dumps which is plain json.dumps for dicts.
@@ -87,12 +75,14 @@ def kv_pairs_to_index_params_dict(
8775
else:
8876
search_params = {}
8977

90-
return {
78+
out = {
9179
"index_type": index_type,
92-
"metric_type": metric_type,
9380
"params": build_params,
9481
"search_params": search_params,
9582
}
83+
if "metric_type" in kv:
84+
out["metric_type"] = kv["metric_type"]
85+
return out
9686

9787

9888
def index_spec_to_kv_pairs(spec: IndexSpec) -> List:

0 commit comments

Comments
 (0)