Commit Graph
17 Commits
Author SHA1 Message Date
6cd816acac Add match: { substring } filter condition (#10711)
* Add `match: { substring }` filter condition

Unindexed `text` and `text_any` matching became token-aware in #10341 and
#10593. Users who relied on the old raw substring behaviour get it back as
an explicit condition: `match: { "substring": "..." }` selects points with a
string value containing the given string, byte-wise and case-sensitive,
consistent with exact keyword and prefix matching.

Execution: a keyword index (with or without the `prefix` option) serves the
condition by scanning its value dictionary and uniting the postings of the
matching keys; cardinality reuses the prefix estimator, generalised into
`keys_union_cardinality`. The per-point checker goes through the forward
index. Without a keyword index the condition falls back to reading the
payload. Text, bool, integer and uuid indexes decline it.

Strict mode: the condition requires the `KeywordMatch` capability, so with
`unindexed_filtering_retrieve: false` it is rejected on unindexed and on
text-indexed fields and allowed on any keyword index.

API: `MatchSubstring` in the REST `Match` union with regenerated OpenAPI,
gRPC `Match.substring = 12`, edge python `MatchSubstring`, edge ffi
`Match::Substring`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Test substring fallback on a text-indexed field, document estimator params

A text index cannot serve `substring`, so on a field that has only a text
index the condition runs through the payload fallback; only strict mode may
reject it. Pin that in the OpenAPI suite and reword the strict-mode unit
test comment, which read as if the text index itself blocked the query.

Also spell out what `keys` and `postings` mean in `keys_union_cardinality`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Serve `match: { substring }` from the keyword key dictionary

The condition used to enumerate keys through `MapIndexRead::for_each_value`,
which on the on-disk variant drags the whole `value_to_points` file through
`for_each_entry`, plus one random read per matching key for its postings
count. Query planning paid that scan in full, before deciding whether to use
the clause at all.

Route it through the `prefix_index.bin` key dictionary instead: front-coded
keys with their postings counts inline, no postings. Estimation now reads
keys only and never touches `value_to_points`; filtering takes the matched
key list and resolves postings in one batched read, as prefix matching
already does.

This makes the `prefix` option a requirement: a keyword index without it has
no key dictionary, so it declines the condition and falls back to the payload
scan, the same as a text index. Strict mode follows — substring now infers
`KeywordPrefix`, so `unindexed_filtering_retrieve: false` names
`keyword (with prefix: true)` as the index to create.

`PrefixIndex::for_each_key` reads blocks in ~1 MiB chunks rather than the
whole key section at once: a substring cannot be pruned by the block index,
so the one-shot read would grow with the dictionary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Sync MatchSubstring OpenAPI description with Rust docs

After routing substring matching through the prefix key dictionary,
the schema docstring required regenerating so OpenAPI stays consistent.

* Scan the map index keys for substring match without a dictionary

Without the keyword dictionary a substring condition was declined by the
field index and left to the per-point condition checker, which reads the
forward index for every candidate point. Enumerate the distinct keys of
`values_to_points` instead: the same one-pass-over-distinct-values shape as
the dictionary scan, only over a structure that interleaves keys with their
postings. Filtering and cardinality estimation are then always served, so
the condition can act as a primary clause on a plain keyword index.

Prefix matching keeps its per-point fallback: an ordered dictionary is what
makes a prefix a bounded range, and enumerating every key to answer one is
not a trade worth making implicitly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Reject substring matching in strict mode

A substring condition is answered by looking at every distinct value of the
field: no index gives it a bounded access path, so there is no index a user
could create to make it affordable. Reject it under strict mode instead,
wherever a filter reaches verification — read and write filters, nested
sub-filters, and prefetch filters.

Filter limits are now checked before the unindexed-field check, so the
rejection is not reported as "create an index for this key", advice that
would lead to the same rejection afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Update the strict mode substring test to the new rejection

The test asserted that substring filtering under strict mode asks for a
keyword index with the `prefix` option. It is now rejected whatever index
the field carries, so every case in the test gets the same answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Estimate a substring condition without scanning

Counting the keys a substring matches costs the same scan as answering the
condition, and `filter` then repeats it to collect those keys. Report the
uninformed estimate instead — the one an unindexed condition has always
reported — and keep the primary clause, so the scan happens once, in
`filter`, and only when the planner picks the condition to drive iteration.

With no counts to collect, `substring_scan` collapses into `substring_keys`:
the in-RAM variants no longer look up a posting count per matched key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Don't to parse everything as UTF-8

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: qdrant-cloud-bot <111755117+qdrant-cloud-bot@users.noreply.github.com>
Co-authored-by: timvisee <tim@visee.me>
2026-09-22 12:47:34 +02:00
Andrey Vasnetsov 39a9c6ae97 [AI] Introduce QueryBatchRequest for edge batch queries (#10641) 2026-09-14 19:27:10 +02:00
Andrey VasnetsovandClaude Fable 5.1 81bb80a5d3 Expose id tracker memory placement in collection config (#10597)
Add `id_tracker: { memory: cold | pinned }` to CollectionParams,
CollectionParamsDiff and CreateCollection (REST + gRPC `IdTrackerParams`),
mirroring `payload: { memory }`. `cold` builds the disk-resident id tracker,
`pinned` the in-RAM immutable one. Unset keeps the current behavior: the
`serverless_compatible` feature flag decides.

The requested placement is persisted as an optional `id_tracker_memory` on
SegmentConfig (skipped when unset, so existing configs are unchanged); the
segment builder resolves it through `SegmentConfig::id_tracker_memory_placement`
instead of reading the feature flag directly.

The config mismatch optimizer rebuilds non-appendable segments whose effective
placement differs from the requested one. Appendable segments are skipped: they
always use the mutable tracker and get the current config when indexed.

`cached` is rejected by validation: the disk mapping reader has no
populate-on-open path.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 12:28:24 +02:00
Arnaud GourlayandClaude Opus 5 34fae7221f Remove stale clippy allows and the obsolete large-error-threshold override (#10337)
* Drop obsolete clippy large-error-threshold override

The 256 threshold was pinned for clippy 1.87 while tonic's `Status` was a
large error type. Upstream boxed its contents in `5de7bad` (hyperium/tonic#2253),
which is in the pinned 0.14.6 fork, so `Status` is now a single `Box` and the
default threshold of 128 passes.

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

* Remove stale clippy allows

These 11 allows no longer suppress anything under any of the three CI clippy
configurations (default, --all-targets, --all-targets --all-features).

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 10:52:54 +02:00
Kumar ShivenduandClaude Opus 5 83fe47c90a feat: add a dedicated min operator to score formulas (#10296)
Follow-up to #10287, which added `max`. Expressing a minimum still
required spelling out `(a + b - |a - b|) / 2`, the sign flip of the max
identity — drop the `neg` and you silently get a maximum instead. It also
only works for two operands and mentions each one twice, so the scorer
walks every sub-tree twice per candidate point.

The pair is what makes clamping expressible:

    {"max": [0.0, {"min": [1.0, "$score"]}]}

`min` mirrors `max` throughout, and both guard helpers introduced in
#10287 already took an `operator: &str`, so they are reused unchanged: an
empty operand list is rejected at parse time rather than folding to
+infinity, and the Edge FFI rejects it at construction time. The result
needs no `is_finite` check, since `min` cannot produce a non-finite value
from finite inputs.

The unindexed-field walker shares one arm for `Max | Min` as the bodies
are identical, with a test pinning `min` separately so a later split
cannot silently drop it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 22:51:44 +05:30
Kumar ShivenduandClaude Opus 5 e2d42462fa feat: add a dedicated max operator to score formulas (#10287)
* feat: add a dedicated max operator to score formulas

Expressing a maximum in a score formula required spelling out the
arithmetic identity `(a + b + |a - b|) / 2`. That is easy to get wrong
(the `/ 2` is load-bearing), only works for two operands, and mentions
each operand twice, so the scorer evaluates every sub-tree twice per
candidate point.

`max` is variadic, mirroring `sum` and `mult`:

    {"max": ["$score", {"mult": [0.5, "popularity"]}]}

Unlike `sum` and `mult`, `max` has no identity element for the empty
case, so an empty operand list is rejected at parse time rather than
folding to -infinity and scoring every point with a non-finite value.
The check lives in `ExpressionInternal::parse_and_convert`, which every
entry point passes through, and the Edge FFI additionally rejects it at
construction time to match how that crate validates elsewhere.

The result needs no `is_finite` check: unlike `log10`, `exp`, `div`,
`sqrt` and `pow`, `max` cannot produce a non-finite value from finite
inputs, so it follows the existing `sum` convention.

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

* test: cover max error propagation and datetime operands

An operand that fails must fail the whole expression rather than being
passed over in favour of a finite sibling. Covered with the failure both
before and after the finite operand: `mult` short-circuits on zero and
so can skip evaluating later operands, and this pins down that `max`
must not grow a similar shortcut that would swallow an error.

Also covers `max` over datetime operands, which reach the scorer through
a separate conversion to seconds, so that "score by whichever timestamp
is newer" is verified rather than assumed.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 17:31:18 +05:30
Andrey VasnetsovandClaude Fable 5 e32d3fbf89 Add acosh expression to formula query (#10231)
Unary inverse hyperbolic cosine, parallel to sqrt/ln/exp/log10, in REST,
gRPC, and edge (FFI + Python) interfaces. Inputs below 1 produce the same
NonFiniteNumber error as an invalid sqrt or ln.

Closes #10186

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 10:47:20 +02:00
7364cc42ef feat(edge): add query_batch for batched planned queries (#10100)
* feat(edge): add query_batch for batched planned queries

Expose the planned-query batch path as a public API so multiple
independent queries can share one planning pass over leaf searches
and scrolls. Wired through EdgeShardRead, FFI, and Python bindings.

Co-authored-by: Cursor <cursoragent@cursor.com>

* perf(edge): push batched query vectors down to segments

`query_batch` planned the whole batch at once but then executed every leaf
search on its own: one query context, one fan-out over all segments, and one
single-vector `Segment::search_batch` call per leaf.

Execute the batch as a batch instead:

- `EdgeReadView::search_batch` builds the query context once, visits the
  segments once, and hands each segment the leaves that agree on everything
  but their query vector as a single multi-vector `search_batch` call.
  `search` is now a thin wrapper over a one-element batch.
- Move `SearchType`/`BatchSearchParams` from `collection`'s segments searcher
  into `shard`, next to `CoreSearchRequest`, and add `group_search_batches`
  so both the collection and the edge read path share one grouping
  implementation. Edge computes the grouping once and reuses it per segment.
- `search_matrix` now issues its per-sample nearest queries through
  `query_batch`; they share filter, limit and vector name, so the whole
  sample is scored in one batched search per segment instead of one full
  segment pass per sampled point.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: generall <andrey@vasnetsov.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 11:59:40 +02:00
Srimon Danguria 8db152b270 Make tonic optional in shard and clarify feature dependencies in Cargo.toml (#10069) 2026-08-05 15:37:36 +02:00
Andrey VasnetsovandClaude Opus 5 21db2f3ff9 Bump edge packages (Python + Rust + FFI) to 0.8.0 (#10098)
Minor version bump of the edge packages from `0.7.2` to `0.8.0`.

- `lib/edge/python/Cargo.toml`: `qdrant-edge-py` 0.7.2 -> 0.8.0
- `lib/edge/publish/amalgamate.py`: `VERSION` constant bumped
  (`qdrant-edge` on crates.io)
- `lib/edge/ffi/Cargo.toml`: `qdrant-edge-ffi` bumped, kept in sync with the
  other two as its header comment requires
- `lib/edge/publish/ast-grep-rules.yaml`: inline package version comment
  updated (it was stale at 0.7.1)
- `Cargo.lock`: regenerated version entries

Follows the same pattern as #9252.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 14:55:05 +02:00
xzfc 75385df69f Remove dead code (#10030)
* Remove dead code

* Remove unused dependencies

* `allow(dead_code)` -> `expect(dead_code)`

* ast-grep: rule-tests/*-test.yml => tests/*-test.yml

For brevity.

* ast-grep: forbid allow(dead_code)
2026-07-30 20:59:31 +00:00
Daniel Boros 714b61e9a9 feat: pin per-shard search pool to a core (#10029)
* feat: pin per-shard search pool to a core

* fix: plumb search_pool_core through bindings

* feat: expose search_pool_core in python bindings

* fix: validate search pool core before pinning

* chore: trim comments
2026-07-30 20:41:48 +02:00
dependabot[bot] c16763e0f5 build(deps): bump uniffi from 0.31.2 to 0.32.0 (#10001)
Bumps [uniffi](https://github.com/mozilla/uniffi-rs) from 0.31.2 to 0.32.0.
- [Changelog](https://github.com/mozilla/uniffi-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/mozilla/uniffi-rs/compare/v0.31.2...v0.32.0)

---
updated-dependencies:
- dependency-name: uniffi
  dependency-version: 0.32.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 11:41:58 +02:00
Sasha Denisov 94b0ec1ab6 test(edge-ffi): assert Match.Any/Except land in the correct AnyVariants arm (#9991)
The AnyVariants redesign's tests only checked `is_ok()`. Strengthen them to
pin the actual contract now that the strings-XOR-integers constraint is
type-enforced:

- destructure the converted `SegmentMatch` and assert the values land in the
  matching engine `AnyVariants` arm, in order (catches a mis-wired `From`);
- cover the previously-missing `Except` x `Strings` corner;
- assert `into_iter().collect()` into the engine's `IndexSet` dedups
  (first-seen order), matching the engine's own construction;
- pin empty-set validity for both `Any` (matches nothing) and `Except`
  (matches everything) — the one semantic the docstring promises and the old
  XOR guard used to reject.

Uses `let`-else rather than a wildcard match arm to satisfy the crate's
`wildcard_enum_match_arm` lint on engine enums. 3 tests -> 7; suite 122 -> 126.
2026-07-28 08:56:15 +02:00
Sasha Denisov 4469e9b2f6 refactor(edge-ffi): model Match.Any/Except as a typed AnyVariants one-of (#9988)
Match::Any/Except took `{ strings: Option<Vec<String>>, integers: Option<Vec<i64>> }`
with a runtime XOR check — two of the four representable states were invalid
(both-none, both-set -> InvalidArgument), and the prior field defaults made the
all-invalid empty call the easiest thing to type in Kotlin/Swift.

Replace with a typed sum type `AnyVariants { Strings | Integers }`, matching the
engine's own `AnyVariants`, the gRPC `oneof`, the crate's existing
`ValueVariants`, and every official Qdrant client (Python union, Java/Go/Rust
typed constructors, JS `string[] | number[]`). "Exactly one" is now enforced at
compile time; the two InvalidArgument paths and the field defaults are gone.
Empty sets remain valid (Any -> matches nothing, Except -> matches everything),
mirroring the engine.

122 crate tests pass; clippy clean; regenerated bindings expose
`Match.Any(AnyVariants.Strings(...))`.
2026-07-27 15:19:57 +02:00
Sasha Denisov 2498bfc6bf fix(edge-ffi): default every Option field to None so optionals are skippable (#9987)
`#[uniffi(default = None)]` was applied to request Record fields but not to
enum-variant fields (Query / Match / Fusion, payload-index params) or the
response Records — an oversight, not a UniFFI limitation: uniffi 0.31 fully
supports defaults on enum-variant fields (verified end-to-end by regenerating
the bindings and confirming `= null` lands, e.g. `Query.Nearest.using`).

Annotate every remaining Option<T> field (enum variants + Records) so the
generated Kotlin/Swift bindings default them to null/nil. Consumers can now omit
any optional field — e.g. `Query.Nearest(vector)` instead of
`Query.Nearest(vector, using = null)` — and adding a new optional field to a
Record or enum variant stays source-compatible (named-argument callers).

Constructor/method arguments already use
`#[uniffi::constructor/method(default(...))]` and are unchanged.

Verified: 123 crate tests pass; regenerated bindings have zero optional fields
without a default.
2026-07-27 13:19:07 +02:00
a2b68597c9 feat: shared qdrant-edge-ffi crate (UniFFI boundary for mobile SDKs) (#9374)
* Add shared qdrant-edge-ffi crate with UniFFI bindings

Introduce a shared FFI crate that wraps Qdrant Edge's core types with
UniFFI attributes so the same Rust source can power both the Swift and
Kotlin bindings. The crate lives at lib/edge/ffi/ and exposes ~60 public
types (EdgeShard, Point, Query, Filter, UpdateOperation, …) plus their
enum / record / sealed-union variants.

- lib/edge/ffi/src/       UniFFI-wrapped domain types (config, types,
                          filter, query, update, error, lib)
- lib/edge/ffi/bindgen/   Separate crate housing the uniffi-bindgen CLI
                          (needed so consumers of the `uniffi` runtime
                          don't have to depend on its CLI feature)
- lib/edge/ffi/uniffi.toml Sets the generated Kotlin package to
                          tech.qdrant.edge.ffi (Swift uses the crate
                          name verbatim)

Every public type carries Rust doc comments that UniFFI propagates to
Swift Quick Help and Kotlin KDoc, so the generated bindings ship with
first-class IDE documentation.

Workspace changes:
- Cargo.toml          Adds the new crates as workspace members and
                      introduces a `release-mobile` profile
                      (thin LTO, codegen-units=1, strip=symbols,
                      panic=abort) for size-conscious mobile builds
- Cargo.lock          Pins uniffi 0.31 and its transitive dependencies

Made-with: Cursor

* fix(edge-ffi): harden FFI boundary, add tests, quantization parity, optimize/HNSW

Builds on @ivan-afanasiev's qdrant-edge-ffi foundation (preceding commit) — takes
it to a tested, safe, reviewable state. Split out of #9359 per maintainer request
so the FFI crate can be reviewed in isolation; Swift/Android SDK PRs stack on top.

Boundary safety (host input → catchable error, never a process abort):
- release-mobile profile switched to `panic = "unwind"` so UniFFI's catch_unwind
  turns a panic into a catchable error (abort would risk WAL/segment consistency
  on an on-device DB).
- Fallible boundary conversions reject bad input (UUID, geo, JSON path, payload
  JSON, contradictory match filters) with InvalidArgument instead of panicking.
- Host-supplied counts bounded: limit/offset (bounded_limit, 1 Mi cap), vector
  size (1..=65536), HNSW params (m/payload_m ≤ 2048, ef_construct 4..=100000,
  max_indexing_threads ≤ 1024) — these drive eager allocation / thread spawning
  at optimize(), so unbounded values would abort uncatchably.

API:
- Quantization parity with the Python Edge SDK: all four strategies
  (Scalar/Product/Binary/Turbo) accepted; HnswIndexConfig + optimize() exposed
  (without optimize() search is brute-force).
- config() is an honest "as-requested" read-back (HNSW + quantization round-trip).
- EdgeError stays branchable (ShardClosed / InvalidArgument / OperationError;
  field is `reason`, not `message`, to avoid the Kotlin Throwable collision).

edge core (required by the boundary):
- EdgeShard::flush is fallible (OperationResult) instead of panicking on lock
  contention; Drop logs a flush error instead of aborting; python flush()? updated.
- scroll.rs drops a with_capacity(limit) pre-alloc a huge limit could turn into an
  allocator abort (defense-in-depth alongside bounded_limit).

Tests (CI: cargo +nightly test -p qdrant-edge-ffi): 4 unit + 22 conversion +
18 integration — persistence, crash-recovery, payload round-trip, concurrency,
delete-reload, search ranking, scroll pagination, quantization accept + config
round-trip, HNSW optimize, boundary rejection.

* fix(edge-ffi): validate geo radius/rings and reject empty field conditions

Three boundary-validation gaps surfaced in review of #9374, all rejected
now with EdgeError::InvalidArgument instead of producing wrong results or
reaching a panic in the geo index:

- GeoRadius: a negative or non-finite radius passed straight through to
  the geo index. Reject !is_finite() || < 0.0.
- GeoLineString rings (exterior + interiors of a GeoPolygon): the segment
  type was built by direct struct literal, bypassing the engine's
  validate_line_string (which only runs on the serde path). A malformed
  ring (<4 points or unclosed) could panic in the geo index on indexed
  payloads. Mirror validate_line_string at the single GeoLineString
  conversion chokepoint, covering both exterior and interior rings.
- FieldCondition: a condition with no predicate set is a silent no-op
  (matches every point). Reject it, mirroring the engine's
  validate_field_condition. This is the engine/gRPC/REST/Python contract
  of "at least one" predicate -- NOT "exactly one"; multiple predicates
  remain valid and AND together. The doc comment is corrected accordingly.

Adds 9 conversion tests (geo radius negative/NaN/infinite/valid, ring
too-few/unclosed/bad-interior, field-condition no-predicate/multiple).
cargo +nightly test -p qdrant-edge-ffi: 53 green.

* fix(edge-ffi): adapt to memory/idf API and fallible info after rebase

Keep FlushMode::Sync from recent segment-holder changes while preserving
fallible flush. Fill newly required memory/idf fields (matching the Python
edge bindings) and propagate EdgeShard::info()'s OperationResult.

Co-authored-by: Cursor <cursoragent@cursor.com>

* ci(edge): disable checkout credential persistence in edge-test

actions/checkout persists GITHUB_TOKEN into .git/config by default. This
workflow runs on pull_request from any branch and then builds and runs
repository-controlled code (Rust/Python examples), which could read or
exfiltrate that token. The job never pushes, so drop the persisted
credentials with persist-credentials: false.

Addresses a CodeRabbit security finding on the edge-ffi PR.

* fix(edge): block on lock in flush instead of failing on contention

flush() used try_lock()/try_read() and returned a 'lock busy' OperationError
when a concurrent update/optimize held the WAL or segment lock. That branch is
the wrong trade-off: callers of flush() expect their data persisted, and the
one spot it would fire on the direct Rust path is exactly when an in-flight
update is holding the WAL lock across its whole operation — i.e. when there is
unflushed data most worth persisting. At the FFI boundary it is moreover dead
code, since the outer Mutex<Option<EdgeShard>> already serializes every call.

Switch to blocking lock()/read(), matching the semantics update() and optimize()
already use on these same locks. flush() stays fallible so a genuine WAL/segment
I/O error is still surfaced rather than panicking. Drop cannot contend (it needs
&mut self, so no &self borrow can hold the locks), so it will not hang.

Addresses a CodeRabbit review nitpick on the edge-ffi PR.

* fix(edge-ffi): harden vector/query boundary from multi-agent review

Addresses findings from a multi-agent review of the FFI boundary:

- Multivector conversions were infallible: an empty outer Vec panicked in
  release (MultiDenseVectorInternal::new_unchecked only debug_asserts) and a
  ragged matrix was silently reshaped against row[0].len(), storing data the
  host never sent. Make NamedVector/Vector -> persisted conversions TryFrom and
  validate the matrix (non-empty, uniform non-zero row width) with the same
  rules as the engine's try_from_matrix, returning InvalidArgument.
- Reject non-finite (NaN/inf) vector components at ingest, mirroring the geo
  is_finite guard. The engine validates only dimensionality, so a poisoned
  component would be stored and later serialized back as JSON null silently.
- unload() now returns Result: it flushes explicitly and surfaces a final fsync
  failure instead of only reaching Drop's log line (no default log sink exists).
  On error the shard stays loaded so the host can retry.
- Cap filter/prefetch nesting depth (MAX_QUERY_NESTING_DEPTH). Condition::Filter
  and nested Prefetch are self-recursive; an unbounded host tree would overflow
  the stack — a SIGABRT that panic=unwind cannot catch. Reject deeper trees as
  InvalidArgument via depth-threaded conversion helpers.
- Replace the '""'-on-serialization-failure fallback in payload/vector JSON
  encoding with .expect (serialization is infallible; fail loud, not silent
  invalid JSON).
- Docs: correct the flush() # Errors (can return OperationError), the edge-core
  flush() caller enumeration, upsert_points/update_vectors # Errors (vector
  validation), and reword the fictional lib/edge/VERSION / version-sync comment
  in ffi/Cargo.toml to reflect that no automated check exists yet.

* test(edge-ffi): add behavior coverage for vectors, filters, query, updates

Adds 18 integration tests closing gaps a multi-agent review flagged (the suite
proved type conversion but not behavior):

Safety (back the new boundary validation):
- multi_vector_invalid_matrices_rejected — empty/ragged/zero-dim multi-vectors
- non_finite_vector_components_rejected — NaN/inf across single/named/multi/sparse
- finite_vectors_accepted_by_upsert_constructor — over-rejection guard
- deeply_nested_filter_rejected_shallow_accepted, deeply_nested_prefetch_rejected
  — depth cap rejects >64, accepts shallow

Behavior:
- filter_restricts_count_scroll_and_search — a filter actually narrows the result
  set across count/scroll/search (not just that conversion succeeds)
- flush_under_concurrent_upserts — flush() blocks under a concurrent update loop,
  no panic, final count == successful upserts
- vector_content_round_trips_through_retrieve_and_search
- cosine_distance_ranks_by_direction, euclid_and_manhattan_rank_nearest_first
- delete_points_by_filter / update_vectors / delete_vectors / delete_payload /
  clear_payload — the five previously-untested update ops
- multivector_round_trips, sparse_vector_round_trips
- rrf_fusion_over_prefetches_returns_fused_set

Suite: 4 unit + 32 conversion + 36 integration = 72, all green.

Not covered (blocked by FFI surface, tracked for follow-up): facet() and OrderBy
scroll both need a payload index, and UpdateOperation exposes no create-index
constructor.

* fix(edge): avoid lost-update TOCTOU in set_vector_hnsw_config

set_vector_hnsw_config read().clone()'d the config, mutated the clone, then
write(|c| *c = cfg) overwrote the whole config. The read lock is released before
the write, so a concurrent config update between the two is silently discarded
(lost-update TOCTOU). Use SaveOnDisk::write_optional to run the fallible mutation
on a clone inside the held lock, returning None on failure to abort persist+swap
without overwriting — the atomic pattern the repo's SaveOnDisk learning
prescribes for fallible config mutations.

Addresses a CodeRabbit critical finding on the edge-ffi PR.

* fix(edge-ffi): adapt read requests to edge::* structs after #9901 rebase

#9901 (Edge: request-specific structures for EdgeShardRead) moved the read API
off the shard/core request types onto edge-owned request structs. Retarget the
FFI conversions accordingly:

- QueryRequest/SearchRequest/CountRequest/ScrollRequest/FacetRequest/Prefetch
  now convert into edge::{QueryRequest,SearchRequest,CountRequest,ScrollRequest,
  FacetRequest,Prefetch} (were ShardQueryRequest/CoreSearchRequest/*Internal).
  Field shapes are identical except score_threshold, which is a plain ScoreType
  (f32) on the edge structs, not OrderedFloat — drop the wrap. Nesting-depth
  guard and bounded_limit validation preserved.
- retrieve() builds an edge::RetrieveRequest and calls the new single-argument
  EdgeShardRead::retrieve (was a 3-arg call).
- Cargo.lock reconciled onto dev's lockfile + the FFI/uniffi deps (dev advanced
  23 commits incl. dependency bumps).

cargo +nightly test --locked -p qdrant-edge-ffi: 4 unit + 32 conversion + 18
integration all green.

* feat(edge-ffi): full engine coverage — restructure, all update ops, all scoring queries, missing shard methods

Restructure the crate to mirror the edge crate's layout: shard.rs owns the
EdgeShard object and lifecycle, ops/ has one file per read operation
(request/response records + conversions + exported method together), and
update construction stays in update.rs. Multiple #[uniffi::export] impl
blocks merge cleanly in the generated bindings.

Interface modernization:
- retrieve() takes a RetrieveRequest record mirroring edge::RetrieveRequest
- config surface moves off the deprecated always_ram/on_disk booleans to a
  Memory placement enum (cold/cached/pinned); read-back resolves legacy
  flags via memory_placement(), None-preserving for HNSW
- EdgeShard.inner is RwLock<Option<...>>: operations take the read half and
  run in parallel; unload/update_from_snapshot take the write half and
  drain in-flight requests
- request/config records carry #[uniffi(default = ...)], so generated
  Swift/Kotlin constructors get default arguments (count exact=true, facet
  limit=10, HNSW 16/100/10000/0, everything optional defaults to nil/null)
- all conversions destructure their source exhaustively; intentionally
  unexposed internal fields are named `field: _` with a why-comment

Full update-operation coverage (Python SDK parity):
- upsert_points gains condition/update_mode (conditional upsert),
  update_vectors gains condition, set_payload gains a JSON-path key
- new: delete_vectors_by_filter, set/overwrite/delete/clear payload
  by-filter forms, overwrite_payload, create/delete_field_index (with a
  PayloadSchemaType enum), create_dense_vector, create_sparse_vector,
  delete_vector_name

Full scoring-query coverage:
- Query::Nearest takes a NamedVector (dense/sparse/multi-vector search)
- new Query variants: Recommend (BestScore/SumScores), Discover, Context,
  Feedback; new ScoringQuery variants: Formula (recursive Expression
  object with validating, depth-capped constructors) and Mmr;
  Fusion::Rrf gains weights

Missing shard methods: query_groups, search_matrix, create, path,
snapshot_manifest, update_from_snapshot (full + partial recovery),
set_hnsw_config, set_vector_hnsw_config, set_optimizers_config.

Two compile-time coverage maps (update.rs, ops/query.rs) exhaustively
match the engine's operation/query enum trees with no wildcard arms, so a
new engine variant fails compilation in this crate until the FFI surface
decision is recorded. A staging passthrough feature keeps them exhaustive
when shard/staging is enabled. The scoring-query map immediately caught
the otherwise-missed Mmr variant.

Tests: 82 total (4 unit + 32 conversion + 46 integration), including new
end-to-end coverage for field-index-enabled facet, conditional upsert,
overwrite/clear-by-filter, recommend/discover/context/MMR, formula
re-scoring, grouped queries, search matrix, vector-name ops, and the
lifecycle additions. Kotlin+Swift binding generation verified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(edge-ffi): cover the remaining enum boundaries — filter tree, formula, schema, selections

Add coverage maps for the four construction-only enum families that had no
exhaustiveness guard, and expose everything they revealed as missing:

Filter surface (the big one — filter.rs map over Condition/Match/
RangeInterface/AnyVariants/ValueVariants):
- Condition::Slice — deterministic id-space slice filter, with the
  serde-path total/index validation re-applied at the boundary
- Condition::Nested — nested-object array filters, depth-counted against
  the recursion cap
- Match::TextAny / Match::Phrase / Match::Prefix — the three previously
  unreachable text-match modes
- FieldCondition.datetime_range — RFC 3339 datetime ranges (mutually
  exclusive with the float range, matching the engine's single
  RangeInterface slot)
- Filter.min_should
- WithPayload::Exclude — exclude-style payload selection (types.rs map
  over WithPayloadInterface/PayloadSelector/WithVector)
- OrderBy.start_from — Integer/Float/Datetime cursor (mapped in the
  scoring-query coverage map)
- CustomIdChecker recorded as intentionally unexposed (serde-skipped,
  runtime-internal)

Formula (formula.rs map over ExpressionInternal + DecayKind): all 17
expression variants were already constructible; the map now forces a
decision when the engine grows a new one.

Field-index schema and vector-name config (update.rs map extended):
PayloadFieldSchema::FieldType covered per schema type; the per-type
FieldParams forms recorded as not exposed yet; VectorNameConfig
Dense/Sparse tied to their constructors.

Tests: 91 total (+7 conversion, +2 integration: slice partitioning and
payload-exclude retrieval). Kotlin binding generation verified for the
new types.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(edge-ffi): resolve clippy warnings (inline format arg, large enum variant)

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(edge-ffi): address multi-agent review of the full-coverage expansion

Fixes findings from a multi-agent review (each independently re-verified),
targeting the new surface added in the full-engine-coverage expansion. All
verdicts CONFIRMED except query_groups (refuted → no change). 100 tests green.

- formula: cap Expression node count (MAX_FORMULA_NODES = 10_000), not just
  depth. Expression is a host-held Arc; combinators eagerly deep-clone their
  children's inner tree, so reusing one handle as several children
  (sum([e, e]) repeated) grows node count as 2^depth while depth stays under
  the 64 cap — an eager multi-GB clone that aborts the process (uncatchable
  under panic=unwind). Track a saturating size in node() and decay(); the
  depth guard stays (it protects the stack for narrow-but-deep chains).
- sparse: validate host sparse vectors at the boundary via
  validate_sparse_vector_impl (indices.len == values.len, unique indices).
  Without it a length mismatch reached an out-of-bounds panic at insert/scoring
  (opaque caught-panic, not a typed error) and duplicate indices silently
  double-counted. Route both sparse arms through a fallible helper; the
  infallible From<SparseVector> is removed so no path can bypass validation.
- order_value: surface it on ScoredPoint/Record (new OrderValue { Int, Float },
  matching the Python edge SDK). It was dropped behind a comment claiming "no
  order-by-scored surface" — false: order-by query/scroll and OrderBy.start_from
  are exposed, and ordered results carry a constant score and no next_offset, so
  order_value is the only way to resume ordered pagination when payload is off.
- search_matrix: drop from v1. It is an O(n^2) analytics op the reference Python
  edge SDK does not expose; its flat 1-Mi bounds are the wrong shape (one call
  hangs/OOM-crashes the device on a normal shard). Removing pre-publish is free;
  re-adding later with proper caps is non-breaking, the reverse is not.
- tests: node-count reject, sparse len/dup reject, order_value populated +
  start_from resume, and snapshot negative tests (bad path / corrupt archive /
  post-unload surface OperationError, not a panic, and the shard survives).

* fix(edge-ffi): reject oversized formula before the eager clone; close test gaps

Follow-up from a re-review of the fix commit.

- formula: the node-count check ran AFTER the eager `inner` deep-clone, because
  the clone was an argument to `node()` (evaluated before the function body). A
  host could build one accepted handle and reuse it as many children in one call
  (sum(vec![e; N])), materializing N x e.size nodes before the size check could
  reject it — the same uncatchable OOM abort the cap was meant to prevent, in two
  calls. `node()` now takes the children handles plus a build closure and runs
  both caps BEFORE invoking it, so a rejected tree never clones (decay() already
  did this). All combinators route through it.
- tests: decay node-count reject + happy path (decay has its own guard, was
  untested); wide-fanout reject (regression for the ordering fix); query-side
  ScoringQuery::OrderBy -> ScoredPoint.order_value (only the scroll/Record side
  was covered); real multi-page order-by continuation via StartFrom (was a
  single-page degenerate case); sparse values-longer reject; float OrderValue;
  with_payload=false omits payload. Suite: 107 green (4 + 39 + 64).

* feat(edge-ffi): keep search_matrix behind an off-by-default `matrix` feature

Reconsidered the outright removal: the FFI crate is a general UniFFI boundary,
not mobile-only, so non-mobile consumers (desktop/server/Rust) may want the
distance-matrix op. Instead of deleting it, gate the whole `ops/matrix.rs`
module behind a new off-by-default `matrix` Cargo feature.

- The mobile Swift/Kotlin bindings build without the feature, so search_matrix
  stays off the mobile surface (verified: default `cargo test` excludes it and
  its test; `--features matrix` includes both).
- The O(n^2) DoS is documented, not capped here: the op is opt-in and off the
  constrained mobile surface, so bounding sample_size/limit_per_sample for a
  given deployment is the enabling consumer's / SDK layer's responsibility. The
  module doc spells this out; the per-field bounded_limit only stops a lone
  u64::MAX value, not the quadratic compute.
- CI: add an `--all-features` test leg so the feature-gated surface (matrix +
  the pre-existing staging passthrough) can't bit-rot — this also closes the
  build-publish review note that `staging` had no CI coverage.

* docs+test(edge-ffi): tighten matrix doc wording; pin formula node-count boundary

Non-blocking polish from a final all-reviewer pass (6/6 APPROVE):

- matrix.rs / Cargo.toml docs: (1) the per-field bounded_limit caps at
  MAX_RESULT_COUNT (1,048,576), not merely a lone u64::MAX — say so, since 1 Mi
  is itself catastrophic for an O(n^2) op; (2) the DoS bound is owned by the
  opting-in consumer, not an "SDK/wrapper layer" that need not exist for a raw
  UniFFI consumer; (3) the mobile bindgen (a follow-up PR) must build with
  default features to keep the op off the mobile surface — stated as intent, not
  present-tense fact (no swift/android dirs exist yet).
- formula_node_count_exact_boundary: pins the exact MAX_FORMULA_NODES threshold
  (10_000 accepted, 10_001 rejected) — the existing tests jumped to ~16k, so the
  precise cap edge was unverified.

Suite: 108 default / 109 --all-features, all green.

* feat(edge-ffi): make the matrix feature on by default

Flip `matrix` to on-by-default (`default = ["matrix"]`). General (desktop /
server-side / Rust) UniFFI consumers now get `search_matrix` without opting in;
the mobile Swift/Kotlin bindgen builds with `--no-default-features` to drop the
O(n^2) analytics op from the mobile binding surface.

CI now covers all three shapes: default (matrix on), `--no-default-features`
(mobile surface, matrix excluded — guards the crate still compiles/passes
without it), and `--all-features` (matrix + staging). Verified: default 66 /
no-default 65 / all-features 66 integration tests, all green; no Cargo.lock
drift.

* fix(edge-ffi): rustfmt the sparse validator; correct stale matrix-feature docs

From a full all-reviewer pass of the review fixes:

- types.rs: rustfmt the `to_internal_sparse` `.map_err` chain. It failed
  `cargo +nightly fmt --all -- --check` (the rust-lint.yml CI gate) — the
  edge-test legs only run `cargo test`, so it slipped through locally.
- ops/mod.rs + integration.rs: fix two doc comments that still said the `matrix`
  feature is 'off-by-default' after it was flipped on-by-default. Left uncorrected
  they'd mislead the follow-up mobile-bindgen author into skipping
  `--no-default-features` and shipping the O(n^2) op to phones.

Design decisions (recorded): matrix stays on-by-default; the O(n^2) DoS is
documented, not capped — a static cap can't know the target device (compute cost
is device-dependent; the caller owns it via async/timeout). No cap added.

Reviewers: 3 APPROVE, 3 CHANGES-REQUESTED, all CR items were these two doc/fmt
misses plus the recorded design calls. All 3 feature configs green (66/65/66).

* fix(edge-ffi): resolve clippy errors in FFI tests

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(edge): reject conflicting vector-name re-create instead of desyncing config

The segment-level `create_vector_name` is idempotent: re-creating an existing
name is a no-op that leaves storage untouched. `EdgeShard::update` treated any
`Ok` from that no-op as success and unconditionally re-applied the requested
params to the shard config, so a second `create_dense_vector("v", 8, Cosine)`
after `("v", 4, Dot)` left `config()` advertising 8/Cosine while storage kept
4/Dot — a shape the shard then rejects on upsert. The empty name "" (the
default vector) hit the same path against a single-vector shard's primary field.

Reject a conflicting re-create up front (before the WAL, so it can't brick
replay) with a clear error, accept an identical re-create as idempotent, and
only re-apply config when storage actually changed.

* fix(edge-ffi): harden FFI boundary from an adversarial per-search-type review

Boundary-validation and doc fixes surfaced by fuzzing each query type with
executed repros:

- Reject non-finite floats that JSON cannot represent but the raw-f64 FFI can:
  order-by `StartFrom::Float` (a NaN panicked on a float-indexed field and
  silently truncated an integer scan), and formula `decay` midpoint/scale +
  `div` by_zero_default (a NaN evaded the engine's comparison-based range
  checks → debug panic across the boundary / silent all-zero rescore).
- Drop the `key` parameter from `overwrite_payload`/`overwrite_payload_by_filter`:
  the engine has no payload selector on the overwrite path (the server discards
  it too), so a keyed overwrite silently replaced the whole payload.
- Reject a `FieldCondition` carrying more than one predicate: the engine has no
  defined semantics for it (it evaluates one, index-dependent), so passing
  several through diverged silently from a Qdrant server. Callers AND predicates
  via separate `must` conditions.
- Doc fixes: FeedbackCoefficients b/c (exponent/multiplier, not weight/margin),
  RecommendStrategy::BestScore default, div-by-zero behavior, retrieve
  duplicate-ID collapsing, non-atomic partial-snapshot recovery.

Also fix the clippy `--all-targets -D warnings` lints in the test files that
reddened CI's `lint` job (uninlined_format_args, err_expect, disallowed
std::fs::write) and add regression tests for the validations above.

* fix(edge): compare only vector identity fields when detecting re-create conflicts

The conflicting-re-create guard added in the previous commit compared the full
`EdgeVectorParams`/`EdgeSparseVectorParams` via `PartialEq`, but a vector
declared in the initial `EdgeConfig` is stored with `on_disk: Some(false)` (from
`from_vector_data_config`) while a `CreateVectorName` op leaves it `None`. So an
identical re-create of a construction-defined vector — or of any vector after
`set_vector_hnsw_config` — was falsely rejected as a conflict, a regression on
the idempotent path.

Compare only the identity fields the op actually defines (dense: size, distance,
multivector_config, datatype; sparse: modifier, datatype); the storage/tuning
fields it cannot express are set from the config, the optimizer, or
`set_*_config` and must not trigger a false conflict. Adds a regression test
re-creating the config-defined "vec".

* style: apply cargo fmt

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(edge-ffi): expose payload_schema in info() and parameterized index creation

Close the last interface gap in the FFI surface: `info()` previously
elided the engine's `payload_schema`, and `create_field_index` only
accepted a bare type.

- New `payload_index` module mirroring the full `PayloadSchemaParams`
  family (all 8 index types incl. tokenizer/stopwords/stemmer options)
  with bidirectional conversions.
- `ShardInfo.payload_schema` reports each index's type, creation params,
  and indexed point count.
- New `UpdateOperation::create_field_index_with_params` constructor;
  coverage map updated accordingly.
- Boundary normalizations, following the quantization-config precedent:
  deprecated `on_disk` folds into the reported `memory` placement, and
  contradictory integer params (lookup+range both off) reject with
  `InvalidArgument`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Ivan Afanasiev <ivan.afanasiev@yahoo.com>
Co-authored-by: root <111755117+qdrant-cloud-bot@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: generall <andrey@vasnetsov.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:26:32 +02:00