mirror of
https://github.com/milvus-io/milvus.git
synced 2026-07-21 02:05:41 +00:00
feat: impl StructArray -- support ARRAY_CONTAINS* (#47172)
issue: https://github.com/milvus-io/milvus/issues/42148 design doc: https://github.com/milvus-io/milvus-design-docs/blob/main/design_docs/20260306-struct.md --------- Signed-off-by: SpadeA <tangchenjie1210@gmail.com>
This commit is contained in:
@@ -152,6 +152,30 @@ ArrayOffsetsSealed::RowOffsetsToElementOffsets(
|
||||
return element_offsets;
|
||||
}
|
||||
|
||||
TargetBitmap
|
||||
ArrayOffsetsSealed::ForEachRowElementRange(
|
||||
const ElementRangePredicate& predicate,
|
||||
int64_t row_start,
|
||||
int64_t row_count) const {
|
||||
AssertInfo(row_start >= 0 && row_start + row_count <= GetRowCount(),
|
||||
"row range out of bounds: row_start={}, row_count={}, "
|
||||
"total_rows={}",
|
||||
row_start,
|
||||
row_count,
|
||||
GetRowCount());
|
||||
|
||||
TargetBitmap result(row_count);
|
||||
|
||||
for (int64_t i = 0; i < row_count; ++i) {
|
||||
int64_t row_id = row_start + i;
|
||||
int32_t elem_start = row_to_element_start_[row_id];
|
||||
int32_t elem_end = row_to_element_start_[row_id + 1];
|
||||
result[i] = predicate(elem_start, elem_end);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
std::shared_ptr<ArrayOffsetsSealed>
|
||||
ArrayOffsetsSealed::BuildFromSegment(const void* segment,
|
||||
const FieldMeta& field_meta) {
|
||||
@@ -378,6 +402,32 @@ ArrayOffsetsGrowing::RowOffsetsToElementOffsets(
|
||||
return element_offsets;
|
||||
}
|
||||
|
||||
TargetBitmap
|
||||
ArrayOffsetsGrowing::ForEachRowElementRange(
|
||||
const ElementRangePredicate& predicate,
|
||||
int64_t row_start,
|
||||
int64_t row_count) const {
|
||||
std::shared_lock lock(mutex_);
|
||||
|
||||
AssertInfo(row_start >= 0 && row_start + row_count <= committed_row_count_,
|
||||
"row range out of bounds: row_start={}, row_count={}, "
|
||||
"committed_rows={}",
|
||||
row_start,
|
||||
row_count,
|
||||
committed_row_count_);
|
||||
|
||||
TargetBitmap result(row_count);
|
||||
|
||||
for (int64_t i = 0; i < row_count; ++i) {
|
||||
int64_t row_id = row_start + i;
|
||||
int32_t elem_start = row_to_element_start_[row_id];
|
||||
int32_t elem_end = row_to_element_start_[row_id + 1];
|
||||
result[i] = predicate(elem_start, elem_end);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
void
|
||||
ArrayOffsetsGrowing::Insert(int64_t row_id_start,
|
||||
const int32_t* array_lengths,
|
||||
|
||||
@@ -71,6 +71,19 @@ class IArrayOffsets {
|
||||
virtual FixedVector<int32_t>
|
||||
RowOffsetsToElementOffsets(
|
||||
const FixedVector<int32_t>& row_offsets) const = 0;
|
||||
|
||||
// Iterate over rows and apply predicate with element range
|
||||
// predicate: function(element_start, element_end) -> bool
|
||||
// element_start: first element ID of the row (inclusive)
|
||||
// element_end: last element ID of the row (exclusive)
|
||||
// Returns: bitmap where bit[i] = predicate result for row (row_start + i)
|
||||
using ElementRangePredicate =
|
||||
std::function<bool(int32_t elem_start, int32_t elem_end)>;
|
||||
|
||||
virtual TargetBitmap
|
||||
ForEachRowElementRange(const ElementRangePredicate& predicate,
|
||||
int64_t row_start,
|
||||
int64_t row_count) const = 0;
|
||||
};
|
||||
|
||||
class ArrayOffsetsSealed : public IArrayOffsets {
|
||||
@@ -122,6 +135,11 @@ class ArrayOffsetsSealed : public IArrayOffsets {
|
||||
RowOffsetsToElementOffsets(
|
||||
const FixedVector<int32_t>& row_offsets) const override;
|
||||
|
||||
TargetBitmap
|
||||
ForEachRowElementRange(const ElementRangePredicate& predicate,
|
||||
int64_t row_start,
|
||||
int64_t row_count) const override;
|
||||
|
||||
static std::shared_ptr<ArrayOffsetsSealed>
|
||||
BuildFromSegment(const void* segment, const FieldMeta& field_meta);
|
||||
|
||||
@@ -169,6 +187,11 @@ class ArrayOffsetsGrowing : public IArrayOffsets {
|
||||
RowOffsetsToElementOffsets(
|
||||
const FixedVector<int32_t>& row_offsets) const override;
|
||||
|
||||
TargetBitmap
|
||||
ForEachRowElementRange(const ElementRangePredicate& predicate,
|
||||
int64_t row_start,
|
||||
int64_t row_count) const override;
|
||||
|
||||
private:
|
||||
struct PendingRow {
|
||||
int64_t row_id;
|
||||
|
||||
@@ -375,6 +375,16 @@ class QueryContext : public Context {
|
||||
return element_level_bitset_.has_value();
|
||||
}
|
||||
|
||||
void
|
||||
set_bitset_is_element_level(bool is_element_level) {
|
||||
bitset_is_element_level_ = is_element_level;
|
||||
}
|
||||
|
||||
bool
|
||||
bitset_is_element_level() const {
|
||||
return bitset_is_element_level_;
|
||||
}
|
||||
|
||||
private:
|
||||
folly::Executor* executor_;
|
||||
//folly::Executor::KeepAlive<> executor_keepalive_;
|
||||
@@ -412,6 +422,9 @@ class QueryContext : public Context {
|
||||
std::shared_ptr<const IArrayOffsets> array_offsets_{nullptr};
|
||||
int64_t active_element_count_{0}; // Total elements in active documents
|
||||
std::optional<TargetBitmap> element_level_bitset_;
|
||||
// Whether the current bitset has been converted to element-level
|
||||
// Set by ElementFilterBitsNode after conversion, checked by VectorSearchNode
|
||||
bool bitset_is_element_level_{false};
|
||||
};
|
||||
|
||||
// Represent the state of one thread of query execution.
|
||||
|
||||
@@ -1523,9 +1523,27 @@ class SegmentExpr : public Expr {
|
||||
return processed_size;
|
||||
}
|
||||
|
||||
// ProcessIndexChunks: execute index query and batch results
|
||||
template <typename T, typename FUNC, typename... ValTypes>
|
||||
VectorPtr
|
||||
ProcessIndexChunks(FUNC func, const ValTypes&... values) {
|
||||
return ProcessIndexChunksImpl<T>(func, false, values...);
|
||||
}
|
||||
|
||||
// ProcessIndexChunks with func_returns_row_level flag
|
||||
// func_returns_row_level: if true, func returns row-level bitset even for nested index
|
||||
// (used when func already handles element-to-row conversion internally)
|
||||
template <typename T, typename FUNC, typename... ValTypes>
|
||||
VectorPtr
|
||||
ProcessIndexChunksWithRowLevel(FUNC func, const ValTypes&... values) {
|
||||
return ProcessIndexChunksImpl<T>(func, true, values...);
|
||||
}
|
||||
|
||||
template <typename T, typename FUNC, typename... ValTypes>
|
||||
VectorPtr
|
||||
ProcessIndexChunksImpl(FUNC func,
|
||||
bool func_returns_row_level,
|
||||
const ValTypes&... values) {
|
||||
typedef std::
|
||||
conditional_t<std::is_same_v<T, std::string_view>, std::string, T>
|
||||
IndexInnerType;
|
||||
@@ -1568,17 +1586,29 @@ class SegmentExpr : public Expr {
|
||||
|
||||
cached_index_chunk_res_ = std::make_shared<TargetBitmap>(
|
||||
std::move(func(index_ptr, values...)));
|
||||
cached_index_chunk_valid_res_ =
|
||||
std::make_shared<TargetBitmap>(index_ptr->IsNotNull());
|
||||
cached_index_chunk_id_ = 0;
|
||||
cached_is_nested_index_ = index_ptr->IsNestedIndex();
|
||||
|
||||
if (cached_is_nested_index_ && func_returns_row_level) {
|
||||
// TODO(SpadeA): now, nested index is only supported for Struct which
|
||||
// does not support null now.
|
||||
cached_index_chunk_valid_res_ =
|
||||
std::make_shared<TargetBitmap>(active_count_, true);
|
||||
} else {
|
||||
cached_index_chunk_valid_res_ =
|
||||
std::make_shared<TargetBitmap>(index_ptr->IsNotNull());
|
||||
}
|
||||
}
|
||||
|
||||
TargetBitmap result;
|
||||
TargetBitmap valid_result;
|
||||
|
||||
if (cached_is_nested_index_) {
|
||||
// Nested index: batch by rows, return corresponding elements
|
||||
// If func already returns row-level bitset, skip element-to-row conversion
|
||||
bool need_element_slicing =
|
||||
cached_is_nested_index_ && !func_returns_row_level;
|
||||
|
||||
if (need_element_slicing) {
|
||||
// Nested index with element-level result: batch by rows, slice elements
|
||||
auto array_offsets = segment_->GetArrayOffsets(field_id_);
|
||||
|
||||
auto data_pos = current_index_chunk_pos_;
|
||||
@@ -1596,7 +1626,7 @@ class SegmentExpr : public Expr {
|
||||
|
||||
current_index_chunk_pos_ = data_pos + batch_rows;
|
||||
} else {
|
||||
// Normal index: batch by rows
|
||||
// Normal index or row-level result: batch by rows directly
|
||||
auto data_pos = current_index_chunk_pos_;
|
||||
auto size =
|
||||
std::min(std::min(size_per_chunk_ - data_pos, batch_size_),
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
#include <boost/container/vector.hpp>
|
||||
#include <folly/FBVector.h>
|
||||
#include <gtest/gtest.h>
|
||||
#include <algorithm>
|
||||
@@ -52,6 +53,8 @@
|
||||
#include "segcore/SegmentGrowingImpl.h"
|
||||
#include "test_utils/DataGen.h"
|
||||
#include "test_utils/GenExprProto.h"
|
||||
#include "test_utils/storage_test_utils.h"
|
||||
#include "test_utils/cachinglayer_test_utils.h"
|
||||
|
||||
using namespace milvus;
|
||||
using namespace milvus::query;
|
||||
@@ -1888,3 +1891,259 @@ TEST(Expr, TestTermInArray) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST(Expr, TestArrayContainsForStruct) {
|
||||
// Step 1: Prepare schema with array field
|
||||
int dim = 4;
|
||||
auto schema = std::make_shared<Schema>();
|
||||
auto vec_fid = schema->AddDebugVectorArrayField("structA[array_float_vec]",
|
||||
DataType::VECTOR_FLOAT,
|
||||
dim,
|
||||
knowhere::metric::L2);
|
||||
auto int_array_fid = schema->AddDebugArrayField(
|
||||
"structA[price_array]", DataType::INT32, false);
|
||||
|
||||
auto int64_fid = schema->AddDebugField("id", DataType::INT64);
|
||||
schema->set_primary_field_id(int64_fid);
|
||||
|
||||
size_t N = 500;
|
||||
int array_len = 3;
|
||||
|
||||
// Step 2: Generate test data
|
||||
auto raw_data = DataGen(schema, N, 42, 0, 1, array_len);
|
||||
|
||||
// Use random values between 1-20 for array elements
|
||||
std::mt19937 rng(42); // Fixed seed for reproducibility
|
||||
std::uniform_int_distribution<int> dist(1, 20);
|
||||
|
||||
// Track which rows contain value 5 for verification
|
||||
std::set<int> rows_containing_5;
|
||||
|
||||
for (int i = 0; i < raw_data.raw_->fields_data_size(); i++) {
|
||||
auto* field_data = raw_data.raw_->mutable_fields_data(i);
|
||||
if (field_data->field_id() == int_array_fid.get()) {
|
||||
field_data->mutable_scalars()
|
||||
->mutable_array_data()
|
||||
->mutable_data()
|
||||
->Clear();
|
||||
|
||||
for (int row = 0; row < N; row++) {
|
||||
auto* array_data = field_data->mutable_scalars()
|
||||
->mutable_array_data()
|
||||
->mutable_data()
|
||||
->Add();
|
||||
|
||||
for (int elem = 0; elem < array_len; elem++) {
|
||||
int value = dist(rng); // Random value between 1-20
|
||||
array_data->mutable_int_data()->mutable_data()->Add(value);
|
||||
if (value == 5) {
|
||||
rows_containing_5.insert(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Create sealed segment with field data
|
||||
auto segment = CreateSealedWithFieldDataLoaded(schema, raw_data);
|
||||
|
||||
// Step 4: Load vector index for element-level search
|
||||
auto array_vec_values = raw_data.get_col<VectorFieldProto>(vec_fid);
|
||||
|
||||
// DataGen generates VECTOR_ARRAY with data in float_vector (flattened),
|
||||
// not in vector_array (nested structure)
|
||||
std::vector<float> vector_data(dim * N * array_len);
|
||||
for (int i = 0; i < N; i++) {
|
||||
const auto& float_vec = array_vec_values[i].float_vector().data();
|
||||
// float_vec contains array_len * dim floats
|
||||
for (int j = 0; j < array_len * dim; j++) {
|
||||
vector_data[i * array_len * dim + j] = float_vec[j];
|
||||
}
|
||||
}
|
||||
|
||||
// For element-level search, index all elements (N * array_len vectors)
|
||||
auto indexing = GenVecIndexing(N * array_len,
|
||||
dim,
|
||||
vector_data.data(),
|
||||
knowhere::IndexEnum::INDEX_HNSW);
|
||||
LoadIndexInfo load_index_info;
|
||||
load_index_info.field_id = vec_fid.get();
|
||||
load_index_info.index_params = GenIndexParams(indexing.get());
|
||||
load_index_info.cache_index =
|
||||
CreateTestCacheIndex("test", std::move(indexing));
|
||||
load_index_info.index_params["metric_type"] = knowhere::metric::L2;
|
||||
load_index_info.field_type = DataType::VECTOR_ARRAY;
|
||||
load_index_info.element_type = DataType::VECTOR_FLOAT;
|
||||
segment->LoadIndex(load_index_info);
|
||||
|
||||
int topK = 5;
|
||||
|
||||
ScopedSchemaHandle schema_handle(*schema);
|
||||
|
||||
// Step 5a: Test with array_contains_any filter
|
||||
{
|
||||
std::string expr = "array_contains_any(structA[price_array], [5])";
|
||||
|
||||
auto plan_bytes = schema_handle.ParseSearch(
|
||||
expr, "structA[array_float_vec]", topK, "L2", R"({"ef": 50})", 3);
|
||||
auto plan = CreateSearchPlanByExpr(
|
||||
schema, plan_bytes.data(), plan_bytes.size());
|
||||
ASSERT_NE(plan, nullptr);
|
||||
|
||||
auto num_queries = 1;
|
||||
auto seed = 1024;
|
||||
auto ph_group_raw =
|
||||
CreatePlaceholderGroup(num_queries, dim, seed, true);
|
||||
auto ph_group =
|
||||
ParsePlaceholderGroup(plan.get(), ph_group_raw.SerializeAsString());
|
||||
|
||||
auto search_result =
|
||||
segment->Search(plan.get(), ph_group.get(), 1L << 63);
|
||||
|
||||
// Verify results
|
||||
ASSERT_NE(search_result, nullptr);
|
||||
|
||||
// Array contains is a regular filter, not element-level search
|
||||
ASSERT_FALSE(search_result->seg_offsets_.empty());
|
||||
|
||||
// Should have topK results per query
|
||||
ASSERT_LE(search_result->seg_offsets_.size(),
|
||||
static_cast<size_t>(topK * num_queries));
|
||||
|
||||
for (size_t i = 0; i < search_result->seg_offsets_.size(); i++) {
|
||||
int64_t doc_id = search_result->seg_offsets_[i];
|
||||
float distance = search_result->distances_[i];
|
||||
|
||||
// Verify the doc's array contains value 5 using our tracked set
|
||||
ASSERT_TRUE(rows_containing_5.count(doc_id) > 0)
|
||||
<< "Result doc_id " << doc_id << " should contain value 5";
|
||||
}
|
||||
|
||||
// Verify distances are sorted (ascending for L2)
|
||||
for (size_t i = 1; i < search_result->distances_.size(); ++i) {
|
||||
ASSERT_LE(search_result->distances_[i - 1],
|
||||
search_result->distances_[i])
|
||||
<< "Distances should be sorted in ascending order";
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5b: Test with array_contains_all filter
|
||||
// contains_all requires the array to contain ALL given elements
|
||||
{
|
||||
std::string expr = "array_contains_all(structA[price_array], [5, 10])";
|
||||
|
||||
auto plan_bytes = schema_handle.ParseSearch(
|
||||
expr, "structA[array_float_vec]", topK, "L2", R"({"ef": 50})", 3);
|
||||
auto plan = CreateSearchPlanByExpr(
|
||||
schema, plan_bytes.data(), plan_bytes.size());
|
||||
ASSERT_NE(plan, nullptr);
|
||||
|
||||
auto num_queries = 1;
|
||||
auto seed = 1024;
|
||||
auto ph_group_raw =
|
||||
CreatePlaceholderGroup(num_queries, dim, seed, true);
|
||||
auto ph_group =
|
||||
ParsePlaceholderGroup(plan.get(), ph_group_raw.SerializeAsString());
|
||||
|
||||
auto search_result =
|
||||
segment->Search(plan.get(), ph_group.get(), 1L << 63);
|
||||
|
||||
ASSERT_NE(search_result, nullptr);
|
||||
|
||||
// Verify each result's array contains BOTH 5 and 10
|
||||
auto array_col =
|
||||
raw_data.get_col(int_array_fid)->scalars().array_data().data();
|
||||
for (size_t i = 0; i < search_result->seg_offsets_.size(); i++) {
|
||||
int64_t doc_id = search_result->seg_offsets_[i];
|
||||
float distance = search_result->distances_[i];
|
||||
|
||||
auto& arr = array_col[doc_id].int_data().data();
|
||||
bool has_5 = std::find(arr.begin(), arr.end(), 5) != arr.end();
|
||||
bool has_10 = std::find(arr.begin(), arr.end(), 10) != arr.end();
|
||||
ASSERT_TRUE(has_5 && has_10) << "Result doc_id " << doc_id
|
||||
<< " should contain both 5 and 10";
|
||||
}
|
||||
|
||||
for (size_t i = 1; i < search_result->distances_.size(); ++i) {
|
||||
ASSERT_LE(search_result->distances_[i - 1],
|
||||
search_result->distances_[i])
|
||||
<< "Distances should be sorted in ascending order";
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: Test with scalar index on price_array field
|
||||
{
|
||||
// Get array data from raw_data and convert to boost::container::vector format
|
||||
// (required by InvertedIndexTantivy::BuildWithRawDataForUT)
|
||||
auto array_col =
|
||||
raw_data.get_col(int_array_fid)->scalars().array_data().data();
|
||||
std::vector<boost::container::vector<int32_t>> vec_of_array;
|
||||
vec_of_array.reserve(N);
|
||||
for (size_t i = 0; i < N; i++) {
|
||||
boost::container::vector<int32_t> arr;
|
||||
for (size_t j = 0; j < array_col[i].int_data().data_size(); j++) {
|
||||
arr.push_back(array_col[i].int_data().data(j));
|
||||
}
|
||||
vec_of_array.push_back(arr);
|
||||
}
|
||||
|
||||
// Build inverted index using simplified API
|
||||
auto arr_index =
|
||||
std::make_unique<index::InvertedIndexTantivy<int32_t>>();
|
||||
Config cfg;
|
||||
cfg["is_array"] = true;
|
||||
cfg["is_nested_index"] = true;
|
||||
arr_index->BuildWithRawDataForUT(N, vec_of_array.data(), cfg);
|
||||
|
||||
// Load index into segment
|
||||
LoadIndexInfo arr_index_info;
|
||||
arr_index_info.field_id = int_array_fid.get();
|
||||
arr_index_info.index_params = GenIndexParams(arr_index.get());
|
||||
arr_index_info.cache_index =
|
||||
CreateTestCacheIndex("test_array", std::move(arr_index));
|
||||
segment->LoadIndex(arr_index_info);
|
||||
|
||||
// Now search with index
|
||||
std::string expr = "array_contains_any(structA[price_array], [5])";
|
||||
|
||||
auto plan_bytes = schema_handle.ParseSearch(
|
||||
expr, "structA[array_float_vec]", topK, "L2", R"({"ef": 50})", 3);
|
||||
auto plan = CreateSearchPlanByExpr(
|
||||
schema, plan_bytes.data(), plan_bytes.size());
|
||||
ASSERT_NE(plan, nullptr);
|
||||
|
||||
auto num_queries = 1;
|
||||
auto seed = 1024;
|
||||
auto ph_group_raw =
|
||||
CreatePlaceholderGroup(num_queries, dim, seed, true);
|
||||
auto ph_group =
|
||||
ParsePlaceholderGroup(plan.get(), ph_group_raw.SerializeAsString());
|
||||
|
||||
auto search_result =
|
||||
segment->Search(plan.get(), ph_group.get(), 1L << 63);
|
||||
|
||||
// Verify results with index
|
||||
ASSERT_NE(search_result, nullptr);
|
||||
ASSERT_FALSE(search_result->seg_offsets_.empty());
|
||||
ASSERT_LE(search_result->seg_offsets_.size(),
|
||||
static_cast<size_t>(topK * num_queries));
|
||||
|
||||
for (size_t i = 0; i < search_result->seg_offsets_.size(); i++) {
|
||||
int64_t doc_id = search_result->seg_offsets_[i];
|
||||
float distance = search_result->distances_[i];
|
||||
|
||||
// Verify the doc's array contains value 5
|
||||
ASSERT_TRUE(rows_containing_5.count(doc_id) > 0)
|
||||
<< "Result doc_id " << doc_id
|
||||
<< " should contain value 5 (with index)";
|
||||
}
|
||||
|
||||
// Verify distances are sorted (ascending for L2)
|
||||
for (size_t i = 1; i < search_result->distances_.size(); ++i) {
|
||||
ASSERT_LE(search_result->distances_[i - 1],
|
||||
search_result->distances_[i])
|
||||
<< "Distances should be sorted in ascending order (with index)";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2120,31 +2120,60 @@ PhyJsonContainsFilterExpr::ExecArrayContainsForIndexSegmentImpl() {
|
||||
elements.insert(GetValueWithCastNumber<GetType>(element));
|
||||
}
|
||||
boost::container::vector<GetType> elems(elements.begin(), elements.end());
|
||||
|
||||
// Get array offsets for nested index (needed for element-to-row conversion)
|
||||
auto array_offsets = segment_->GetArrayOffsets(expr_->column_.field_id_);
|
||||
|
||||
auto execute_sub_batch =
|
||||
[this](Index* index_ptr,
|
||||
const boost::container::vector<GetType>& vals) {
|
||||
switch (expr_->op_) {
|
||||
case proto::plan::JSONContainsExpr_JSONOp_Contains:
|
||||
case proto::plan::JSONContainsExpr_JSONOp_ContainsAny: {
|
||||
return index_ptr->In(vals.size(), vals.data());
|
||||
}
|
||||
case proto::plan::JSONContainsExpr_JSONOp_ContainsAll: {
|
||||
TargetBitmap result(index_ptr->Count());
|
||||
result.set();
|
||||
for (size_t i = 0; i < vals.size(); i++) {
|
||||
auto sub = index_ptr->In(1, &vals[i]);
|
||||
result &= sub;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
default:
|
||||
ThrowInfo(
|
||||
ExprInvalid,
|
||||
"unsupported array contains type {}",
|
||||
proto::plan::JSONContainsExpr_JSONOp_Name(expr_->op_));
|
||||
[this, &array_offsets](
|
||||
Index* index_ptr,
|
||||
const boost::container::vector<GetType>& vals) -> TargetBitmap {
|
||||
// Query helper: for nested index, convert element-level to row-level
|
||||
auto query_in = [&](size_t n, const GetType* data) -> TargetBitmap {
|
||||
auto element_bitset = index_ptr->In(n, data);
|
||||
if (!index_ptr->IsNestedIndex()) {
|
||||
return element_bitset;
|
||||
}
|
||||
AssertInfo(array_offsets != nullptr,
|
||||
"array offsets not found for field {}",
|
||||
expr_->column_.field_id_.get());
|
||||
return array_offsets->ForEachRowElementRange(
|
||||
[&element_bitset](int32_t elem_start, int32_t elem_end) {
|
||||
for (int32_t i = elem_start; i < elem_end; ++i) {
|
||||
if (element_bitset[i]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
0,
|
||||
active_count_);
|
||||
};
|
||||
auto res = ProcessIndexChunks<GetType>(execute_sub_batch, elems);
|
||||
|
||||
switch (expr_->op_) {
|
||||
case proto::plan::JSONContainsExpr_JSONOp_Contains:
|
||||
case proto::plan::JSONContainsExpr_JSONOp_ContainsAny:
|
||||
return query_in(vals.size(), vals.data());
|
||||
|
||||
case proto::plan::JSONContainsExpr_JSONOp_ContainsAll: {
|
||||
TargetBitmap result(active_count_);
|
||||
result.set();
|
||||
for (size_t i = 0; i < vals.size(); i++) {
|
||||
result &= query_in(1, &vals[i]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
default:
|
||||
ThrowInfo(
|
||||
ExprInvalid,
|
||||
"unsupported array contains type {}",
|
||||
proto::plan::JSONContainsExpr_JSONOp_Name(expr_->op_));
|
||||
}
|
||||
};
|
||||
|
||||
// Use WithRowLevel version since func handles element-to-row conversion for nested index
|
||||
auto res =
|
||||
ProcessIndexChunksWithRowLevel<GetType>(execute_sub_batch, elems);
|
||||
AssertInfo(res->size() == real_batch_size,
|
||||
"internal error: expr processed rows {} not equal "
|
||||
"expect batch size {}",
|
||||
|
||||
@@ -120,6 +120,8 @@ PhyElementFilterBitsNode::GetOutput() {
|
||||
// Step 4: Set query context
|
||||
query_context_->set_element_level_query(true);
|
||||
query_context_->set_struct_name(struct_name_);
|
||||
// Mark that bitset has been converted to element-level
|
||||
query_context_->set_bitset_is_element_level(true);
|
||||
|
||||
std::chrono::high_resolution_clock::time_point end_time =
|
||||
std::chrono::high_resolution_clock::now();
|
||||
|
||||
@@ -118,6 +118,7 @@ PhyFilterBitsNode::GetOutput() {
|
||||
}
|
||||
}
|
||||
bitset.flip();
|
||||
|
||||
AssertInfo(bitset.size() == need_process_rows_,
|
||||
"bitset size: {}, need_process_rows_: {}",
|
||||
bitset.size(),
|
||||
|
||||
@@ -115,8 +115,10 @@ PhyVectorSearchNode::GetOutput() {
|
||||
//
|
||||
// When embedding search embedding on embedding list is used, which means element_level_ is true, we need to transform doc-level
|
||||
// bitset to element-level bitset. In pre-filter path, ElementFilterBitsNode already transforms the bitset. We need to transform it
|
||||
// in iterative filter path.
|
||||
if (milvus::exec::UseVectorIterator(search_info_) && ph.element_level_) {
|
||||
// in iterative filter path or when ElementFilterBitsNode is not present (e.g., only doc-level filter without element-level filter).
|
||||
//
|
||||
// Check if bitset needs conversion: element_level search is requested but bitset hasn't been converted yet
|
||||
if (ph.element_level_ && !query_context_->bitset_is_element_level()) {
|
||||
auto col_input = GetColumnVector(input_);
|
||||
TargetBitmapView view(col_input->GetRawData(), col_input->size());
|
||||
TargetBitmapView valid_view(col_input->GetValidRawData(),
|
||||
|
||||
@@ -339,6 +339,13 @@ InvertedIndexTantivy<T>::IsNull() {
|
||||
int64_t count = Count();
|
||||
TargetBitmap bitset(count);
|
||||
|
||||
// For nested index, null rows don't have elements in the index,
|
||||
// so all elements in the index are valid (none are null).
|
||||
// null_offset_ stores row offsets, not element offsets.
|
||||
if (is_nested_index_) {
|
||||
return bitset; // All false - no element is null
|
||||
}
|
||||
|
||||
auto fill_bitset = [this, count, &bitset]() {
|
||||
auto end =
|
||||
std::lower_bound(null_offset_.begin(), null_offset_.end(), count);
|
||||
@@ -365,6 +372,13 @@ InvertedIndexTantivy<T>::IsNotNull() {
|
||||
int64_t count = Count();
|
||||
TargetBitmap bitset(count, true);
|
||||
|
||||
// For nested index, null rows don't have elements in the index,
|
||||
// so all elements in the index are valid.
|
||||
// null_offset_ stores row offsets, not element offsets.
|
||||
if (is_nested_index_) {
|
||||
return bitset; // All true - all elements are valid
|
||||
}
|
||||
|
||||
auto fill_bitset = [this, count, &bitset]() {
|
||||
auto end =
|
||||
std::lower_bound(null_offset_.begin(), null_offset_.end(), count);
|
||||
|
||||
@@ -9,6 +9,7 @@ expr:
|
||||
| StringLiteral # String
|
||||
| (Identifier|Meta) # Identifier
|
||||
| JSONIdentifier # JSONIdentifier
|
||||
| StructFieldIdentifier # StructField
|
||||
| StructSubFieldIdentifier # StructSubField
|
||||
| LBRACE Identifier RBRACE # TemplateVariable
|
||||
| '(' expr ')' # Parens
|
||||
@@ -139,6 +140,7 @@ Meta: '$meta';
|
||||
|
||||
StringLiteral: EncodingPrefix? ('"' DoubleSCharSequence? '"' | '\'' SingleSCharSequence? '\'');
|
||||
JSONIdentifier: (Identifier | Meta)('[' (StringLiteral | DecimalConstant) ']')+;
|
||||
StructFieldIdentifier: Identifier '[' Identifier ']';
|
||||
StructSubFieldIdentifier: '$[' Identifier ']';
|
||||
|
||||
fragment EncodingPrefix: 'u8' | 'u' | 'U' | 'L';
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -69,9 +69,10 @@ Identifier=68
|
||||
Meta=69
|
||||
StringLiteral=70
|
||||
JSONIdentifier=71
|
||||
StructSubFieldIdentifier=72
|
||||
Whitespace=73
|
||||
Newline=74
|
||||
StructFieldIdentifier=72
|
||||
StructSubFieldIdentifier=73
|
||||
Whitespace=74
|
||||
Newline=75
|
||||
'('=1
|
||||
')'=2
|
||||
'['=3
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -69,9 +69,10 @@ Identifier=68
|
||||
Meta=69
|
||||
StringLiteral=70
|
||||
JSONIdentifier=71
|
||||
StructSubFieldIdentifier=72
|
||||
Whitespace=73
|
||||
Newline=74
|
||||
StructFieldIdentifier=72
|
||||
StructSubFieldIdentifier=73
|
||||
Whitespace=74
|
||||
Newline=75
|
||||
'('=1
|
||||
')'=2
|
||||
'['=3
|
||||
|
||||
@@ -51,6 +51,10 @@ func (v *BasePlanVisitor) VisitIdentifier(ctx *IdentifierContext) interface{} {
|
||||
return v.VisitChildren(ctx)
|
||||
}
|
||||
|
||||
func (v *BasePlanVisitor) VisitStructField(ctx *StructFieldContext) interface{} {
|
||||
return v.VisitChildren(ctx)
|
||||
}
|
||||
|
||||
func (v *BasePlanVisitor) VisitLike(ctx *LikeContext) interface{} {
|
||||
return v.VisitChildren(ctx)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,9 @@ type PlanVisitor interface {
|
||||
// Visit a parse tree produced by PlanParser#Identifier.
|
||||
VisitIdentifier(ctx *IdentifierContext) interface{}
|
||||
|
||||
// Visit a parse tree produced by PlanParser#StructField.
|
||||
VisitStructField(ctx *StructFieldContext) interface{}
|
||||
|
||||
// Visit a parse tree produced by PlanParser#Like.
|
||||
VisitLike(ctx *LikeContext) interface{}
|
||||
|
||||
|
||||
@@ -1400,6 +1400,34 @@ func (v *ParserVisitor) VisitJSONIdentifier(ctx *parser.JSONIdentifierContext) i
|
||||
}
|
||||
}
|
||||
|
||||
// VisitStructField handles struct_array[sub_field] syntax for struct sub-field access.
|
||||
func (v *ParserVisitor) VisitStructField(ctx *parser.StructFieldContext) interface{} {
|
||||
// Get the full identifier text, e.g., "struct_array[sub_int]"
|
||||
identifier := ctx.StructFieldIdentifier().GetText()
|
||||
|
||||
// Look up the field directly by its full name
|
||||
field, err := v.schema.GetFieldFromName(identifier)
|
||||
if err != nil {
|
||||
return fmt.Errorf("struct field not found: %s, error: %s", identifier, err)
|
||||
}
|
||||
|
||||
return &ExprWithType{
|
||||
expr: &planpb.Expr{
|
||||
Expr: &planpb.Expr_ColumnExpr{
|
||||
ColumnExpr: &planpb.ColumnExpr{
|
||||
Info: &planpb.ColumnInfo{
|
||||
FieldId: field.FieldID,
|
||||
DataType: field.DataType,
|
||||
ElementType: field.GetElementType(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
dataType: field.DataType,
|
||||
nodeDependent: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (v *ParserVisitor) VisitExists(ctx *parser.ExistsContext) interface{} {
|
||||
child := ctx.Expr().Accept(v)
|
||||
if err := getError(child); err != nil {
|
||||
|
||||
@@ -2717,6 +2717,22 @@ func TestExpr_Match(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpr_ArrayContains(t *testing.T) {
|
||||
schema := newTestSchema(true)
|
||||
helper, err := typeutil.CreateSchemaHelper(schema)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Valid ArrayContains expressions
|
||||
validExprs := []string{
|
||||
`array_contains(struct_array[sub_int], 1)`,
|
||||
`array_contains(struct_array[sub_int], 1) && array_contains(struct_array[sub_int], 2)`,
|
||||
}
|
||||
|
||||
for _, expr := range validExprs {
|
||||
assertValidExpr(t, helper, expr)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Timestamptz Expression Tests
|
||||
// These tests cover VisitTimestamptzCompareForward and VisitTimestamptzCompareReverse
|
||||
|
||||
Reference in New Issue
Block a user