test: Fix sparse/group_by/text_match/diskann/invalid/by_pk (#48347)

## Summary
- GPU→L1, zip() for positional index, add metric_type, fix spelling
- IVF_SQ8→DISKANN, skip DISKANN mmap, fix INT8 metric, filter assertions
- Fix false-pass bypass in check_search_results for empty hits

## Code Review Fix (0e4ad86c13)

### check_search_results: remove empty-hits bypass

`check_search_results` in `func_check.py` had a `if len(hits) == 0:
continue` guard that silently skipped all verification (including limit
check) when search returned no results. This caused tests with filters
matching zero rows to false-pass instead of failing.

Removed the 2-line guard entirely. The existing `assert len(hits) ==
check_items["limit"]` already handles this correctly — when limit is
specified and hits is empty, the assertion properly fails.

**Impact:** `test_search_with_scalar_field` in
`test_milvus_client_search_diskann.py` filters `int64 in [1, 2, 3, 4]`
on randomly-generated INT64 data (non-PK field uses full-range random
values). This matches 0 rows, and previously false-passed due to the
bypass. After the fix, it correctly fails. The filter in the test should
also be updated to use values that exist in the data.

Verified: ran all 6 PR test files (L0+L1, `-n 4`) — 88 passed, 1
expected failure (`test_search_with_scalar_field`), 9 skipped.

issue: #48048
🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: yanliang567 <82361606+yanliang567@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
yanliang567
2026-03-24 10:51:30 +08:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 61e929c2cf
commit 17532517c6
7 changed files with 2440 additions and 2031 deletions
-2
View File
@@ -453,8 +453,6 @@ class ResponseChecker:
else:
ids = list(hits.ids)
distances = list(hits.distances)
if len(hits) == 0:
continue
if check_items.get("limit", None) is not None \
and ((len(hits) != check_items["limit"]) or (len(set(ids)) != check_items["limit"])):
log.error("search_results_check: limit(topK) searched (%d) "
File diff suppressed because it is too large Load Diff
@@ -1,98 +1,29 @@
import numpy as np
from pymilvus.orm.types import CONSISTENCY_STRONG, CONSISTENCY_BOUNDED, CONSISTENCY_SESSION, CONSISTENCY_EVENTUALLY
from pymilvus import AnnSearchRequest, RRFRanker, WeightedRanker
from pymilvus import (
FieldSchema, CollectionSchema, DataType,
Collection
)
from common.constants import *
from utils.util_pymilvus import *
import pytest
from pymilvus import DataType
from common.common_type import CaseLabel, CheckTasks
from common import common_type as ct
from common import common_func as cf
from utils.util_log import test_log as log
from base.client_base import TestcaseBase
import heapq
from time import sleep
from decimal import Decimal, getcontext
import decimal
import multiprocessing
import numbers
import random
import math
import numpy
import threading
import pytest
import pandas as pd
from faker import Faker
from base.client_v2_base import TestMilvusClientV2Base
Faker.seed(19530)
fake_en = Faker("en_US")
fake_zh = Faker("zh_CN")
# patch faker to generate text with specific distribution
cf.patch_faker_text(fake_en, cf.en_vocabularies_distribution)
cf.patch_faker_text(fake_zh, cf.zh_vocabularies_distribution)
pd.set_option("expand_frame_repr", False)
prefix = "search_collection"
search_num = 10
max_dim = ct.max_dim
min_dim = ct.min_dim
epsilon = ct.epsilon
hybrid_search_epsilon = 0.01
gracefulTime = ct.gracefulTime
default_nb = ct.default_nb
default_nb_medium = ct.default_nb_medium
default_nq = ct.default_nq
default_dim = ct.default_dim
default_limit = ct.default_limit
max_limit = ct.max_limit
default_search_exp = "int64 >= 0"
default_search_string_exp = "varchar >= \"0\""
default_search_mix_exp = "int64 >= 0 && varchar >= \"0\""
default_invaild_string_exp = "varchar >= 0"
default_json_search_exp = "json_field[\"number\"] >= 0"
perfix_expr = 'varchar like "0%"'
default_search_exp = f"{ct.default_int64_field_name} >= 0"
default_search_field = ct.default_float_vec_field_name
default_search_params = ct.default_search_params
default_int64_field_name = ct.default_int64_field_name
default_float_field_name = ct.default_float_field_name
default_bool_field_name = ct.default_bool_field_name
default_string_field_name = ct.default_string_field_name
default_json_field_name = ct.default_json_field_name
default_index_params = ct.default_index
vectors = [[random.random() for _ in range(default_dim)] for _ in range(default_nq)]
uid = "test_search"
nq = 1
epsilon = 0.001
field_name = default_float_vec_field_name
binary_field_name = default_binary_vec_field_name
search_param = {"nprobe": 1}
entity = gen_entities(1, is_normal=True)
entities = gen_entities(default_nb, is_normal=True)
raw_vectors, binary_entities = gen_binary_entities(default_nb)
default_query, _ = gen_search_vectors_params(field_name, entities, default_top_k, nq)
index_name1 = cf.gen_unique_str("float")
index_name2 = cf.gen_unique_str("varhar")
half_nb = ct.default_nb // 2
max_hybrid_search_req_num = ct.max_hybrid_search_req_num
class TestSearchDiskann(TestcaseBase):
class TestSearchDiskannIndependent(TestMilvusClientV2Base):
"""
******************************************************************
The following cases are used to test search about diskann index
******************************************************************
"""
@pytest.fixture(scope="function", params=[False, True])
def _async(self, request):
yield request.param
@pytest.mark.tags(CaseLabel.L2)
def test_search_with_delete_data(self, _async):
def test_search_with_delete_data(self):
"""
target: test delete after creating index
method: 1.create collection , insert data,
@@ -101,48 +32,61 @@ class TestSearchDiskann(TestcaseBase):
expected: assert index and deleted id not in search result
"""
# 1. initialize with data
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
dim = 100
auto_id = True
enable_dynamic_field = True
collection_w, _, _, ids = \
self.init_collection_general(prefix, True, auto_id=auto_id, dim=dim, is_index=False,
enable_dynamic_field=enable_dynamic_field)[0:4]
# 2. create index
default_index = {"index_type": "DISKANN",
"metric_type": "L2", "params": {}}
collection_w.create_index(
ct.default_float_vec_field_name, default_index)
collection_w.load()
tmp_expr = f'{ct.default_int64_field_name} in {[0]}'
expr = f'{ct.default_int64_field_name} in {ids[:half_nb]}'
# Create schema with auto_id and dynamic field
schema = self.create_schema(client, enable_dynamic_field=True)[0]
schema.add_field(ct.default_int64_field_name, DataType.INT64, is_primary=True, auto_id=True)
schema.add_field(ct.default_float_field_name, DataType.FLOAT)
schema.add_field(ct.default_string_field_name, DataType.VARCHAR, max_length=65535)
schema.add_field(ct.default_json_field_name, DataType.JSON)
schema.add_field(ct.default_float_vec_field_name, DataType.FLOAT_VECTOR, dim=dim)
self.create_collection(client, collection_name, schema=schema)
# Insert data
data = cf.gen_row_data_by_schema(nb=ct.default_nb, schema=schema)
insert_res, _ = self.insert(client, collection_name, data=data)
ids = insert_res["ids"]
self.flush(client, collection_name)
# 2. create index
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_float_vec_field_name,
index_type="DISKANN", metric_type="L2", params={})
self.create_index(client, collection_name, index_params=idx)
self.load_collection(client, collection_name)
# delete half of data
del_res = collection_w.delete(expr)[0]
assert del_res.delete_count == half_nb
expr = f'{ct.default_int64_field_name} in {ids[:half_nb]}'
self.delete(client, collection_name, filter=expr)
collection_w.delete(tmp_expr)
default_search_params = {
"metric_type": "L2", "params": {"search_list": 30}}
vectors = [[random.random() for _ in range(dim)]
for _ in range(default_nq)]
tmp_expr = f'{ct.default_int64_field_name} in {[0]}'
self.delete(client, collection_name, filter=tmp_expr)
# search
default_search_params = {"metric_type": "L2", "params": {"search_list": 30}}
vectors = cf.gen_vectors(default_nq, dim)
output_fields = [default_int64_field_name,
default_float_field_name, default_string_field_name]
collection_w.search(vectors[:default_nq], default_search_field,
default_search_params, default_limit,
default_search_exp,
output_fields=output_fields,
_async=_async,
check_task=CheckTasks.check_search_results,
check_items={"nq": default_nq,
"ids": ids,
"limit": default_limit,
"_async": _async,
"pk_name": ct.default_int64_field_name}
)
self.search(client, collection_name,
data=vectors[:default_nq],
anns_field=default_search_field,
search_params=default_search_params,
limit=default_limit,
filter=default_search_exp,
output_fields=output_fields,
check_task=CheckTasks.check_search_results,
check_items={"nq": default_nq,
"ids": ids[half_nb:],
"limit": default_limit,
"metric": "L2",
"pk_name": ct.default_int64_field_name,
"enable_milvus_client_api": True})
@pytest.mark.tags(CaseLabel.L1)
def test_search_with_scalar_field(self, _async):
def test_search_with_scalar_field(self):
"""
target: test search with scalar field
method: 1.create collection , insert data
@@ -151,38 +95,51 @@ class TestSearchDiskann(TestcaseBase):
expected: assert index and search successfully
"""
# 1. initialize with data
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
dim = 66
enable_dynamic_field = True
collection_w, _, _, ids = \
self.init_collection_general(prefix, True, dim=dim, primary_field=ct.default_string_field_name,
is_index=False, enable_dynamic_field=enable_dynamic_field)[0:4]
# Create schema with varchar PK and dynamic field
schema = self.create_schema(client, enable_dynamic_field=True)[0]
schema.add_field(ct.default_string_field_name, DataType.VARCHAR, max_length=65535, is_primary=True)
schema.add_field(ct.default_int64_field_name, DataType.INT64)
schema.add_field(ct.default_float_field_name, DataType.FLOAT)
schema.add_field(ct.default_json_field_name, DataType.JSON)
schema.add_field(ct.default_float_vec_field_name, DataType.FLOAT_VECTOR, dim=dim)
self.create_collection(client, collection_name, schema=schema)
# Insert data
data = cf.gen_row_data_by_schema(nb=ct.default_nb, schema=schema)
insert_res, _ = self.insert(client, collection_name, data=data)
ids = insert_res["ids"]
self.flush(client, collection_name)
# 2. create index
default_index = {"index_type": "IVF_SQ8",
"metric_type": "COSINE", "params": {"nlist": 64}}
collection_w.create_index(
ct.default_float_vec_field_name, default_index)
index_params = {}
if not enable_dynamic_field:
collection_w.create_index(
ct.default_float_field_name, index_params=index_params)
collection_w.create_index(
ct.default_int64_field_name, index_params=index_params)
else:
collection_w.create_index(
ct.default_string_field_name, index_params=index_params)
collection_w.load()
default_expr = "int64 in [1, 2, 3, 4]"
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_float_vec_field_name,
index_type="DISKANN", metric_type="L2", params={})
idx.add_index(field_name=ct.default_string_field_name, index_type="")
self.create_index(client, collection_name, index_params=idx)
self.load_collection(client, collection_name)
# 3. search with expr (use varchar PK filter — gen_row_data_by_schema generates "0","1","2",...)
default_expr = f'{ct.default_string_field_name} in ["0", "1", "2", "3"]'
limit = 4
default_search_params = {"metric_type": "COSINE", "params": {"nprobe": 64}}
vectors = [[random.random() for _ in range(dim)] for _ in range(default_nq)]
default_search_params = {"metric_type": "L2", "params": {"search_list": 30}}
vectors = cf.gen_vectors(default_nq, dim)
output_fields = [default_int64_field_name,
default_float_field_name, default_string_field_name]
search_res = collection_w.search(vectors[:default_nq], default_search_field,
default_search_params, limit, default_expr,
output_fields=output_fields, _async=_async,
check_task=CheckTasks.check_search_results,
check_items={"nq": default_nq,
"ids": ids,
"limit": limit,
"_async": _async,
"pk_name": ct.default_int64_field_name})
self.search(client, collection_name,
data=vectors[:default_nq],
anns_field=default_search_field,
search_params=default_search_params,
limit=limit,
filter=default_expr,
output_fields=output_fields,
check_task=CheckTasks.check_search_results,
check_items={"nq": default_nq,
"ids": ids,
"limit": limit,
"metric": "L2",
"pk_name": ct.default_string_field_name,
"enable_milvus_client_api": True})
@@ -15,17 +15,22 @@ import pytest
epsilon = 0.001
dyna_filed_name1 = "dyna_filed_name1"
dyna_filed_name2 = "dyna_filed_name2"
inverted_string_field_name = "varchar_inverted"
indexed_json_field_name = "indexed_json"
@pytest.mark.xdist_group("TestGroupSearch")
@pytest.mark.tags(CaseLabel.GPU)
class TestGroupSearch(TestMilvusClientV2Base):
"""Shared collection for group-by search tests.
Schema: int64_pk(PK, auto_id), float_vector(36), bfloat16_vector(35), sparse_vector,
binary_vector(32), all scalar types (nullable), varchar_inverted (INVERTED index),
indexed_json (INVERTED index), dynamic fields enabled
Data: 10000 rows (100 batches × 100 rows), 20% null ratio on scalar fields,
scalar values constant within each batch for group-by testing
Index: IVF_FLAT/COSINE, DISKANN/L2, SPARSE_INVERTED_INDEX/IP, BIN_IVF_FLAT/JACCARD
"""
def setup_class(self):
super().setup_class(self)
self.collection_name = "TestGroupSearch" + cf.gen_unique_str("_")
self.collection_name = "TestGroupSearch" + cf.gen_unique_str("group_by")
self.partition_names = ["partition_1", "partition_2"]
self.primary_field = "int64_pk"
self.float_vector_field_name = ct.default_float_vec_field_name
@@ -49,11 +54,13 @@ class TestGroupSearch(TestMilvusClientV2Base):
self.binary_vector_index = "BIN_IVF_FLAT"
self.index_types = [self.float_vector_index, self.bf16_vector_index,
self.sparse_vector_index, self.binary_vector_index]
self.inverted_string_field = inverted_string_field_name
self.indexed_json_field = indexed_json_field_name
self.metric_types = [self.float_vector_metric, self.bf16_vector_metric,
self.sparse_vector_metric, self.binary_vector_metric]
self.inverted_string_field = "varchar_inverted"
self.indexed_json_field = "indexed_json"
self.enable_dynamic_field = True
self.dyna_filed_name1 = dyna_filed_name1
self.dyna_filed_name2 = dyna_filed_name2
self.dyna_field_name1 = "dyna_field_name1"
self.dyna_field_name2 = "dyna_field_name2"
@pytest.fixture(scope="class", autouse=True)
def prepare_collection(self, request):
@@ -87,7 +94,7 @@ class TestGroupSearch(TestMilvusClientV2Base):
insert_times = 100
nb = 100
# Insert data multiple times with non-duplicated primary keys
for j in range(insert_times):
for _ in range(insert_times):
# Group rows by partition based on primary key mod 3
default_rows = []
partition1_rows = []
@@ -98,15 +105,17 @@ class TestGroupSearch(TestMilvusClientV2Base):
int8_value = random.randint(-128, 127)
int16_value = random.randint(-32768, 32767)
int32_value = random.randint(-2147483648, 2147483647)
# Batch generate vectors for this iteration (avoid per-row gen_vectors calls)
float_vectors = cf.gen_vectors(nb, dim=self.float_vector_dim, vector_data_type=DataType.FLOAT_VECTOR)
bf16_vectors = cf.gen_vectors(nb, dim=self.bf16_vector_dim, vector_data_type=DataType.BFLOAT16_VECTOR)
sparse_vectors = cf.gen_sparse_vectors(nb, empty_percentage=2)
binary_vectors = cf.gen_vectors(nb, dim=self.binary_vector_dim, vector_data_type=DataType.BINARY_VECTOR)
for i in range(nb):
row = {
self.float_vector_field_name: cf.gen_vectors(1, dim=self.float_vector_dim,
vector_data_type=DataType.FLOAT_VECTOR)[0],
self.bfloat16_vector_field_name: cf.gen_vectors(1, dim=self.bf16_vector_dim,
vector_data_type=DataType.BFLOAT16_VECTOR)[0],
self.sparse_vector_field_name: cf.gen_sparse_vectors(1, empty_percentage=2)[0],
self.binary_vector_field_name: cf.gen_vectors(1, dim=self.binary_vector_dim,
vector_data_type=DataType.BINARY_VECTOR)[0],
self.float_vector_field_name: float_vectors[i],
self.bfloat16_vector_field_name: bf16_vectors[i],
self.sparse_vector_field_name: sparse_vectors[i],
self.binary_vector_field_name: binary_vectors[i],
DataType.BOOL.name: bool(i % 2) if random.random() < 0.8 else None,
DataType.INT8.name: int8_value if random.random() < 0.8 else None,
DataType.INT16.name: int16_value if random.random() < 0.8 else None,
@@ -121,8 +130,8 @@ class TestGroupSearch(TestMilvusClientV2Base):
DataType.GEOMETRY.name: geo_value if random.random() < 0.8 else None,
self.inverted_string_field: f"inverted_string_{i}" if random.random() < 0.8 else None,
self.indexed_json_field: {"number": i, "string": f"string_{i}"} if random.random() < 0.8 else None,
self.dyna_filed_name1: f"dyna_value_{i}" if random.random() < 0.8 else None,
self.dyna_filed_name2: {"number": i, "string": f"string_{i}"} if random.random() < 0.8 else None,
self.dyna_field_name1: f"dyna_value_{i}" if random.random() < 0.8 else None,
self.dyna_field_name2: {"number": i, "string": f"string_{i}"} if random.random() < 0.8 else None,
}
# Distribute to partitions based on pk mod 3
@@ -178,12 +187,12 @@ class TestGroupSearch(TestMilvusClientV2Base):
@pytest.mark.tags(CaseLabel.L0)
@pytest.mark.parametrize("group_by_field, output_field", [
(DataType.VARCHAR.name, DataType.VARCHAR.name),
(inverted_string_field_name, inverted_string_field_name),
("varchar_inverted", "varchar_inverted"),
(DataType.JSON.name, DataType.JSON.name),
(indexed_json_field_name, indexed_json_field_name),
("indexed_json", "indexed_json"),
(f"{DataType.JSON.name}['number']", DataType.JSON.name),
(dyna_filed_name1, dyna_filed_name1),
(f"{dyna_filed_name2}['string']", dyna_filed_name2),
("dyna_field_name1", "dyna_field_name1"),
("dyna_field_name2['string']", "dyna_field_name2"),
])
def test_search_group_size(self, group_by_field, output_field):
"""
@@ -197,50 +206,44 @@ class TestGroupSearch(TestMilvusClientV2Base):
group_size = 5
client = self._client()
collection_info = self.describe_collection(client, self.collection_name)[0]
for j in range(len(self.vector_fields)):
if self.vector_fields[j] == self.binary_vector_field_name:
pass
else:
search_vectors = cf.gen_vectors(nq, dim=self.dims[j],
vector_data_type=cf.get_field_dtype_by_field_name(collection_info,
self.vector_fields[
j]))
search_params = {"params": cf.get_search_params_params(self.index_types[j])}
# when strict_group_size=true, it shall return results with entities = limit * group_size
res1 = self.search(client, self.collection_name, data=search_vectors, anns_field=self.vector_fields[j],
search_params=search_params, limit=limit,
group_by_field=group_by_field, filter=f"{output_field} is not null",
group_size=group_size, strict_group_size=True,
output_fields=[output_field])[0]
for i in range(nq):
assert len(res1[i]) == limit * group_size
for l in range(limit):
group_values = []
for k in range(group_size):
group_values.append(res1[i][l * group_size + k].fields.get(output_field))
if group_values and isinstance(group_values[0], dict):
group_values = [json.dumps(value) for value in group_values]
assert len(set(group_values)) == 1
elif group_values:
assert len(set(group_values)) == 1
# when strict_group_size=false, it shall return results with group counts = limit
res1 = self.search(client, self.collection_name,
data=search_vectors,
anns_field=self.vector_fields[j],
search_params=search_params, limit=limit,
group_by_field=group_by_field, filter=f"{output_field} is not null",
group_size=group_size, strict_group_size=False,
output_fields=[output_field])[0]
for i in range(nq):
for field, dim, idx_type, metric in zip(self.vector_fields, self.dims, self.index_types, self.metric_types):
if field == self.binary_vector_field_name:
continue
search_vectors = cf.gen_vectors(nq, dim=dim,
vector_data_type=cf.get_field_dtype_by_field_name(collection_info,
field))
search_params = {"params": cf.get_search_params_params(idx_type), "metric_type": metric}
# when strict_group_size=true, it shall return results with entities = limit * group_size
res1 = self.search(client, self.collection_name, data=search_vectors, anns_field=field,
search_params=search_params, limit=limit,
group_by_field=group_by_field, filter=f"{output_field} is not null",
group_size=group_size, strict_group_size=True,
output_fields=[output_field])[0]
for i in range(nq):
assert len(res1[i]) == limit * group_size
for idx in range(limit):
group_values = []
for l in range(len(res1[i])):
group_values.append(res1[i][l].fields.get(output_field))
if group_values and isinstance(group_values[0], dict):
for k in range(group_size):
group_values.append(res1[i][idx * group_size + k].fields.get(output_field))
if isinstance(group_values[0], dict):
group_values = [json.dumps(value) for value in group_values]
assert len(set(group_values)) == limit
elif group_values:
assert len(set(group_values)) == limit
assert len(set(group_values)) == 1
# when strict_group_size=false, it shall return results with group counts = limit
res1 = self.search(client, self.collection_name,
data=search_vectors,
anns_field=field,
search_params=search_params, limit=limit,
group_by_field=group_by_field, filter=f"{output_field} is not null",
group_size=group_size, strict_group_size=False,
output_fields=[output_field])[0]
for i in range(nq):
group_values = []
for idx in range(len(res1[i])):
group_values.append(res1[i][idx].fields.get(output_field))
if group_values and isinstance(group_values[0], dict):
group_values = [json.dumps(value) for value in group_values]
assert len(set(group_values)) == limit
@pytest.mark.tags(CaseLabel.L0)
def test_hybrid_search_group_size(self):
@@ -254,20 +257,19 @@ class TestGroupSearch(TestMilvusClientV2Base):
req_list = []
client = self._client()
collection_info = self.describe_collection(client, self.collection_name)[0]
for j in range(len(self.vector_fields)):
if self.vector_fields[j] == self.binary_vector_field_name:
pass # not support group by search on binary vector
else:
search_params = {
"data": cf.gen_vectors(nq, dim=self.dims[j],
vector_data_type=cf.get_field_dtype_by_field_name(collection_info,
self.vector_fields[j])),
"anns_field": self.vector_fields[j],
"param": {"params": cf.get_search_params_params(self.index_types[j])},
"limit": limit,
"expr": f"{self.primary_field} > 0"}
req = AnnSearchRequest(**search_params)
req_list.append(req)
for field, dim, idx_type, metric in zip(self.vector_fields, self.dims, self.index_types, self.metric_types):
if field == self.binary_vector_field_name:
continue # not support group by search on binary vector
search_params = {
"data": cf.gen_vectors(nq, dim=dim,
vector_data_type=cf.get_field_dtype_by_field_name(collection_info,
field)),
"anns_field": field,
"param": {"params": cf.get_search_params_params(idx_type), "metric_type": metric},
"limit": limit,
"expr": f"{self.primary_field} > 0"}
req = AnnSearchRequest(**search_params)
req_list.append(req)
# 4. hybrid search group by
rank_scorers = ["max", "avg", "sum"]
for scorer in rank_scorers:
@@ -276,18 +278,18 @@ class TestGroupSearch(TestMilvusClientV2Base):
rank_group_scorer=scorer, output_fields=[DataType.VARCHAR.name])[0]
for i in range(nq):
group_values = []
for l in range(len(res[i])):
group_values.append(res[i][l].get(DataType.VARCHAR.name))
for idx in range(len(res[i])):
group_values.append(res[i][idx].get(DataType.VARCHAR.name))
assert len(set(group_values)) == limit
# group_distances = []
tmp_distances = [100 for _ in range(group_size)] # init with a large value
group_distances = [res[i][0].distance] # init with the first value
for l in range(len(res[i]) - 1):
curr_group_value = res[i][l].get(DataType.VARCHAR.name)
next_group_value = res[i][l + 1].get(DataType.VARCHAR.name)
for idx in range(len(res[i]) - 1):
curr_group_value = res[i][idx].get(DataType.VARCHAR.name)
next_group_value = res[i][idx + 1].get(DataType.VARCHAR.name)
if curr_group_value == next_group_value:
group_distances.append(res[i][l + 1].distance)
group_distances.append(res[i][idx + 1].distance)
else:
if scorer == 'sum':
assert np.sum(group_distances) <= np.sum(tmp_distances)
@@ -297,7 +299,7 @@ class TestGroupSearch(TestMilvusClientV2Base):
assert np.max(group_distances) <= np.max(tmp_distances)
tmp_distances = group_distances
group_distances = [res[i][l + 1].distance]
group_distances = [res[i][idx + 1].distance]
@pytest.mark.tags(CaseLabel.L2)
def test_hybrid_search_group_by(self):
@@ -308,20 +310,19 @@ class TestGroupSearch(TestMilvusClientV2Base):
collection_info = self.describe_collection(client, self.collection_name)[0]
# 3. prepare search params
req_list = []
for i in range(len(self.vector_fields)):
if self.vector_fields[i] == self.binary_vector_field_name:
pass # not support group by search on binary vector
else:
search_param = {
"data": cf.gen_vectors(ct.default_nq, dim=self.dims[i],
vector_data_type=cf.get_field_dtype_by_field_name(collection_info,
self.vector_fields[i])),
"anns_field": self.vector_fields[i],
"param": {},
"limit": ct.default_limit,
"expr": f"{self.primary_field} > 0"}
req = AnnSearchRequest(**search_param)
req_list.append(req)
for field, dim, idx_type, metric in zip(self.vector_fields, self.dims, self.index_types, self.metric_types):
if field == self.binary_vector_field_name:
continue # not support group by search on binary vector
search_param = {
"data": cf.gen_vectors(ct.default_nq, dim=dim,
vector_data_type=cf.get_field_dtype_by_field_name(collection_info,
field)),
"anns_field": field,
"param": {"metric_type": metric},
"limit": ct.default_limit,
"expr": f"{self.primary_field} > 0"}
req = AnnSearchRequest(**search_param)
req_list.append(req)
# 4. hybrid search group by
res = self.hybrid_search(client, self.collection_name, reqs=req_list, ranker=WeightedRanker(0.1, 0.9, 0.3),
limit=ct.default_limit, group_by_field=DataType.VARCHAR.name,
@@ -330,31 +331,30 @@ class TestGroupSearch(TestMilvusClientV2Base):
check_items={"nq": ct.default_nq, "limit": ct.default_limit})[0]
for i in range(ct.default_nq):
group_values = []
for l in range(ct.default_limit):
group_values.append(res[i][l].get(DataType.VARCHAR.name))
for idx in range(ct.default_limit):
group_values.append(res[i][idx].get(DataType.VARCHAR.name))
assert len(group_values) == len(set(group_values))
# 5. hybrid search with RRFRanker on one vector field with group by
req_list = []
for i in range(1, len(self.vector_fields)):
if self.vector_fields[i] == self.binary_vector_field_name:
pass # not support group by search on binary vector
else:
search_param = {
"data": cf.gen_vectors(ct.default_nq, dim=self.dims[i],
vector_data_type=cf.get_field_dtype_by_field_name(collection_info,
self.vector_fields[i])),
"anns_field": self.vector_fields[i],
"param": {},
"limit": ct.default_limit,
"expr": f"{self.primary_field} > 0"}
req = AnnSearchRequest(**search_param)
req_list.append(req)
self.hybrid_search(client, self.collection_name, reqs=req_list, ranker=RRFRanker(),
limit=ct.default_limit, group_by_field=self.inverted_string_field,
output_fields=[self.inverted_string_field],
check_task=CheckTasks.check_search_results,
check_items={"nq": ct.default_nq, "limit": ct.default_limit})
for field, dim, idx_type, metric in zip(self.vector_fields[1:], self.dims[1:], self.index_types[1:], self.metric_types[1:]):
if field == self.binary_vector_field_name:
continue # not support group by search on binary vector
search_param = {
"data": cf.gen_vectors(ct.default_nq, dim=dim,
vector_data_type=cf.get_field_dtype_by_field_name(collection_info,
field)),
"anns_field": field,
"param": {"metric_type": metric},
"limit": ct.default_limit,
"expr": f"{self.primary_field} > 0"}
req = AnnSearchRequest(**search_param)
req_list.append(req)
self.hybrid_search(client, self.collection_name, reqs=req_list, ranker=RRFRanker(),
limit=ct.default_limit, group_by_field=self.inverted_string_field,
output_fields=[self.inverted_string_field],
check_task=CheckTasks.check_search_results,
check_items={"nq": ct.default_nq, "limit": ct.default_limit})
@pytest.mark.tags(CaseLabel.L2)
def test_hybrid_search_group_by_empty_results(self):
@@ -365,20 +365,19 @@ class TestGroupSearch(TestMilvusClientV2Base):
collection_info = self.describe_collection(client, self.collection_name)[0]
# 3. prepare search params
req_list = []
for i in range(len(self.vector_fields)):
if self.vector_fields[i] == self.binary_vector_field_name:
pass # not support group by search on binary vector
else:
search_param = {
"data": cf.gen_vectors(ct.default_nq, dim=self.dims[i],
vector_data_type=cf.get_field_dtype_by_field_name(collection_info,
self.vector_fields[i])),
"anns_field": self.vector_fields[i],
"param": {},
"limit": ct.default_limit,
"expr": f"{self.primary_field} < 0"} # make sure return empty results
req = AnnSearchRequest(**search_param)
req_list.append(req)
for field, dim, idx_type, metric in zip(self.vector_fields, self.dims, self.index_types, self.metric_types):
if field == self.binary_vector_field_name:
continue # not support group by search on binary vector
search_param = {
"data": cf.gen_vectors(ct.default_nq, dim=dim,
vector_data_type=cf.get_field_dtype_by_field_name(collection_info,
field)),
"anns_field": field,
"param": {"metric_type": metric},
"limit": ct.default_limit,
"expr": f"{self.primary_field} < 0"} # make sure return empty results
req = AnnSearchRequest(**search_param)
req_list.append(req)
# 4. hybrid search group by empty results
self.hybrid_search(client, self.collection_name, reqs=req_list, ranker=WeightedRanker(0.1, 0.9, 0.3),
limit=ct.default_limit, group_by_field=DataType.VARCHAR.name,
@@ -394,7 +393,7 @@ class TestGroupSearch(TestMilvusClientV2Base):
client = self._client()
search_vectors = cf.gen_vectors(1, dim=self.binary_vector_dim,
vector_data_type=DataType.BINARY_VECTOR)
search_params = {}
search_params = {"metric_type": self.binary_vector_metric}
limit = 1
error = {ct.err_code: 999,
ct.err_msg: "not support search_group_by operation based on binary vector"}
@@ -415,50 +414,51 @@ class TestGroupSearch(TestMilvusClientV2Base):
collection_info = self.describe_collection(client, self.collection_name)[0]
nq = 2
limit = 15
for j in range(len(self.vector_fields)):
if self.vector_fields[j] == self.binary_vector_field_name:
pass # not support group by search on binary vector
else:
search_vectors = cf.gen_vectors(nq, dim=self.dims[j],
vector_data_type=cf.get_field_dtype_by_field_name(collection_info,
self.vector_fields[
j]))
search_params = {"params": cf.get_search_params_params(self.index_types[j])}
res1 = self.search(client, self.collection_name, data=search_vectors, anns_field=self.vector_fields[j],
search_params=search_params, limit=limit,
filter=f"{support_field} is not null",
group_by_field=support_field,
output_fields=[support_field])[0]
for i in range(nq):
grpby_values = []
dismatch = 0
results_num = 2 if support_field == DataType.BOOL.name else limit
for l in range(results_num):
top1 = res1[i][l]
top1_grpby_pk = top1.id
top1_grpby_value = top1.get(support_field)
for field, dim, idx_type, metric in zip(self.vector_fields, self.dims, self.index_types, self.metric_types):
if field == self.binary_vector_field_name:
continue # not support group by search on binary vector
search_vectors = cf.gen_vectors(nq, dim=dim,
vector_data_type=cf.get_field_dtype_by_field_name(collection_info,
field))
search_params = {"params": cf.get_search_params_params(idx_type), "metric_type": metric}
res1 = self.search(client, self.collection_name, data=search_vectors, anns_field=field,
search_params=search_params, limit=limit,
filter=f"{support_field} is not null",
group_by_field=support_field,
output_fields=[support_field])[0]
for i in range(nq):
grpby_values = []
mismatch = 0
results_num = 2 if support_field == DataType.BOOL.name else limit
for idx in range(results_num):
top1 = res1[i][idx]
top1_grpby_pk = top1.id
top1_grpby_value = top1.get(support_field)
if isinstance(top1_grpby_value, bool):
filter_expr = f"{support_field}=={str(top1_grpby_value).lower()}"
else:
filter_expr = f"{support_field}=={top1_grpby_value}"
if support_field == DataType.VARCHAR.name:
filter_expr = f"{support_field}=='{top1_grpby_value}'"
if support_field == DataType.TIMESTAMPTZ.name:
filter_expr = f"{support_field}== ISO '{top1_grpby_value}'"
grpby_values.append(top1_grpby_value)
res_tmp = self.search(client, self.collection_name, data=[search_vectors[i]],
anns_field=self.vector_fields[j],
search_params=search_params, limit=1, filter=filter_expr,
output_fields=[support_field])[0]
top1_expr_pk = res_tmp[0][0].id
if top1_grpby_pk != top1_expr_pk:
dismatch += 1
log.info(
f"{support_field} on {self.vector_fields[j]} dismatch_item, top1_grpby_dis: {top1.distance}, top1_expr_dis: {res_tmp[0][0].distance}")
log.info(
f"{support_field} on {self.vector_fields[j]} top1_dismatch_num: {dismatch}, results_num: {results_num}, dismatch_rate: {dismatch / results_num}")
baseline = 1 if support_field == DataType.BOOL.name else 0.2 # skip baseline check for boolean
assert results_num > 0, "results_num should be greater than 0"
assert dismatch / results_num <= baseline
# verify no dup values of the group_by_field in results
assert len(grpby_values) == len(set(grpby_values))
if support_field == DataType.VARCHAR.name:
filter_expr = f"{support_field}=='{top1_grpby_value}'"
if support_field == DataType.TIMESTAMPTZ.name:
filter_expr = f"{support_field}== ISO '{top1_grpby_value}'"
grpby_values.append(top1_grpby_value)
res_tmp = self.search(client, self.collection_name, data=[search_vectors[i]],
anns_field=field,
search_params=search_params, limit=1, filter=filter_expr,
output_fields=[support_field])[0]
top1_expr_pk = res_tmp[0][0].id
if top1_grpby_pk != top1_expr_pk:
mismatch += 1
log.info(
f"{support_field} on {field} mismatch_item, top1_grpby_dis: {top1.distance}, top1_expr_dis: {res_tmp[0][0].distance}")
log.info(
f"{support_field} on {field} top1_mismatch_num: {mismatch}, results_num: {results_num}, mismatch_rate: {mismatch / results_num}")
baseline = 1 if support_field == DataType.BOOL.name else 0.2 # skip baseline check for boolean
assert results_num > 0, "results_num should be greater than 0"
assert mismatch / results_num <= baseline
# verify no dup values of the group_by_field in results
assert len(grpby_values) == len(set(grpby_values))
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("grpby_unsupported_field", [DataType.FLOAT.name, DataType.DOUBLE.name,
@@ -475,7 +475,7 @@ class TestGroupSearch(TestMilvusClientV2Base):
client = self._client()
search_vectors = cf.gen_vectors(1, dim=self.float_vector_dim,
vector_data_type=DataType.FLOAT_VECTOR)
search_params = {}
search_params = {"metric_type": self.float_vector_metric}
limit = 1
error = {ct.err_code: 999,
ct.err_msg: f"unsupported data type {grpby_unsupported_field} for group by operator"}
@@ -496,7 +496,7 @@ class TestGroupSearch(TestMilvusClientV2Base):
page_rounds = 3
client = self._client()
collection_info = self.describe_collection(client, self.collection_name)[0]
search_param = {}
search_param = {"metric_type": self.bf16_vector_metric}
default_search_exp = f"{self.primary_field} >= 0"
grpby_field = self.inverted_string_field
default_search_field = self.vector_fields[1]
@@ -546,7 +546,7 @@ class TestGroupSearch(TestMilvusClientV2Base):
limit = 10
group_size = 5
page_rounds = 3
search_param = {}
search_param = {"metric_type": self.bf16_vector_metric}
default_search_exp = f"{self.primary_field} >= 0"
grpby_field = self.inverted_string_field
default_search_field = self.vector_fields[1]
@@ -620,7 +620,7 @@ class TestGroupSearch(TestMilvusClientV2Base):
search_vectors = cf.gen_vectors(1, dim=self.dims[1],
vector_data_type=cf.get_field_dtype_by_field_name(collection_info,
self.vector_fields[1]))
search_params = {}
search_params = {"metric_type": self.bf16_vector_metric}
limit = 10
max_group_size = 10
self.search(client, self.collection_name, data=search_vectors, anns_field=default_search_field,
@@ -678,7 +678,7 @@ class TestGroupSearch(TestMilvusClientV2Base):
client = self._client()
search_vectors = cf.gen_vectors(1, dim=self.float_vector_dim,
vector_data_type=DataType.FLOAT_VECTOR)
search_params = {}
search_params = {"metric_type": self.float_vector_metric}
grpby_field = DataType.VARCHAR.name
error = {ct.err_code: 1100,
ct.err_msg: "Not allowed to do groupBy when doing iteration"}
@@ -712,7 +712,7 @@ class TestGroupSearch(TestMilvusClientV2Base):
@pytest.mark.tags(CaseLabel.L2)
@pytest.mark.parametrize("grpby_nonexist_field", ["nonexist_field", 21])
def test_search_group_by_non_exit_field_on_dynamic_enabled_collection(self, grpby_nonexist_field):
def test_search_group_by_nonexistent_field_on_dynamic_enabled_collection(self, grpby_nonexist_field):
"""
target: test search group by with the non existing field against dynamic field enabled collection
method: 1. create a collection with dynamic field enabled
@@ -724,7 +724,7 @@ class TestGroupSearch(TestMilvusClientV2Base):
nq = 2
search_vectors = cf.gen_vectors(nq, dim=self.float_vector_dim,
vector_data_type=DataType.FLOAT_VECTOR)
search_params = {}
search_params = {"metric_type": self.float_vector_metric}
limit = 100
self.search(client, self.collection_name, data=search_vectors,
anns_field=self.float_vector_field_name,
@@ -735,10 +735,17 @@ class TestGroupSearch(TestMilvusClientV2Base):
@pytest.mark.xdist_group("TestGroupSearchInvalid")
@pytest.mark.tags(CaseLabel.GPU)
class TestGroupSearchInvalid(TestMilvusClientV2Base):
"""Shared collection for group-by invalid input tests.
Schema: int64_pk(PK, auto_id), float_vector(128), int8_vector(64), all scalar types (nullable),
dynamic=False
Data: 2000 rows
Index: FLAT/L2, HNSW/COSINE
"""
def setup_class(self):
super().setup_class(self)
self.collection_name = "TestGroupSearchInvalid" + cf.gen_unique_str("_")
self.collection_name = "TestGroupSearchInvalid" + cf.gen_unique_str("group_by")
self.primary_field = "int64_pk"
self.float_vector_field_name = ct.default_float_vec_field_name
self.int8_vector_field_name = "int8_vector"
@@ -777,7 +784,7 @@ class TestGroupSearchInvalid(TestMilvusClientV2Base):
insert_times = 2
nb = 1000
# Insert data multiple times with non-duplicated primary keys
for j in range(insert_times):
for _ in range(insert_times):
rows = cf.gen_row_data_by_schema(nb, schema=collection_schema)
# Insert into collection
self.insert(client, self.collection_name, data=rows)
@@ -788,12 +795,10 @@ class TestGroupSearchInvalid(TestMilvusClientV2Base):
index_params = self.prepare_index_params(client)[0]
index_params.add_index(field_name=self.float_vector_field_name,
metric_type=self.float_vector_metric,
index_type=self.float_vector_index,
params={"nlist": 128})
index_type=self.float_vector_index)
index_params.add_index(field_name=self.int8_vector_field_name,
metric_type=self.int8_vector_metric,
index_type=self.int8_vector_index,
params={"nlist": 128})
index_type=self.int8_vector_index)
self.create_index(client, self.collection_name, index_params=index_params)
self.wait_for_index_ready(client, self.collection_name, index_name=self.float_vector_field_name)
self.wait_for_index_ready(client, self.collection_name, index_name=self.int8_vector_field_name)
@@ -816,7 +821,7 @@ class TestGroupSearchInvalid(TestMilvusClientV2Base):
verify: the error code and msg
"""
client = self._client()
search_params = {}
search_params = {"metric_type": self.float_vector_metric}
search_vectors = cf.gen_vectors(1, dim=self.float_vector_dim,
vector_data_type=DataType.FLOAT_VECTOR)
# verify
@@ -831,7 +836,7 @@ class TestGroupSearchInvalid(TestMilvusClientV2Base):
@pytest.mark.tags(CaseLabel.L2)
@pytest.mark.parametrize("grpby_nonexist_field", ["nonexist_field", 21])
def test_search_group_by_non_exit_field(self, grpby_nonexist_field):
def test_search_group_by_nonexistent_field(self, grpby_nonexist_field):
"""
target: test search group by with the nonexisting field
method: 1. create a collection with data
@@ -842,7 +847,7 @@ class TestGroupSearchInvalid(TestMilvusClientV2Base):
client = self._client()
search_vectors = cf.gen_vectors(1, dim=self.float_vector_dim,
vector_data_type=DataType.FLOAT_VECTOR)
search_params = {}
search_params = {"metric_type": self.float_vector_metric}
limit = 1
error = {ct.err_code: 1700,
ct.err_msg: f"groupBy field not found in schema: field not found[field={grpby_nonexist_field}]"}
@@ -880,14 +885,12 @@ class TestSearchGroupByIndependent(TestMilvusClientV2Base):
self.load_collection(client, collection_name)
for _ in range(10):
rows = []
for i in range(ct.default_nb):
row = {
ct.default_primary_field_name: i,
ct.default_float_vec_field_name: cf.gen_vectors(1, dim=ct.default_dim)[0],
ct.default_int32_field_name: i,
}
rows.append(row)
all_vectors = cf.gen_vectors(ct.default_nb, dim=ct.default_dim)
rows = [{
ct.default_primary_field_name: i,
ct.default_float_vec_field_name: all_vectors[i],
ct.default_int32_field_name: i,
} for i in range(ct.default_nb)]
self.insert(client, collection_name, data=rows)
self.flush(client, collection_name)
@@ -896,7 +899,7 @@ class TestSearchGroupByIndependent(TestMilvusClientV2Base):
search_vectors = cf.gen_vectors(nq, dim=ct.default_dim)
grpby_field = ct.default_int32_field_name
search_params = {}
search_params = {"metric_type": metric}
# normal search to get the best result
normal_res = self.search(client, collection_name, data=search_vectors,
File diff suppressed because it is too large Load Diff
@@ -1,16 +1,10 @@
from pymilvus import (
FieldSchema, CollectionSchema, DataType,
Collection
)
from utils.util_pymilvus import *
from common.common_type import CaseLabel, CheckTasks
from common import common_type as ct
from common import common_func as cf
from utils.util_log import test_log as log
from base.client_base import TestcaseBase
import random
import pytest
import pandas as pd
from pymilvus import DataType
from common.common_type import CaseLabel, CheckTasks
from common import common_func as cf
from utils.util_log import test_log as log
from base.client_v2_base import TestMilvusClientV2Base
from faker import Faker
Faker.seed(19530)
@@ -23,381 +17,272 @@ cf.patch_faker_text(fake_zh, cf.zh_vocabularies_distribution)
pd.set_option("expand_frame_repr", False)
prefix = "search_collection"
search_num = 10
max_dim = ct.max_dim
min_dim = ct.min_dim
epsilon = ct.epsilon
hybrid_search_epsilon = 0.01
gracefulTime = ct.gracefulTime
default_nb = ct.default_nb
default_nb_medium = ct.default_nb_medium
default_nq = ct.default_nq
default_dim = ct.default_dim
default_limit = ct.default_limit
max_limit = ct.max_limit
default_search_exp = "int64 >= 0"
default_search_string_exp = "varchar >= \"0\""
default_search_mix_exp = "int64 >= 0 && varchar >= \"0\""
default_invaild_string_exp = "varchar >= 0"
default_json_search_exp = "json_field[\"number\"] >= 0"
perfix_expr = 'varchar like "0%"'
default_search_field = ct.default_float_vec_field_name
default_search_params = ct.default_search_params
default_int64_field_name = ct.default_int64_field_name
default_float_field_name = ct.default_float_field_name
default_bool_field_name = ct.default_bool_field_name
default_string_field_name = ct.default_string_field_name
default_json_field_name = ct.default_json_field_name
default_index_params = ct.default_index
vectors = [[random.random() for _ in range(default_dim)] for _ in range(default_nq)]
uid = "test_search"
nq = 1
epsilon = 0.001
field_name = default_float_vec_field_name
binary_field_name = default_binary_vec_field_name
search_param = {"nprobe": 1}
entity = gen_entities(1, is_normal=True)
entities = gen_entities(default_nb, is_normal=True)
raw_vectors, binary_entities = gen_binary_entities(default_nb)
default_query, _ = gen_search_vectors_params(field_name, entities, default_top_k, nq)
index_name1 = cf.gen_unique_str("float")
index_name2 = cf.gen_unique_str("varhar")
half_nb = ct.default_nb // 2
max_hybrid_search_req_num = ct.max_hybrid_search_req_num
class TestSearchTextMatchIndependent(TestMilvusClientV2Base):
"""Independent tests for text match search with tokenized varchar fields.
Each test creates its own collection because text_match requires specialized schema
(enable_analyzer, enable_match, analyzer_params) that varies by tokenizer config.
class TestSearchWithTextMatchFilter(TestcaseBase):
"""
******************************************************************
The following cases are used to test query text match
******************************************************************
Verification approach:
- Build word frequency map from inserted data using cf.analyze_documents
- Search with text_match filter using most common tokens
- Manually assert every returned result contains the matched token(s)
"""
TEXT_FIELDS = ["word", "sentence", "paragraph", "text"]
def _setup_text_match_collection(self, client, tokenizer, enable_inverted_index, enable_partition_key):
"""Helper to create collection, insert faker data, build index, and return analysis artifacts.
Returns: (collection_name, df_split, wf_map, dim)
"""
if tokenizer == "jieba":
language = "zh"
fake = fake_zh
else:
language = "en"
fake = fake_en
analyzer_params = {"tokenizer": tokenizer}
dim = 128
collection_name = cf.gen_collection_name_by_testcase_name()
schema = self.create_schema(client)[0]
schema.add_field("id", DataType.INT64, is_primary=True)
for field_name in self.TEXT_FIELDS:
extra = {}
if field_name == "word" and enable_partition_key:
extra["is_partition_key"] = True
schema.add_field(
field_name, DataType.VARCHAR, max_length=65535,
enable_analyzer=True, enable_match=True,
analyzer_params=analyzer_params, **extra)
schema.add_field("float32_emb", DataType.FLOAT_VECTOR, dim=dim)
schema.add_field("sparse_emb", DataType.SPARSE_FLOAT_VECTOR)
self.create_collection(client, collection_name, schema=schema)
# Generate and insert data
data_size = 5000
float_vectors = cf.gen_vectors(data_size, dim)
sparse_vectors = cf.gen_sparse_vectors(data_size, dim=10000)
data = [
{
"id": i,
"word": fake.word().lower(),
"sentence": fake.sentence().lower(),
"paragraph": fake.paragraph().lower(),
"text": fake.text().lower(),
"float32_emb": float_vectors[i],
"sparse_emb": sparse_vectors[i],
}
for i in range(data_size)
]
self.insert(client, collection_name, data=data)
self.flush(client, collection_name)
# Build indexes
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name="float32_emb", index_type="HNSW", metric_type="L2",
params={"M": 16, "efConstruction": 500})
idx.add_index(field_name="sparse_emb", index_type="SPARSE_INVERTED_INDEX", metric_type="IP")
if enable_inverted_index:
idx.add_index(field_name="word", index_type="INVERTED")
self.create_index(client, collection_name, index_params=idx)
self.load_collection(client, collection_name)
# Analyze corpus for verification
df = pd.DataFrame(data)
wf_map = {}
for field in self.TEXT_FIELDS:
wf_map[field] = cf.analyze_documents(df[field].tolist(), language=language)
df_split = cf.split_dataframes(df, self.TEXT_FIELDS, language=language)
return collection_name, df_split, wf_map, dim
@pytest.mark.tags(CaseLabel.L0)
@pytest.mark.parametrize("enable_partition_key", [True, False])
@pytest.mark.parametrize("enable_inverted_index", [True, False])
@pytest.mark.parametrize("tokenizer", ["standard"])
def test_search_with_text_match_filter_normal_en(
self, tokenizer, enable_inverted_index, enable_partition_key
self, enable_inverted_index, enable_partition_key
):
"""
target: test text match normal
method: 1. enable text match and insert data with varchar
2. get the most common words and query with text match
3. verify the result
expected: text match successfully and result is correct
target: verify text_match filter with standard tokenizer on English text across dense+sparse ANN
method: 1. create collection with enable_analyzer+enable_match on 4 varchar fields
2. insert 5000 rows of faker-generated English text
3. for each ANN field (float32_emb/sparse_emb) and each text field:
a. search with single most-common token → assert token in every result
b. search with top-10 tokens → assert any token in every result
4. verify text_match supports search-by-pk
expected: all results contain matched token(s); search-by-pk works with text_match filter
"""
analyzer_params = {
"tokenizer": tokenizer,
}
dim = 128
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True),
FieldSchema(
name="word",
dtype=DataType.VARCHAR,
max_length=65535,
enable_analyzer=True,
enable_match=True,
is_partition_key=enable_partition_key,
analyzer_params=analyzer_params,
),
FieldSchema(
name="sentence",
dtype=DataType.VARCHAR,
max_length=65535,
enable_analyzer=True,
enable_match=True,
analyzer_params=analyzer_params,
),
FieldSchema(
name="paragraph",
dtype=DataType.VARCHAR,
max_length=65535,
enable_analyzer=True,
enable_match=True,
analyzer_params=analyzer_params,
),
FieldSchema(
name="text",
dtype=DataType.VARCHAR,
max_length=65535,
enable_analyzer=True,
enable_match=True,
analyzer_params=analyzer_params,
),
FieldSchema(name="float32_emb", dtype=DataType.FLOAT_VECTOR, dim=dim),
FieldSchema(name="sparse_emb", dtype=DataType.SPARSE_FLOAT_VECTOR),
]
schema = CollectionSchema(fields=fields, description="test collection")
data_size = 5000
collection_w = self.init_collection_wrap(
name=cf.gen_unique_str(prefix), schema=schema
)
log.info(f"collection {collection_w.describe()}")
fake = fake_en
if tokenizer == "jieba":
language = "zh"
fake = fake_zh
else:
language = "en"
client = self._client()
collection_name, df_split, wf_map, dim = \
self._setup_text_match_collection(client, "standard", enable_inverted_index, enable_partition_key)
data = [
{
"id": i,
"word": fake.word().lower(),
"sentence": fake.sentence().lower(),
"paragraph": fake.paragraph().lower(),
"text": fake.text().lower(),
"float32_emb": [random.random() for _ in range(dim)],
"sparse_emb": cf.gen_sparse_vectors(1, dim=10000)[0],
}
for i in range(data_size)
]
df = pd.DataFrame(data)
log.info(f"dataframe\n{df}")
batch_size = 5000
for i in range(0, len(df), batch_size):
collection_w.insert(
data[i: i + batch_size]
if i + batch_size < len(df)
else data[i: len(df)]
)
collection_w.flush()
collection_w.create_index(
"float32_emb",
{"index_type": "HNSW", "metric_type": "L2", "params": {"M": 16, "efConstruction": 500}},
)
collection_w.create_index(
"sparse_emb",
{"index_type": "SPARSE_INVERTED_INDEX", "metric_type": "IP"},
)
if enable_inverted_index:
collection_w.create_index("word", {"index_type": "INVERTED"})
collection_w.load()
# analyze the croup
text_fields = ["word", "sentence", "paragraph", "text"]
wf_map = {}
for field in text_fields:
wf_map[field] = cf.analyze_documents(df[field].tolist(), language=language)
# search with filter single field for one token
df_split = cf.split_dataframes(df, text_fields, language=language)
log.info(f"df_split\n{df_split}")
text_fields = self.TEXT_FIELDS
for ann_field in ["float32_emb", "sparse_emb"]:
log.info(f"ann_field {ann_field}")
if ann_field == "float32_emb":
search_data = [[random.random() for _ in range(dim)]]
elif ann_field == "sparse_emb":
search_data = cf.gen_sparse_vectors(1, dim=10000)
search_data = cf.gen_vectors(1, dim)
search_params = {"metric_type": "L2"}
else:
search_data = [[random.random() for _ in range(dim)]]
search_data = cf.gen_sparse_vectors(1, dim=10000)
search_params = {"metric_type": "IP"}
# search with single token per text field
for field in text_fields:
token = wf_map[field].most_common()[0][0]
expr = f"text_match({field}, '{token}')"
manual_result = df_split[
df_split.apply(lambda row: token in row[field], axis=1)
df_split.apply(lambda row, t=token, f=field: t in row[f], axis=1)
]
log.info(f"expr: {expr}, manual_check_result: {len(manual_result)}")
res_list, _ = collection_w.search(
res_list, _ = self.search(
client, collection_name,
data=search_data,
anns_field=ann_field,
param={},
search_params=search_params,
limit=100,
expr=expr, output_fields=["id", field])
filter=expr,
output_fields=["id", field])
assert len(res_list) >= 1
assert len(res_list[0]) > 0
assert len(res_list[0]) <= len(manual_result)
for res in res_list:
log.info(f"res len {len(res)} res {res}")
assert len(res) > 0
assert len(res) >= 1
for r in res:
r = r.to_dict()
assert token in r["entity"][field]
# search with filter single field for multi-token
# search with multi-token (top 10 most common words) per text field
for field in text_fields:
# match top 10 most common words
top_10_tokens = []
for word, count in wf_map[field].most_common(10):
top_10_tokens.append(word)
top_10_tokens = [word for word, _ in wf_map[field].most_common(10)]
string_of_top_10_words = " ".join(top_10_tokens)
expr = f"text_match({field}, '{string_of_top_10_words}')"
log.info(f"expr {expr}")
res_list, _ = collection_w.search(
res_list, _ = self.search(
client, collection_name,
data=search_data,
anns_field=ann_field,
param={},
search_params=search_params,
limit=100,
expr=expr, output_fields=["id", field])
filter=expr,
output_fields=["id", field])
assert len(res_list) >= 1
assert len(res_list[0]) > 0
for res in res_list:
log.info(f"res len {len(res)} res {res}")
assert len(res) > 0
assert len(res) >= 1
for r in res:
r = r.to_dict()
assert any([token in r["entity"][field] for token in top_10_tokens])
assert any(token in r["entity"][field] for token in top_10_tokens)
# verify Text Match support search by pk
collection_w.search(ids=[1, 2],
anns_field=ann_field,
param={},limit=100,
expr=expr, output_fields=["id", field],
check_task=CheckTasks.check_search_results,
check_items={"nq": 2, "limit": 100})
# verify text_match supports search-by-pk
res_list, _ = self.search(
client, collection_name,
data=None,
ids=[1, 2],
anns_field=ann_field,
search_params=search_params,
limit=100,
filter=expr,
output_fields=["id", field],
check_task=CheckTasks.check_search_results,
check_items={"nq": 2, "limit": 100,
"enable_milvus_client_api": True})
for res in res_list:
for r in res:
assert any(token in r["entity"][field] for token in top_10_tokens)
@pytest.mark.tags(CaseLabel.L0)
@pytest.mark.parametrize("enable_partition_key", [True, False])
@pytest.mark.parametrize("enable_inverted_index", [True, False])
@pytest.mark.parametrize("tokenizer", ["jieba"])
@pytest.mark.skip(reason="unstable case")
@pytest.mark.skip(reason="unstable: jieba tokenization vs Python substring mismatch, see analysis below")
def test_search_with_text_match_filter_normal_zh(
self, tokenizer, enable_inverted_index, enable_partition_key
self, enable_inverted_index, enable_partition_key
):
"""
target: test text match normal
method: 1. enable text match and insert data with varchar
2. get the most common words and query with text match
3. verify the result
expected: text match successfully and result is correct
target: verify text_match filter with jieba tokenizer on Chinese text across dense+sparse ANN
method: 1. create collection with enable_analyzer+enable_match using jieba tokenizer
2. insert 5000 rows of faker-generated Chinese text
3. for each ANN field and each text field:
a. search with single most-common token → assert token in every result
b. search with top-10 tokens → assert any token in every result
4. verify text_match supports search-by-pk
expected: all results contain matched token(s); search-by-pk works with text_match filter
"""
analyzer_params = {
"tokenizer": tokenizer,
}
dim = 128
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True),
FieldSchema(
name="word",
dtype=DataType.VARCHAR,
max_length=65535,
enable_analyzer=True,
enable_match=True,
is_partition_key=enable_partition_key,
analyzer_params=analyzer_params,
),
FieldSchema(
name="sentence",
dtype=DataType.VARCHAR,
max_length=65535,
enable_analyzer=True,
enable_match=True,
analyzer_params=analyzer_params,
),
FieldSchema(
name="paragraph",
dtype=DataType.VARCHAR,
max_length=65535,
enable_analyzer=True,
enable_match=True,
analyzer_params=analyzer_params,
),
FieldSchema(
name="text",
dtype=DataType.VARCHAR,
max_length=65535,
enable_analyzer=True,
enable_match=True,
analyzer_params=analyzer_params,
),
FieldSchema(name="float32_emb", dtype=DataType.FLOAT_VECTOR, dim=dim),
FieldSchema(name="sparse_emb", dtype=DataType.SPARSE_FLOAT_VECTOR),
]
schema = CollectionSchema(fields=fields, description="test collection")
data_size = 5000
collection_w = self.init_collection_wrap(
name=cf.gen_unique_str(prefix), schema=schema
)
log.info(f"collection {collection_w.describe()}")
fake = fake_en
if tokenizer == "jieba":
language = "zh"
fake = fake_zh
else:
language = "en"
client = self._client()
collection_name, df_split, wf_map, dim = \
self._setup_text_match_collection(client, "jieba", enable_inverted_index, enable_partition_key)
data = [
{
"id": i,
"word": fake.word().lower(),
"sentence": fake.sentence().lower(),
"paragraph": fake.paragraph().lower(),
"text": fake.text().lower(),
"float32_emb": [random.random() for _ in range(dim)],
"sparse_emb": cf.gen_sparse_vectors(1, dim=10000)[0],
}
for i in range(data_size)
]
df = pd.DataFrame(data)
log.info(f"dataframe\n{df}")
batch_size = 5000
for i in range(0, len(df), batch_size):
collection_w.insert(
data[i : i + batch_size]
if i + batch_size < len(df)
else data[i : len(df)]
)
collection_w.flush()
collection_w.create_index(
"float32_emb",
{"index_type": "HNSW", "metric_type": "L2", "params": {"M": 16, "efConstruction": 500}},
)
collection_w.create_index(
"sparse_emb",
{"index_type": "SPARSE_INVERTED_INDEX", "metric_type": "IP"},
)
if enable_inverted_index:
collection_w.create_index("word", {"index_type": "INVERTED"})
collection_w.load()
# analyze the croup
text_fields = ["word", "sentence", "paragraph", "text"]
wf_map = {}
for field in text_fields:
wf_map[field] = cf.analyze_documents(df[field].tolist(), language=language)
# search with filter single field for one token
df_split = cf.split_dataframes(df, text_fields, language=language)
log.info(f"df_split\n{df_split}")
text_fields = self.TEXT_FIELDS
for ann_field in ["float32_emb", "sparse_emb"]:
log.info(f"ann_field {ann_field}")
if ann_field == "float32_emb":
search_data = [[random.random() for _ in range(dim)]]
elif ann_field == "sparse_emb":
search_data = cf.gen_sparse_vectors(1,dim=10000)
search_data = cf.gen_vectors(1, dim)
search_params = {"metric_type": "L2"}
else:
search_data = [[random.random() for _ in range(dim)]]
search_data = cf.gen_sparse_vectors(1, dim=10000)
search_params = {"metric_type": "IP"}
# search with single token per text field
for field in text_fields:
token = wf_map[field].most_common()[0][0]
expr = f"text_match({field}, '{token}')"
manual_result = df_split[
df_split.apply(lambda row: token in row[field], axis=1)
df_split.apply(lambda row, t=token, f=field: t in row[f], axis=1)
]
log.info(f"expr: {expr}, manual_check_result: {len(manual_result)}")
res_list, _ = collection_w.search(
res_list, _ = self.search(
client, collection_name,
data=search_data,
anns_field=ann_field,
param={},
search_params=search_params,
limit=100,
expr=expr, output_fields=["id", field])
filter=expr,
output_fields=["id", field])
assert len(res_list) >= 1
assert len(res_list[0]) > 0
assert len(res_list[0]) <= len(manual_result)
for res in res_list:
log.info(f"res len {len(res)} res {res}")
assert len(res) > 0
assert len(res) >= 1
for r in res:
r = r.to_dict()
assert token in r["entity"][field]
# search with filter single field for multi-token
# search with multi-token (top 10 most common words) per text field
for field in text_fields:
# match top 10 most common words
top_10_tokens = []
for word, count in wf_map[field].most_common(10):
top_10_tokens.append(word)
top_10_tokens = [word for word, _ in wf_map[field].most_common(10)]
string_of_top_10_words = " ".join(top_10_tokens)
expr = f"text_match({field}, '{string_of_top_10_words}')"
log.info(f"expr {expr}")
res_list, _ = collection_w.search(
res_list, _ = self.search(
client, collection_name,
data=search_data,
anns_field=ann_field,
param={},
search_params=search_params,
limit=100,
expr=expr, output_fields=["id", field])
filter=expr,
output_fields=["id", field])
assert len(res_list) >= 1
assert len(res_list[0]) > 0
for res in res_list:
log.info(f"res len {len(res)} res {res}")
assert len(res) > 0
assert len(res) >= 1
for r in res:
r = r.to_dict()
assert any([token in r["entity"][field] for token in top_10_tokens])
assert any(token in r["entity"][field] for token in top_10_tokens)
# verify text_match supports search-by-pk
res_list, _ = self.search(
client, collection_name,
data=None,
ids=[1, 2],
anns_field=ann_field,
search_params=search_params,
limit=100,
filter=expr,
output_fields=["id", field],
check_task=CheckTasks.check_search_results,
check_items={"nq": 2, "limit": 100,
"enable_milvus_client_api": True})
for res in res_list:
for r in res:
assert any(token in r["entity"][field] for token in top_10_tokens)
@@ -1,292 +1,494 @@
import numpy as np
from pymilvus.orm.types import CONSISTENCY_STRONG, CONSISTENCY_BOUNDED, CONSISTENCY_SESSION, CONSISTENCY_EVENTUALLY
from pymilvus import AnnSearchRequest, RRFRanker, WeightedRanker
from pymilvus import (
FieldSchema, CollectionSchema, DataType,
Collection
)
from common.constants import *
from utils.util_pymilvus import *
import pytest
from common.common_type import CaseLabel, CheckTasks
from common import common_type as ct
from common import common_func as cf
from utils.util_log import test_log as log
from base.client_base import TestcaseBase
import heapq
from time import sleep
from decimal import Decimal, getcontext
import decimal
import multiprocessing
import numbers
import random
import math
import numpy
import threading
import pytest
import pandas as pd
from faker import Faker
from base.client_v2_base import TestMilvusClientV2Base
Faker.seed(19530)
fake_en = Faker("en_US")
fake_zh = Faker("zh_CN")
# patch faker to generate text with specific distribution
cf.patch_faker_text(fake_en, cf.en_vocabularies_distribution)
cf.patch_faker_text(fake_zh, cf.zh_vocabularies_distribution)
pd.set_option("expand_frame_repr", False)
prefix = "search_collection"
search_num = 10
max_dim = ct.max_dim
min_dim = ct.min_dim
epsilon = ct.epsilon
hybrid_search_epsilon = 0.01
gracefulTime = ct.gracefulTime
default_nb = ct.default_nb
default_nb_medium = ct.default_nb_medium
default_nq = ct.default_nq
default_dim = ct.default_dim
default_limit = ct.default_limit
max_limit = ct.max_limit
default_search_exp = "int64 >= 0"
default_search_string_exp = "varchar >= \"0\""
default_search_mix_exp = "int64 >= 0 && varchar >= \"0\""
default_invaild_string_exp = "varchar >= 0"
default_json_search_exp = "json_field[\"number\"] >= 0"
perfix_expr = 'varchar like "0%"'
default_search_field = ct.default_float_vec_field_name
default_search_params = ct.default_search_params
default_int64_field_name = ct.default_int64_field_name
default_float_field_name = ct.default_float_field_name
default_bool_field_name = ct.default_bool_field_name
default_string_field_name = ct.default_string_field_name
default_json_field_name = ct.default_json_field_name
default_index_params = ct.default_index
vectors = [[random.random() for _ in range(default_dim)] for _ in range(default_nq)]
uid = "test_search"
nq = 1
epsilon = 0.001
field_name = default_float_vec_field_name
binary_field_name = default_binary_vec_field_name
search_param = {"nprobe": 1}
entity = gen_entities(1, is_normal=True)
entities = gen_entities(default_nb, is_normal=True)
raw_vectors, binary_entities = gen_binary_entities(default_nb)
default_query, _ = gen_search_vectors_params(field_name, entities, default_top_k, nq)
index_name1 = cf.gen_unique_str("float")
index_name2 = cf.gen_unique_str("varhar")
half_nb = ct.default_nb // 2
max_hybrid_search_req_num = ct.max_hybrid_search_req_num
class TestSparseSearch(TestcaseBase):
""" Add some test cases for the sparse vector """
@pytest.mark.xdist_group("TestSparseSearchShared")
@pytest.mark.tags(CaseLabel.L1)
class TestSparseSearchShared(TestMilvusClientV2Base):
"""Shared collection for sparse vector search read-only tests.
Schema: int64(PK), float, varchar(65535), sparse_vector
Data: 4000 rows
Index: SPARSE_INVERTED_INDEX / IP
"""
shared_alias = "TestSparseSearchShared"
def setup_class(self):
super().setup_class(self)
self.collection_name = "TestSparseSearchShared" + cf.gen_unique_str("sparse_search")
@pytest.fixture(scope="class", autouse=True)
def prepare_collection(self, request):
client = self._client(alias=self.shared_alias)
schema = cf.gen_default_sparse_schema(auto_id=False)
self.create_collection(client, self.collection_name, schema=schema, force_teardown=False)
data = cf.gen_row_data_by_schema(nb=4000, schema=schema)
self.insert(client, self.collection_name, data=data)
self.flush(client, self.collection_name)
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_sparse_vec_field_name,
index_type="SPARSE_INVERTED_INDEX", metric_type="IP", params={})
self.create_index(client, self.collection_name, index_params=idx)
self.load_collection(client, self.collection_name)
def teardown():
self.drop_collection(self._client(alias=self.shared_alias), self.collection_name)
request.addfinalizer(teardown)
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("index", ct.all_index_types[10:12])
def test_sparse_search_default(self):
"""
target: verify basic sparse vector search returns correct results with IP distance ordering
method: 1. search on shared sparse collection with default search params
2. check nq, limit, output_fields, and IP distance descending order via check_task
expected: search returns nq groups, each with limit results, distances sorted descending (IP)
"""
client = self._client(alias=self.shared_alias)
search_vectors = cf.gen_sparse_vectors(default_nq)
self.search(client, self.collection_name,
data=search_vectors,
anns_field=ct.default_sparse_vec_field_name,
search_params=ct.default_sparse_search_params,
limit=default_limit,
output_fields=[ct.default_sparse_vec_field_name],
check_task=CheckTasks.check_search_results,
check_items={"nq": default_nq,
"limit": default_limit,
"metric": "IP",
"enable_milvus_client_api": True,
"pk_name": ct.default_int64_field_name,
"output_fields": [ct.default_sparse_vec_field_name]})
@pytest.mark.tags(CaseLabel.L1)
def test_sparse_search_with_filter(self):
"""
target: verify sparse search with scalar filter correctly filters results
method: 1. search with filter "int64 < 100" on shared sparse collection
2. check nq, limit, distance order via check_task
3. manually assert every returned hit satisfies int64 < 100
expected: all returned results have int64 < 100, no false positives from filter
"""
client = self._client(alias=self.shared_alias)
search_vectors = cf.gen_sparse_vectors(default_nq)
filter_limit = 100
expr = f"{ct.default_int64_field_name} < {filter_limit}"
search_res, _ = self.search(client, self.collection_name,
data=search_vectors,
anns_field=ct.default_sparse_vec_field_name,
search_params=ct.default_sparse_search_params,
limit=default_limit,
filter=expr,
output_fields=[ct.default_int64_field_name],
check_task=CheckTasks.check_search_results,
check_items={"nq": default_nq,
"limit": default_limit,
"metric": "IP",
"enable_milvus_client_api": True,
"pk_name": ct.default_int64_field_name,
"output_fields": [ct.default_int64_field_name]})
for hits in search_res:
for hit in hits:
assert hit[ct.default_int64_field_name] < filter_limit, \
f"filter not effective: got {ct.default_int64_field_name}={hit[ct.default_int64_field_name]}"
@pytest.mark.tags(CaseLabel.L2)
def test_sparse_search_output_field(self):
"""
target: verify sparse search returns exactly the requested output fields
method: 1. search with output_fields=[float, sparse_vector]
2. check_task verifies returned field set matches requested fields exactly
expected: each hit contains float and sparse_vector fields, no extra or missing fields
"""
client = self._client(alias=self.shared_alias)
search_vectors = cf.gen_sparse_vectors(default_nq)
self.search(client, self.collection_name,
data=search_vectors,
anns_field=ct.default_sparse_vec_field_name,
search_params=ct.default_sparse_search_params,
limit=default_limit,
output_fields=[ct.default_float_field_name, ct.default_sparse_vec_field_name],
check_task=CheckTasks.check_search_results,
check_items={"nq": default_nq,
"limit": default_limit,
"metric": "IP",
"enable_milvus_client_api": True,
"pk_name": ct.default_int64_field_name,
"output_fields": [ct.default_float_field_name,
ct.default_sparse_vec_field_name]})
@pytest.mark.tags(CaseLabel.L2)
@pytest.mark.parametrize("batch_size", [10, 100, 500])
def test_sparse_search_iterator(self, batch_size):
"""
target: verify sparse search iterator works correctly with various batch sizes
method: 1. create search iterator with batch_size={10,100,500} and limit=500
2. check_search_iterator verifies: each batch <= batch_size, no duplicate PKs,
total results > 0
expected: iterator exhausts all results, PKs are unique across all batches
"""
client = self._client(alias=self.shared_alias)
search_vectors = cf.gen_sparse_vectors(1)
self.search_iterator(client, self.collection_name,
data=search_vectors,
batch_size=batch_size,
limit=500,
anns_field=ct.default_sparse_vec_field_name,
search_params=ct.default_sparse_search_params,
check_task=CheckTasks.check_search_iterator,
check_items={"batch_size": batch_size})
@pytest.mark.tags(CaseLabel.L2)
@pytest.mark.parametrize("metric_type", ["L2", "COSINE"])
def test_sparse_search_invalid_metric_type(self, metric_type):
"""
target: verify sparse vector search rejects unsupported metric types
method: 1. search with metric_type={L2,COSINE} on sparse vector field (only IP is valid)
2. check_task verifies error response with code 1100
expected: search fails with error message containing "only IP is supported"
"""
client = self._client(alias=self.shared_alias)
search_vectors = cf.gen_sparse_vectors(1)
search_params = {"metric_type": metric_type, "params": {}}
self.search(client, self.collection_name,
data=search_vectors,
anns_field=ct.default_sparse_vec_field_name,
search_params=search_params,
limit=default_limit,
check_task=CheckTasks.err_res,
check_items={ct.err_code: 1100,
ct.err_msg: "only IP is supported"})
@pytest.mark.tags(CaseLabel.L2)
@pytest.mark.parametrize("nq", [1, 100])
def test_sparse_search_different_nq(self, nq):
"""
target: verify sparse search handles different numbers of query vectors correctly
method: 1. search with nq={1,100} sparse query vectors
2. check_task verifies len(search_res) == nq and each query returns limit results
expected: search returns exactly nq groups of results with correct limit and IP ordering
"""
client = self._client(alias=self.shared_alias)
search_vectors = cf.gen_sparse_vectors(nq)
self.search(client, self.collection_name,
data=search_vectors,
anns_field=ct.default_sparse_vec_field_name,
search_params=ct.default_sparse_search_params,
limit=default_limit,
check_task=CheckTasks.check_search_results,
check_items={"nq": nq,
"limit": default_limit,
"metric": "IP",
"enable_milvus_client_api": True,
"pk_name": ct.default_int64_field_name})
class TestSparseSearchIndependent(TestMilvusClientV2Base):
"""Test cases that require independent collection setup (custom index/mmap/delete/dim)."""
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("index", ct.sparse_supported_index_types)
@pytest.mark.parametrize("inverted_index_algo", ct.inverted_index_algo)
def test_sparse_index_search(self, index, inverted_index_algo):
"""
target: verify that sparse index for sparse vectors can be searched properly
method: create connection, collection, insert and search
expected: search successfully
target: verify all sparse index types × inverted_index_algo combinations produce correct search results
method: 1. create collection, insert 3000 rows, build index with parametrized type/algo
2. search with dim_max_score_ratio=1.05 and output sparse_vector field
3. check_task verifies nq, limit, output_fields, and IP distance descending order
expected: search returns correct results for every index type and algo variant
"""
self._connect()
c_name = cf.gen_unique_str(prefix)
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
nb = 3000
# create collection with sparse schema
schema = cf.gen_default_sparse_schema(auto_id=False)
collection_w = self.init_collection_wrap(c_name, schema=schema)
data = cf.gen_default_list_sparse_data(nb=3000)
collection_w.insert(data)
self.create_collection(client, collection_name, schema=schema)
# insert data
data = cf.gen_row_data_by_schema(nb=nb, schema=schema)
self.insert(client, collection_name, data=data)
self.flush(client, collection_name)
# create sparse index
params = cf.get_index_params_params(index)
params.update({"inverted_index_algo": inverted_index_algo})
index_params = {"index_type": index, "metric_type": "IP", "params": params}
collection_w.create_index(ct.default_sparse_vec_field_name, index_params, index_name=index)
collection_w.load()
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_sparse_vec_field_name,
index_type=index, metric_type="IP", params=params)
self.create_index(client, collection_name, index_params=idx)
self.load_collection(client, collection_name)
# search
_params = cf.get_search_params_params(index)
_params.update({"dim_max_score_ratio": 1.05})
search_params = {"params": _params}
collection_w.search(data[-1][0:default_nq], ct.default_sparse_vec_field_name,
search_params, default_limit,
output_fields=[ct.default_sparse_vec_field_name],
check_task=CheckTasks.check_search_results,
check_items={"nq": default_nq,
"limit": default_limit,
"output_fields": [ct.default_sparse_vec_field_name]})
expr = "int64 < 100 "
collection_w.search(data[-1][0:default_nq], ct.default_sparse_vec_field_name,
search_params, default_limit,
expr=expr, output_fields=[ct.default_sparse_vec_field_name],
check_task=CheckTasks.check_search_results,
check_items={"nq": default_nq,
"limit": default_limit,
"output_fields": [ct.default_sparse_vec_field_name]})
search_params = {"metric_type": "IP", "params": _params}
search_vectors = cf.gen_sparse_vectors(default_nq)
self.search(client, collection_name,
data=search_vectors,
anns_field=ct.default_sparse_vec_field_name,
search_params=search_params,
limit=default_limit,
output_fields=[ct.default_sparse_vec_field_name],
check_task=CheckTasks.check_search_results,
check_items={"nq": default_nq,
"limit": default_limit,
"metric": "IP",
"enable_milvus_client_api": True,
"pk_name": ct.default_int64_field_name,
"output_fields": [ct.default_sparse_vec_field_name]})
@pytest.mark.tags(CaseLabel.L2)
@pytest.mark.parametrize("index", ct.all_index_types[10:12])
@pytest.mark.parametrize("index", ct.sparse_supported_index_types)
@pytest.mark.parametrize("dim", [32768, ct.max_sparse_vector_dim])
def test_sparse_index_dim(self, index, dim):
"""
target: validating the sparse index in different dimensions
method: create connection, collection, insert and hybrid search
expected: search successfully
target: verify sparse index and search work correctly with high-dimensional sparse vectors
method: 1. create collection, insert sparse vectors with dim={32768, max_sparse_vector_dim}
(nb reduced to 100 for max_dim to avoid OOM)
2. build index and search with default_limit
3. check_task verifies nq, limit, and IP distance ordering
expected: search returns correct results even at extreme sparse dimensions
"""
self._connect()
c_name = cf.gen_unique_str(prefix)
schema = cf.gen_default_sparse_schema(auto_id=False)
collection_w = self.init_collection_wrap(c_name, schema=schema)
data = cf.gen_default_list_sparse_data(dim=dim)
collection_w.insert(data)
params = cf.get_index_params_params(index)
index_params = {"index_type": index, "metric_type": "IP", "params": params}
collection_w.create_index(ct.default_sparse_vec_field_name, index_params, index_name=index)
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
# reduce nb for extremely high dims to avoid OOM
nb = 100 if dim == ct.max_sparse_vector_dim else default_nb
collection_w.load()
collection_w.search(data[-1][0:default_nq], ct.default_sparse_vec_field_name,
ct.default_sparse_search_params, limit=1,
check_task=CheckTasks.check_search_results,
check_items={"nq": default_nq,
"limit": 1})
# create collection with sparse schema
schema = cf.gen_default_sparse_schema(auto_id=False)
self.create_collection(client, collection_name, schema=schema)
# insert data — override sparse vectors with custom dim
data = cf.gen_row_data_by_schema(nb=nb, schema=schema)
sparse_vectors = cf.gen_sparse_vectors(nb, dim=dim)
for i in range(nb):
data[i][ct.default_sparse_vec_field_name] = sparse_vectors[i]
self.insert(client, collection_name, data=data)
# create sparse index
params = cf.get_index_params_params(index)
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_sparse_vec_field_name,
index_type=index, metric_type="IP", params=params)
self.create_index(client, collection_name, index_params=idx)
self.load_collection(client, collection_name)
# search
search_vectors = cf.gen_sparse_vectors(default_nq)
self.search(client, collection_name,
data=search_vectors,
anns_field=ct.default_sparse_vec_field_name,
search_params=ct.default_sparse_search_params,
limit=default_limit,
check_task=CheckTasks.check_search_results,
check_items={"nq": default_nq,
"limit": default_limit,
"metric": "IP",
"enable_milvus_client_api": True,
"pk_name": ct.default_int64_field_name})
@pytest.mark.tags(CaseLabel.L2)
@pytest.mark.parametrize("index", ct.all_index_types[10:12])
@pytest.mark.parametrize("index", ct.sparse_supported_index_types)
@pytest.mark.parametrize("inverted_index_algo", ct.inverted_index_algo)
def test_sparse_index_enable_mmap_search(self, index, inverted_index_algo):
"""
target: verify that the sparse indexes of sparse vectors can be searched properly after turning on mmap
method: create connection, collection, enable mmap, insert and search
expected: search successfully , query result is correct
target: verify sparse search works correctly after enabling mmap on both collection and index
method: 1. create collection, insert 3000 rows, build sparse index with parametrized type/algo
2. enable mmap on collection and index, assert properties are set to 'True'
3. insert 2000 more rows (start=3000), flush and load
4. search and verify nq, limit, output_fields, IP distance order via check_task
5. query specific PKs [0,1,10,100] and verify exact match on returned int64 values
expected: mmap does not affect search correctness; data from both batches is queryable
"""
self._connect()
c_name = cf.gen_unique_str(prefix)
schema = cf.gen_default_sparse_schema(auto_id=False)
collection_w = self.init_collection_wrap(c_name, schema=schema)
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
first_nb = 3000
data = cf.gen_default_list_sparse_data(nb=first_nb, start=0)
collection_w.insert(data)
schema = cf.gen_default_sparse_schema(auto_id=False)
self.create_collection(client, collection_name, schema=schema)
# insert first batch
data = cf.gen_row_data_by_schema(nb=first_nb, schema=schema, start=0)
self.insert(client, collection_name, data=data)
# create sparse index
params = cf.get_index_params_params(index)
params.update({"inverted_index_algo": inverted_index_algo})
index_params = {"index_type": index, "metric_type": "IP", "params": params}
collection_w.create_index(ct.default_sparse_vec_field_name, index_params, index_name=index)
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_sparse_vec_field_name,
index_type=index, metric_type="IP", params=params)
self.create_index(client, collection_name, index_params=idx)
collection_w.set_properties({'mmap.enabled': True})
pro = collection_w.describe()[0].get("properties")
assert pro["mmap.enabled"] == 'True'
collection_w.alter_index(index, {'mmap.enabled': True})
assert collection_w.index()[0].params["mmap.enabled"] == 'True'
data2 = cf.gen_default_list_sparse_data(nb=2000, start=first_nb) # id shall be continuous
all_data = [] # combine 2 insert datas for next checking
for i in range(len(data2)):
all_data.append(data[i] + data2[i])
collection_w.insert(data2)
collection_w.flush()
collection_w.load()
collection_w.search(data[-1][0:default_nq], ct.default_sparse_vec_field_name,
ct.default_sparse_search_params, default_limit,
output_fields=[ct.default_sparse_vec_field_name],
check_task=CheckTasks.check_search_results,
check_items={"nq": default_nq,
"limit": default_limit,
"output_fields": [ct.default_sparse_vec_field_name]})
# enable mmap on collection
self.alter_collection_properties(client, collection_name, properties={'mmap.enabled': True})
desc, _ = self.describe_collection(client, collection_name)
assert desc.get("properties", {}).get("mmap.enabled") == 'True'
# enable mmap on index
self.alter_index_properties(client, collection_name,
index_name=ct.default_sparse_vec_field_name,
properties={'mmap.enabled': True})
index_info, _ = self.describe_index(client, collection_name,
index_name=ct.default_sparse_vec_field_name)
assert index_info.get("mmap.enabled") == 'True'
# insert second batch
second_nb = 2000
data2 = cf.gen_row_data_by_schema(nb=second_nb, schema=schema, start=first_nb)
self.insert(client, collection_name, data=data2)
self.flush(client, collection_name)
self.load_collection(client, collection_name)
# search
_search_params = cf.get_search_params_params(index)
search_params = {"metric_type": "IP", "params": _search_params}
search_vectors = cf.gen_sparse_vectors(default_nq)
self.search(client, collection_name,
data=search_vectors,
anns_field=ct.default_sparse_vec_field_name,
search_params=search_params,
limit=default_limit,
output_fields=[ct.default_sparse_vec_field_name],
check_task=CheckTasks.check_search_results,
check_items={"nq": default_nq,
"limit": default_limit,
"metric": "IP",
"enable_milvus_client_api": True,
"pk_name": ct.default_int64_field_name,
"output_fields": [ct.default_sparse_vec_field_name]})
# query to verify data from both batches
expr_id_list = [0, 1, 10, 100]
term_expr = f'{ct.default_int64_field_name} in {expr_id_list}'
res = collection_w.query(term_expr)[0]
assert len(res) == 4
res, _ = self.query(client, collection_name, filter=term_expr,
output_fields=[ct.default_int64_field_name])
assert len(res) == len(expr_id_list)
returned_ids = sorted([r[ct.default_int64_field_name] for r in res])
assert returned_ids == sorted(expr_id_list)
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("drop_ratio_build", [0.01])
@pytest.mark.parametrize("index", ct.all_index_types[10:12])
def test_search_sparse_ratio(self, drop_ratio_build, index):
@pytest.mark.parametrize("index", ct.sparse_supported_index_types)
def test_search_sparse_ratio(self, index):
"""
target: create a sparse index by adjusting the ratio parameter.
method: create a sparse index by adjusting the ratio parameter.
expected: search successfully
target: verify sparse search behavior with valid and invalid dim_max_score_ratio values
method: 1. create collection, insert 4000 rows, build index with drop_ratio_build=0.01
2. verify index exists via list_indexes
3. search with valid dim_max_score_ratio={0.5, 0.99, 1, 1.3}:
assert results non-empty and distances sorted descending (IP)
4. search with invalid dim_max_score_ratio={0.49, 1.4}:
assert error code 999 with range validation message
expected: valid ratios return correctly ordered results; out-of-range ratios are rejected
"""
self._connect()
c_name = cf.gen_unique_str(prefix)
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
nb = 4000
drop_ratio_build = 0.01
schema = cf.gen_default_sparse_schema(auto_id=False)
collection_w = self.init_collection_wrap(c_name, schema=schema)
data = cf.gen_default_list_sparse_data(nb=4000)
collection_w.insert(data)
collection_w.flush()
params = {"index_type": index, "metric_type": "IP", "params": {"drop_ratio_build": drop_ratio_build}}
collection_w.create_index(ct.default_sparse_vec_field_name, params, index_name=index)
collection_w.load()
assert collection_w.has_index(index_name=index)[0] is True
self.create_collection(client, collection_name, schema=schema)
data = cf.gen_row_data_by_schema(nb=nb, schema=schema)
self.insert(client, collection_name, data=data)
self.flush(client, collection_name)
# create sparse index with drop_ratio_build
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_sparse_vec_field_name,
index_type=index, metric_type="IP",
params={"drop_ratio_build": drop_ratio_build})
self.create_index(client, collection_name, index_params=idx)
self.load_collection(client, collection_name)
# verify index exists
indexes, _ = self.list_indexes(client, collection_name)
assert ct.default_sparse_vec_field_name in indexes
# search with valid dim_max_score_ratio values
search_vectors = cf.gen_sparse_vectors(default_nq)
_params = {"drop_ratio_search": 0.2}
for dim_max_score_ratio in [0.5, 0.99, 1, 1.3]:
_params.update({"dim_max_score_ratio": dim_max_score_ratio})
search_params = {"metric_type": "IP", "params": _params}
collection_w.search(data[-1][0:default_nq], ct.default_sparse_vec_field_name,
search_params, default_limit,
check_task=CheckTasks.check_search_results,
check_items={"nq": default_nq,
"limit": default_limit})
search_res, _ = self.search(client, collection_name,
data=search_vectors,
anns_field=ct.default_sparse_vec_field_name,
search_params=search_params,
limit=default_limit)
assert len(search_res) == default_nq
for hits in search_res:
assert len(hits) > 0, f"no results for dim_max_score_ratio={dim_max_score_ratio}"
distances = [hit['distance'] for hit in hits]
assert distances == sorted(distances, reverse=True), \
f"distances not sorted descending for IP with ratio={dim_max_score_ratio}"
# search with invalid dim_max_score_ratio values
error = {ct.err_code: 999,
ct.err_msg: "should be in range [0.500000, 1.300000]"}
for invalid_ratio in [0.49, 1.4]:
_params.update({"dim_max_score_ratio": invalid_ratio})
search_params = {"metric_type": "IP", "params": _params}
collection_w.search(data[-1][0:default_nq], ct.default_sparse_vec_field_name,
search_params, default_limit,
check_task=CheckTasks.err_res,
check_items=error)
self.search(client, collection_name,
data=search_vectors,
anns_field=ct.default_sparse_vec_field_name,
search_params=search_params,
limit=default_limit,
check_task=CheckTasks.err_res,
check_items=error)
@pytest.mark.tags(CaseLabel.L2)
@pytest.mark.parametrize("index", ct.all_index_types[10:12])
def test_sparse_vector_search_output_field(self, index):
@pytest.mark.parametrize("index", ct.sparse_supported_index_types)
def test_sparse_search_after_delete(self, index):
"""
target: create sparse vectors and search
method: create sparse vectors and search
expected: normal search
target: verify deleted entities are excluded from sparse search results
method: 1. create collection, insert 2000 rows, build index and load
2. delete first 1000 rows (int64 in [0..999])
3. search and output int64 field
4. manually assert every returned PK is NOT in the deleted set
expected: no deleted PK appears in any search result across all nq queries
"""
self._connect()
c_name = cf.gen_unique_str(prefix)
schema = cf.gen_default_sparse_schema()
collection_w = self.init_collection_wrap(c_name, schema=schema)
data = cf.gen_default_list_sparse_data(nb=4000)
collection_w.insert(data)
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
nb = 2000
schema = cf.gen_default_sparse_schema(auto_id=False)
self.create_collection(client, collection_name, schema=schema)
data = cf.gen_row_data_by_schema(nb=nb, schema=schema)
self.insert(client, collection_name, data=data)
self.flush(client, collection_name)
params = cf.get_index_params_params(index)
index_params = {"index_type": index, "metric_type": "IP", "params": params}
collection_w.create_index(ct.default_sparse_vec_field_name, index_params, index_name=index)
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_sparse_vec_field_name,
index_type=index, metric_type="IP", params=params)
self.create_index(client, collection_name, index_params=idx)
self.load_collection(client, collection_name)
collection_w.load()
d = cf.gen_default_list_sparse_data(nb=10)
collection_w.search(d[-1][0:default_nq], ct.default_sparse_vec_field_name,
ct.default_sparse_search_params, default_limit,
output_fields=["float", "sparse_vector"],
check_task=CheckTasks.check_search_results,
check_items={"nq": default_nq,
"limit": default_limit,
"output_fields": ["float", "sparse_vector"]
})
# delete first half
delete_ids = list(range(nb // 2))
delete_expr = f"{ct.default_int64_field_name} in {delete_ids}"
self.delete(client, collection_name, filter=delete_expr)
@pytest.mark.tags(CaseLabel.L2)
@pytest.mark.parametrize("index", ct.all_index_types[10:12])
@pytest.mark.parametrize("inverted_index_algo", ct.inverted_index_algo)
def test_sparse_vector_search_iterator(self, index, inverted_index_algo):
"""
target: create sparse vectors and search iterator
method: create sparse vectors and search iterator
expected: normal search
"""
self._connect()
c_name = cf.gen_unique_str(prefix)
schema = cf.gen_default_sparse_schema()
collection_w = self.init_collection_wrap(c_name, schema=schema)
data = cf.gen_default_list_sparse_data(nb=4000)
collection_w.insert(data)
params = cf.get_index_params_params(index)
params.update({"inverted_index_algo": inverted_index_algo})
index_params = {"index_type": index, "metric_type": "IP", "params": params}
collection_w.create_index(ct.default_sparse_vec_field_name, index_params, index_name=index)
collection_w.load()
batch_size = 100
collection_w.search_iterator(data[-1][0:1], ct.default_sparse_vec_field_name,
ct.default_sparse_search_params, limit=500, batch_size=batch_size,
check_task=CheckTasks.check_search_iterator,
check_items={"batch_size": batch_size})
# search and verify deleted PKs not in results
search_vectors = cf.gen_sparse_vectors(default_nq)
search_res, _ = self.search(client, collection_name,
data=search_vectors,
anns_field=ct.default_sparse_vec_field_name,
search_params=ct.default_sparse_search_params,
limit=default_limit,
output_fields=[ct.default_int64_field_name])
assert len(search_res) == default_nq
deleted_set = set(delete_ids)
for hits in search_res:
assert len(hits) > 0
for hit in hits:
assert hit[ct.default_int64_field_name] not in deleted_set, \
f"deleted PK {hit[ct.default_int64_field_name]} found in search results"