Skip to content

feat(sindi): add date-window filtering - #2500

Draft
Roxanne0321 wants to merge 1 commit into
antgroup:mainfrom
Roxanne0321:feat/sindi-date-filtering
Draft

feat(sindi): add date-window filtering#2500
Roxanne0321 wants to merge 1 commit into
antgroup:mainfrom
Roxanne0321:feat/sindi-date-filtering

Conversation

@Roxanne0321

Copy link
Copy Markdown
Collaborator

Change Type

  • Bug fix
  • New feature
  • Improvement/Refactor
  • Documentation
  • CI/Build/Infra

Linked Issue

What Changed

  • Add optional per-document date labels through Dataset::Dates, supporting YYYY, YYYY/MM,
    and YYYY/MM/DD calendar forms.
  • Group equal date labels into non-overlapping SINDI windows while preserving window_size;
    datasets without date metadata continue to use undated windows.
  • Add hierarchical date-window selection to KNN and range search, combined with existing filters
    using AND semantics.
  • Preserve date metadata across normal and streaming serialization, including compatibility with
    legacy serialized indexes.
  • Cover mutable and immutable indexes, incremental add, quantization, term-ID remapping,
    reordering, analyzer paths, and public documentation/examples.

Test Evidence

  • make fmt
  • make lint
  • make test
  • make cov, run tests, and collect coverage
  • Other (describe below)

Test details:

clang-format-15 <modified C++ files>
  clang-format version 15.0.7

git diff --check
  passed

cmake --build build --parallel 2
  passed

./build/tests/unittest "[date]"
  All tests passed (116 assertions in 5 test cases)

./build/tests/unittest "[SINDI]"
  All tests passed (184477 assertions in 22 test cases)

./build/tests/unittest "SINDI Date Quantization And Remap Test"
  All tests passed (14 assertions in 1 test case)

clang-tidy was not run because this environment provides clang-tidy 13, while VSAG requires
clang-tidy 15 exactly.

Compatibility Impact

  • API/ABI compatibility: additive public Dataset date accessors; no existing API was removed.
  • Behavior changes: SINDI searches only date-matching windows when a non-empty date selector is
    supplied. Missing or empty selectors retain the previous search-all behavior.

Performance and Concurrency Impact

  • Performance impact: date-scoped queries skip unrelated windows; build grouping adds linear
    bookkeeping proportional to the document count.
  • Concurrency/thread-safety impact: none; existing SINDI locking and read paths are retained.

Documentation Impact

  • No docs update needed
  • Updated docs:
    • README.md
    • DEVELOPMENT.md
    • CONTRIBUTING.md
    • Other: English and Chinese Dataset, SINDI, and serialization documentation plus the SINDI
      C++ example.

Risk and Rollback

  • Risk level: medium
  • Rollback plan: revert commit 4cc2841a to remove the additive Dataset API and SINDI date-window
    behavior together.

Checklist

  • I have linked the relevant issue (required for kind/bug and kind/feature; see "Linked Issue" above)
  • I have added/updated tests for new behavior or bug fixes
  • I have considered API compatibility impact
  • I have updated docs if behavior/workflow changed
  • My commit messages follow project conventions (Conventional Commits, optional [skip ci] prefix)

Signed-off-by: Roxanne0321 <liruoxvan020321@qq.com>
Assisted-by: Codex:gpt-5
Copilot AI review requested due to automatic review settings July 21, 2026 11:27
@vsag-bot

vsag-bot commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

/label status/blocked
/waiting-on author

@vsag-bot

vsag-bot commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Automated pull request review completed.

Review effort: high (1253 changed lines across 24 files).

Submitted 4 inline comments.
Review: #2500 (review)

@mergify

mergify Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Merge Protections

🟢 All 3 merge protections satisfied — ready to merge.

Show 3 satisfied protections

🟢 Require kind label

  • label~=^kind/

🟢 Require version label

  • label~=^version/

