Files
qdrant/.gitignore
Andrey Vasnetsov 03e15b5ed4 refactor: move deferred-point ownership into ID tracker (#9062)
* refactor: move deferred-point ownership into the ID tracker

Re-implements the idea from #8512 against current `dev`.

Deferred-point state (`deferred_internal_id` + `deferred_deleted_count`)
moves out of `Segment.deferred_point_status` and the cached
`SparseVectorIndex.deferred_internal_id` field into `PointMappings`,
exposed through `IdTrackerRead`. The threshold is set once at
`MutableIdTracker::open` time; `PointMappings::drop` now maintains the
deleted counter inline (with double-delete protection), removing the
manual increment in `delete_point_internal` and the
`calculate_deleted_deferred_point_count` rescan.

Read paths consume the threshold through the id tracker:

- The segment read view drops the `deferred_point_status` field and
  `with_view` no longer threads it in; `read_view/{deferred,info}.rs`
  call `self.id_tracker.deferred_*()` directly.
- `SparseVectorIndex` no longer stores its own copy and its
  `update_vector` / search debug-assert read from
  `self.id_tracker.borrow().deferred_internal_id()`.
- `VectorQueryContext.deferred_internal_id` and the
  `SegmentQueryContext::get_vector_context` parameter are gone; the
  three downstream readers (`plain_vector_index`, sparse search, sparse
  `update_vector`) consult their own id tracker.

`PointMappingsRefEnum` centralises the dispatch:

- `iter_internal_with_behavior(DeferredBehavior)` replaces ad-hoc
  branches in `iter_filtered_points` impls.
- `external_iter_cutoff(DeferredBehavior)` covers iterators sourced
  outside the mapping (field-index outputs in
  `struct_payload_index::iter_filtered_points`).
- The internal `deferred_internal_id()` accessor is private; the raw
  threshold no longer leaks to consumers.
- `iter_from_visible` / `iter_random_visible` read the mapping's own
  threshold; callers that previously passed
  `DeferredBehavior::apply(...)` now branch on
  `deferred_behavior.include_all_points()` (scroll / order_by) or simply
  drop the argument (sampling / facet).

`PayloadIndexRead::query_points` drops the now-redundant
`deferred_internal_id` parameter; `iter_filtered_points` takes
`DeferredBehavior` directly so HNSW build/search can request
`IncludeAll` while normal reads request `Exclude`.

RocksDB-related parts of the original PR are skipped — that tracker is
already gone from `dev`.

Tests adapted: sites that mutated `segment.deferred_point_status`
directly now construct a parallel non-deferred segment via
`create_deferred_segment(..., 0)` for comparison;
`test_deleted_deferred_point_count` reads counters through the id
tracker.

See `docs/plans/deferred-points-owned-by-id-tracker.md` for the design
write-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(benches): drop stale deferred_internal_id arg from query_points calls

The boolean / range / conditional bench files weren't built by
`cargo test -p segment`, so they slipped through. `cargo clippy
--workspace --all-targets` catches them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: drop id_tracker / point_mappings args from iter_filtered_points

Both impls already hold an id tracker on `self`:

- `StructPayloadIndexReadView` carries `id_tracker: &'a I`, so
  `self.id_tracker.point_mappings()` borrows from `'a` and the lazy
  iterator chain keeps working unchanged.
- `PlainPayloadIndex` carries `id_tracker: Arc<AtomicRefCell<...>>`,
  where the mapping borrow is local; collect into a `Vec` and return
  `into_iter()`. PlainPayloadIndex::iter_filtered_points has no direct
  callers — only `query_points` was using it — so eager collection is a
  non-issue.

While here, take `self` by value on `iter_internal_visible`,
`iter_from_visible`, `iter_random_visible`,
`iter_internal_with_behavior`, and `external_iter_cutoff`.
`PointMappingsRefEnum` is `Copy`; this matches the existing
`iter_internal` / `iter_from` / `iter_random` shape and lets the
iterator outlive a local `let point_mappings = ...;` binding.

The HNSW `condition_points` helper drops its now-unused `id_tracker`
parameter.

All callers (sampling, scroll, order_by, facet ×2, hnsw build/search)
just drop the two arguments.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: ignore /docs/plans/ and untrack the previously-committed plan

`docs/plans/` is a scratch directory for per-feature planning notes —
not something we want under source control. Add it to `.gitignore` and
drop the deferred-points plan that slipped into history; the design is
captured in the PR description.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: replace external_iter_cutoff with filter_deferred iterator wrapper

Instead of exposing a raw `Option<PointOffsetType>` cutoff that every
caller has to apply with their own `.filter(...)`, give
`PointMappingsRefEnum` an iterator wrapper:

    fn filter_deferred<I: Iterator<Item = PointOffsetType>>(
        self,
        iter: I,
        deferred_behavior: DeferredBehavior,
    ) -> impl Iterator<Item = PointOffsetType>

It returns the iterator unchanged for `IncludeAll` (or when the mapping
has no threshold) and otherwise wraps it in a cutoff `.filter`,
dispatched via `itertools::Either` so the no-cutoff path stays
allocation-free.

The struct payload index's `iter_filtered_points` swaps its open-coded
filter for a single `point_mappings.filter_deferred(...)` call. The
deferred threshold no longer leaks out of `PointMappingsRefEnum`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: move deferred wrapping out of peek_top_all, gate it as test-only

`BatchFilteredSearcher::peek_top_all` baked the deferred cutoff into
its iterator construction, which was the last place outside
`PointMappingsRefEnum` that knew about the threshold. Split the
deleted-iteration concern out into a new accessor:

    fn iter_not_deleted(&self) -> impl Iterator<Item = PointOffsetType> + 'a

It borrows `&'a BitSlice` directly (not via `&self`), so callers can
chain `filter_deferred` and then move `self` into `peek_top_iter`
without lifetime conflicts.

Sparse + plain vector index call sites now do:

    let iter = id_tracker
        .point_mappings()
        .filter_deferred(searcher.iter_not_deleted(), DeferredBehavior::Exclude);
    searcher.peek_top_iter(iter, &is_stopped)

leaving `BatchFilteredSearcher` completely ignorant of deferred state.

With deferred handling lifted out, `peek_top_all` itself is now used
only by tests (3 inline `#[cfg(test)] mod tests`, 1 integration test,
1 bench) — gate it under `#[cfg(feature = "testing")]` to match
`new_for_test`. Production code goes through the
`iter_not_deleted` + `filter_deferred` + `peek_top_iter` composition.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: optimize Segment::retrieve and thread user data through read_vectors

Two interlocking changes that together collapse the per-point lookups
and intermediate allocations in `Segment::retrieve` down to one
external-to-internal pass.

## `IdTrackerRead::resolve_external_ids` (new default trait method)

Single-pass translation of a `&[PointIdType]` slice into two parallel
vectors `(Vec<PointIdType>, Vec<PointOffsetType>)`. Folds deferred
filtering (compare offset against the threshold inline — no separate
`point_is_deferred` lookup) and missing-id errors (eager
`PointIdError`) into resolution.

Lives on the trait so the deferred threshold never leaks out of the id
tracker; the parallel-vector shape lets a future batched payload /
vector fetcher consume `&offsets` straight without unzipping. The
`appendable_flag` guard previously in `point_is_deferred` is gone:
non-appendable trackers always carry `deferred_internal_id() == None`
(set only via `MutableIdTracker::open`, guarded by the segment
constructor), so the check was load-bearing nowhere.

## User-data threading through `read_vectors`

`VectorStorageRead::read_vectors` now takes
`IntoIterator<Item = (U, PointOffsetType)>` and yields
`(U, PointOffsetType, CowVector)`. The user-data tag rides alongside
each offset all the way through, so callers can map results back into a
parallel array without keeping a separate `offset → ...` lookup table.

- Default trait impl: one-line per-key loop.
- Dense impl: `unzip()` into parallel `(Vec<U>, Vec<PointOffsetType>)`
  in a single pass — same allocation count as before, just U riding
  alongside.
- Enum delegations (`VectorStorageEnum`, `VectorStorageReadEnum`)
  forward unchanged.
- `for_each_in_batch` and below stay untouched.

`SegmentReadView::vectors_by_offsets<U: Copy>` becomes a lazy filter
chain — no parallel `Vec<(orig_idx, offset)>` allocation. The dead
`SegmentReadView::read_vectors` helper is removed.

## `Segment::retrieve` end-to-end

Per N points / V vectors / payload:

| Operation                  | Before              | After |
|----------------------------|---------------------|-------|
| `id_tracker.internal_id`   | N × (1 + V + 1)     | N     |
| `id_tracker.external_id`   | N × V               | 0     |
| `point_is_deferred`        | N (when applicable) | 0     |
| `offset_to_id` HashMap     | N entries           | none  |
| `Vec` in `vectors_by_offsets` | 1                | 0     |

The vectors stage passes the external id as `read_vectors`'s user
data — the callback gets `id` directly without any index lookup.
The payload stage uses `payload_by_offset` against the already-resolved
offsets. The shape is also batch-friendly: swapping in a future
`IdTrackerRead::batch_internal_id` or `payload_index.batch_get_payload`
needs no changes outside the two call sites.

## Behavioural notes

- Missing-id now errors eagerly inside resolution, instead of in the
  vectors stage (`WithVector::Bool(true)` / `Selector`) or payload
  stage (`with_payload.enable`). The previous `WithVector::Bool(false)`
  + no-payload path silently inserted an empty record; that is now also
  an error. None of the existing callers (search post-processing,
  external retrieve API, the deferred-points test on tests/mod.rs:1179)
  pass non-existent ids.
- Added a per-payload `check_stopped`; the vectors stage already had
  `stop_if` on its iterator chain.
- `vector_by_offset` (the single-element helper) passes `()` as the
  no-op user data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style: apply rustfmt to optimised retrieve / read_vectors paths

Pre-push hook failure on the previous commit was rustfmt. Same content,
formatted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* do not error out on missing points in retrieve

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:44:43 +02:00

25 lines
363 B
Plaintext

/target
.idea
.vscode
/.devcontainer
/storage
/snapshots
/consensus_test_logs
/tests/consensus_test_logs
/config/local
/tls
/static
*.log
.qdrant-initialized
/.hypothesis
**/__pycache__
tests/storage-compat/compatibility.tar
tests/storage-compat/full-snapshot.snapshot.gz
tests/storage-compat/storage.tar.bz2
venv
.venv
.env
.python-version
.claude/
/docs/plans/