🟢 Require linked issue for feature/bug PRs

  • body~=(?im)(?:^|[\s\-\*])(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s+(?:#\d+|[\w.\-]+/[\w.\-]+#\d+|https?://github\.com/[\w.\-]+/[\w.\-]+/issues/\d+)

@Roxanne0321 Roxanne0321 added kind/feature Brand-new functionality or capabilities 引入全新的功能、新特性或新能力 version/1.0 labels Jul 21, 2026 — with ChatGPT Codex Connector

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces date-window filtering for online queries in the SINDI sparse vector index by adding support for per-element date labels, updating window grouping and metadata management, and implementing date-based pruning during searches. The review feedback identifies a critical performance bottleneck in sindi_analyzer.cpp where a linear search (std::find) is used repeatedly inside a loop, resulting in O(N^2) complexity. The reviewer suggests optimizing this by building a hash map (std::unordered_map) of labels to indices once to achieve O(1) lookups.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +391 to 403
auto base_idx = static_cast<int64_t>(inner_id);
const auto label = sindi_->label_table_->GetLabelById(inner_id);
if (base_dataset->GetIds() != nullptr) {
const auto* begin = base_dataset->GetIds();
const auto* end = begin + base_dataset->GetNumElements();
const auto* iter = std::find(begin, end, label);
if (iter == end) {
return false;
}
base_idx = iter - begin;
}
copy_sparse_vector(base_dataset->GetSparseVectors()[base_idx], data, allocator);
return true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using std::find to perform a linear search over base_dataset->GetIds() introduces an $O(N)$ complexity bottleneck for every call to get_original_sparse_vector_by_inner_id. When called repeatedly (e.g., in calculate_ground_truth or get_mean_doc_retained which can perform up to $N$ or $Q \cdot N$ lookups), this results in an $O(N^2)$ or $O(Q \cdot N^2)$ complexity, causing severe performance degradation or timeouts on large datasets.

To optimize this, we should build a hash map (std::unordered_map<int64_t, int64_t>) mapping each label to its corresponding index in base_dataset once, and use it for $O(1)$ lookups.

Comment on lines +444 to 460
std::vector<InnerIdType> matching_inner_ids;
matching_inner_ids.reserve(static_cast<uint64_t>(sindi_->cur_element_count_.load()));
for (uint64_t raw_inner_id = 0;
raw_inner_id < static_cast<uint64_t>(sindi_->cur_element_count_.load());
++raw_inner_id) {
const auto inner_id = static_cast<InnerIdType>(raw_inner_id);
const auto window_id = sindi_->find_window_id(inner_id);
if (SINDIDateMatches(sindi_->window_metadata_[window_id].date, search_param.date)) {
matching_inner_ids.push_back(inner_id);
}
}

auto query_count = query_dataset->GetNumElements();
auto base_count = base_dataset == nullptr ? sindi_->cur_element_count_.load()
: base_dataset->GetNumElements();
auto effective_topk = std::min<int64_t>(topk, base_count);
auto effective_topk = std::min<int64_t>(topk, static_cast<int64_t>(matching_inner_ids.size()));
if (effective_topk <= 0) {
return nullptr;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

To avoid the $O(N)$ linear search bottleneck in get_original_sparse_vector_by_inner_id during ground truth calculation, we can build a std::unordered_map mapping each label to its corresponding index in base_dataset once at the beginning of calculate_ground_truth.

    std::vector<InnerIdType> matching_inner_ids;
    matching_inner_ids.reserve(static_cast<uint64_t>(sindi_->cur_element_count_.load()));
    for (uint64_t raw_inner_id = 0; 
         raw_inner_id < static_cast<uint64_t>(sindi_->cur_element_count_.load()); 
         ++raw_inner_id) {
        const auto inner_id = static_cast<InnerIdType>(raw_inner_id);
        const auto window_id = sindi_->find_window_id(inner_id);
        if (SINDIDateMatches(sindi_->window_metadata_[window_id].date, search_param.date)) {
            matching_inner_ids.push_back(inner_id);
        }
    }

    std::unordered_map<int64_t, int64_t> label_to_base_idx;
    if (base_dataset != nullptr && base_dataset->GetIds() != nullptr) {
        label_to_base_idx.reserve(base_dataset->GetNumElements());
        for (int64_t i = 0; i < base_dataset->GetNumElements(); ++i) {
            label_to_base_idx[base_dataset->GetIds()[i]] = i;
        }
    }

    auto query_count = query_dataset->GetNumElements();
    auto effective_topk = std::min<int64_t>(topk, static_cast<int64_t>(matching_inner_ids.size()));
    if (effective_topk <= 0) {
        return nullptr;
    }

Comment on lines +470 to 481
for (const auto inner_id : matching_inner_ids) {
SparseVector original{};
const SparseVector* base_vector = nullptr;
if (base_dataset != nullptr && base_dataset->GetSparseVectors() != nullptr) {
base_vector = base_dataset->GetSparseVectors() + base_idx;
} else {
sindi_->GetSparseVectorByInnerId(base_idx, &original, sindi_->allocator_);
base_vector = &original;
if (not get_original_sparse_vector_by_inner_id(
inner_id, &original, sindi_->allocator_, base_dataset)) {
sindi_->allocator_->Deallocate(ids);
sindi_->allocator_->Deallocate(distances);
return nullptr;
}

auto label = base_dataset != nullptr && base_dataset->GetIds() != nullptr
? base_dataset->GetIds()[base_idx]
: sindi_->label_table_->GetLabelById(base_idx);
heap.emplace(get_sparse_distance(query, *base_vector), label);
auto label = sindi_->label_table_->GetLabelById(inner_id);
heap.emplace(get_sparse_distance(query, original), label);
if (static_cast<int64_t>(heap.size()) > effective_topk) {
heap.pop();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Use the pre-built label_to_base_idx map to retrieve the original sparse vector in $O(1)$ time, completely avoiding the $O(N)$ linear search per element.

        for (const auto inner_id : matching_inner_ids) {
            SparseVector original{};
            bool success = false;
            if (sindi_->use_reorder_) {
                sindi_->GetSparseVectorByInnerId(inner_id, &original, sindi_->allocator_);
                success = true;
            } else if (base_dataset != nullptr && base_dataset->GetSparseVectors() != nullptr) {
                auto label = sindi_->label_table_->GetLabelById(inner_id);
                int64_t base_idx = inner_id;
                if (base_dataset->GetIds() != nullptr) {
                    auto iter = label_to_base_idx.find(label);
                    if (iter != label_to_base_idx.end()) {
                        base_idx = iter->second;
                        success = true;
                    }
                } else if (inner_id < base_dataset->GetNumElements()) {
                    success = true;
                }
                if (success) {
                    copy_sparse_vector(base_dataset->GetSparseVectors()[base_idx], &original, sindi_->allocator_);
                }
            }
            if (not success) {
                sindi_->allocator_->Deallocate(ids);
                sindi_->allocator_->Deallocate(distances);
                return nullptr;
            }
            auto label = sindi_->label_table_->GetLabelById(inner_id);
            heap.emplace(get_sparse_distance(query, original), label);
            if (static_cast<int64_t>(heap.size()) > effective_topk) {
                heap.pop();

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds optional per-document date labels to Dataset and extends the SINDI sparse index to group documents into date-homogeneous windows, enabling hierarchical (year/month/day) date-window pruning at query time while maintaining legacy serialization compatibility.

Changes:

  • Add Dataset::Dates() / Dataset::GetDates() and propagate date labels through deep copy / append and serialization.
  • Introduce SINDI window metadata (date, start id, doc count) to support date-grouped windows and date-pruned KNN/range search.
  • Update tests, examples, and English/Chinese docs to cover date validation, search semantics, and serialization behavior.

Reviewed changes

Copilot reviewed 24 out of 24 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/storage/serialization_tags.h Adds a new streaming tag for SINDI window metadata blocks.
src/inner_string_params.h Introduces the "date" search parameter key for SINDI.
src/dataset_impl.h Implements Dates() / GetDates() in the concrete dataset implementation.
src/dataset_impl.cpp Ensures date labels are released, deep-copied, and appended with dataset merges.
src/dataset_impl_test.cpp Adds unit tests covering date label set/get, deep copy, and append behavior.
src/constants.cpp Defines the DATASET_DATES internal key string.
src/analyzer/sindi_analyzer.h Updates analyzer ground-truth API to accept search parameters (incl. date selector).
src/analyzer/sindi_analyzer.cpp Applies date-window pruning in analyzer paths and updates ground-truth computation.
src/algorithm/sindi/sindi.h Introduces SINDIWindowMetadata and new metadata/serialization helpers on the index.
src/algorithm/sindi/sindi.cpp Implements date grouping into windows, date-pruned search, and metadata (de)serialization + legacy inference.
src/algorithm/sindi/sindi_test.cpp Adds coverage for date-window search semantics, add behavior, and serialization compatibility.
src/algorithm/sindi/sindi_parameter.h Adds date validation/matching helpers and a date field in SINDISearchParameter.
src/algorithm/sindi/sindi_parameter.cpp Implements date parsing/validation and hierarchical matching; plumbs date through JSON.
src/algorithm/sindi/sindi_parameter_test.cpp Adds tests for date parsing, validation, and hierarchical matching.
README.md Notes SINDI’s new date-window filtering capability at a high level.
include/vsag/dataset.h Adds the public Dataset date-label accessor API.
include/vsag/constants.h Exposes DATASET_DATES as a public constant.
examples/cpp/109_index_sindi.cpp Demonstrates supplying dates on build and using the date search parameter.
docs/docs/zh/src/indexes/sindi.md Documents date-window grouping and query-time date filtering (Chinese).
docs/docs/zh/src/api/dataset.md Documents the new dataset date accessor (Chinese).
docs/docs/zh/src/advanced/new_serialization.md Documents the new optional streaming block and legacy fallback (Chinese).
docs/docs/en/src/indexes/sindi.md Documents date-window grouping and query-time date filtering (English).
docs/docs/en/src/api/dataset.md Documents the new dataset date accessor (English).
docs/docs/en/src/advanced/new_serialization.md Documents the new optional streaming block and legacy fallback (English).

Comment on lines 387 to +391
if (base_dataset == nullptr || base_dataset->GetSparseVectors() == nullptr ||
inner_id >= base_dataset->GetNumElements()) {
return false;
}
copy_sparse_vector(base_dataset->GetSparseVectors()[inner_id], data, allocator);
auto base_idx = static_cast<int64_t>(inner_id);

@vsag-bot vsag-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated inline review completed.

Review effort: high (1253 changed lines across 24 files).
Submitted 4 inline comments.

Reviewed commit 4cc2841.

Comment thread include/vsag/dataset.h
* @return DatasetPtr A shared pointer to the dataset with updated date labels.
*/
virtual DatasetPtr
Dates(const std::string* dates) = 0;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Do not insert new virtual slots into the public Dataset ABI

These pure virtual methods are inserted before existing methods such as ExtraInfos, shifting every subsequent vtable slot. An application compiled against the previous header but loading the new library will dispatch those calls to the wrong functions (for example, the old ExtraInfos slot now invokes Dates), while external Dataset subclasses also become source-incompatible. Preserve existing slot ordering, such as by appending an ABI-safe/defaulted extension instead.

}

auto inner_id = static_cast<uint16_t>(cur_element_count_ - window_start_id);
for (const auto& group : date_groups) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Resolve duplicate labels before regrouping rows by date

Iterating date groups instead of input rows changes the established first-occurrence behavior for duplicate IDs. For rows (B,Z), (A,X), (B,X), the later B/X row is now inserted before the earlier A/X row, so the earlier row is reported as failed and date queries associate X with the wrong date/vector. Determine accepted duplicate rows in original input order before grouping them into windows.

if (loaded_window_metadata) {
validate_window_metadata(window_count);
} else {
infer_legacy_window_metadata(window_count);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Only infer fixed boundaries for an explicitly legacy stream

A missing or unsupported metadata block is always treated as a legacy stream, even though SINDI_WINDOW_METADATA is marked non-critical and the header does not identify whether variable date windows were written. Removing/skipping this block from a new dated stream therefore loads successfully but reconstructs starts as 0, window_size, ..., causing later postings to be dropped or mapped to wrong labels. Record the window format in the streaming header and require this block for the new format.

deserialize_window_metadata(reader_ref);
validate_window_metadata(window_term_list_size);
} else {
infer_legacy_window_metadata(window_term_list_size);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve date boundaries in footerless round-trips

With deserialize_without_footer=true, has_window_metadata_format can never be set, so a current index serialized with date windows is always restored through this fixed-size legacy inference instead of reading its appended metadata. Two differently dated documents become short windows at starts 0 and 1, but restoration assigns starts 0 and 10000; date searches return no hits and unfiltered search misses the second document. Either make the body self-describing/read the metadata in this mode or reject footerless loading for date-window storage.

@wxyucs

wxyucs commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

/version 1.1

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kind/feature Brand-new functionality or capabilities 引入全新的功能、新特性或新能力 module/api module/docs module/example size/XXL version/1.1

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feat](sindi): add date-window filtering

4 participants