feat(m12): vector retrieval G1/G2 — recall harness, ANN in RETRIEVE, index tuning
m12p1 (measurement truth): TidalDb::vector_search_items pure k-NN probe + POST /vector_search (standalone + region node, merge-by-distance) + tidal-stress --verify-recall (deterministic id-keyed corpus, in-RAM brute-force cosine oracle, open-loop ramp → recall@k + true p99 + read-knee + JSON/gate exit). Repaired fabricated p99 columns (mean-as-p99) in social-scale.md / scale.rs. Verified real: recall@10=0.9997 at 20k/1536-D vs brute-force. m12p2 (G1 unblock): ANN candidate-gen wired into RETRIEVE — for_you=preference vector, related=seed embedding (similar_to), graceful scan-fallback. Cached per-signal-type top-K (signals/ledger/hot_top_k.rs, decay-order-invariant) so trending serves O(K). related over HTTP (FeedQuery.similar_to). Harness gains --feed-profile / --seed-preferences. Verified: trending retrieve p99 3.5-7.7ms. m12p3 (G2): per-query ef_search now honored (RwLock epoch-guard with_expansion, shared guard for same-ef concurrency) + dimension-aware brute→HNSW crossover usearch_min_vectors(dim) + memory_usage() + examples/ann_grid_search.rs. Measured 1536-D/100k clustered: default M=16/ef_c=400/F16/ef_s=200 clears G1+G2 (recall 0.997, p99 1.4ms); F16 -0.25% vs F32; Int8 rejected (-28%). Recall corpus is now clustered (Gaussian mixture) in grid + harness.
This commit is contained in:
parent
81093a6779
commit
bb21e69ae6
108
CHANGELOG.md
108
CHANGELOG.md
@ -6,6 +6,114 @@ All notable changes to tidalDB will be documented in this file.
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
**Index tuning + recall/memory at the production shape (m12p3) — the G2 work: per-query `ef_search` is now honored, the brute-force crossover scales with dimensionality, and the HNSW recall/latency/memory frontier is measured at 1536-D with a real exact oracle**
|
||||||
|
|
||||||
|
- **Per-query `ef_search` override — now real.** `UsearchIndex::search` /
|
||||||
|
`filtered_search` honor a per-request `ef_search` instead of silently dropping
|
||||||
|
it to the index default (pre-m12p3 behaviour: accepted for trait compliance,
|
||||||
|
logged a warning, ignored — `USearch` 2.24 has no per-call beam argument). The
|
||||||
|
override is race-free via an `RwLock` epoch guard (`with_expansion`): searches
|
||||||
|
that agree on `ef_search` run in parallel under a shared guard; only a query
|
||||||
|
that changes the live beam width takes the exclusive guard for its
|
||||||
|
`(set, search)` window — not a per-search mutex. The knob was already plumbed
|
||||||
|
end-to-end in m12p1 (`vector_search_items(.., ef_search)`, the `/vector_search`
|
||||||
|
`ef_search` field, `tidal-stress --recall-ef-search`); m12p3 makes it move
|
||||||
|
recall. `ef_search=0` selects the slot default.
|
||||||
|
- **Dimension-aware brute-force → HNSW crossover.** The exact `BruteForceIndex`
|
||||||
|
scans every vector under a read lock at `count × dim` cost, so a fixed 10,000
|
||||||
|
crossover meant a 15.4M-FMA scan at 1536-D (tens of ms, blocking writers).
|
||||||
|
`usearch_min_vectors(dim)` now keeps a brute-force scan within ~4M FMAs:
|
||||||
|
≈10,000 at/under 128-D (byte-compatible with pre-m12p3), ≈2,600 at 1536-D —
|
||||||
|
flipping high-dim mid-size slots to HNSW before the scan blows the SLA.
|
||||||
|
- **`memory_usage()`** exposed on `UsearchIndex` (the true graph + vector
|
||||||
|
footprint from `USearch`, not the `index_stats` lower bound) for pod sizing.
|
||||||
|
- **Grid-search harness.** `cargo run --release --example ann_grid_search` builds
|
||||||
|
a `UsearchIndex` + an exact `BruteForceIndex` oracle over the same
|
||||||
|
deterministic id-keyed corpus and reports, per `(M, ef_construction, ef_search,
|
||||||
|
quantization)` point, measured recall@10 vs the oracle, mean/p99 search
|
||||||
|
latency, build time, and the true footprint — the tool that produces the
|
||||||
|
documented `M`/`ef` and the F32/F16/Int8 recall+memory numbers.
|
||||||
|
- **Measured at 1536-D (100k clustered corpus, real exact oracle).** The
|
||||||
|
production default (M=16, ef_c=400, F16) **clears G1 and G2**: recall@10 0.997
|
||||||
|
at p99 ≈ 1.4 ms raw ANN; ef_search is the latency lever (recall saturates by
|
||||||
|
ef_s=128 → p99 ≈ 1.0 ms). F16 costs only 0.25% recall vs F32 for half the RAM
|
||||||
|
(≈ 5.2 GB/1M true footprint incl. graph); **Int8 rejected** at 1536-D (recall
|
||||||
|
0.715, −28%). Live `tidal-stress --verify-recall` against a real server gave
|
||||||
|
recall@10 = 1.0000 at 20k/1536-D, default beam and `--recall-ef-search 400`.
|
||||||
|
Finding: the recall corpus is now **clustered** (Gaussian mixture) in both the
|
||||||
|
grid harness and `tidal-stress` (`recall::embedding_for`) — uniform-random
|
||||||
|
high-dim vectors are pathological for recall@k (≈0.97 at 10k → ≈0.54 at 100k, a
|
||||||
|
measurement artifact, not an index regression).
|
||||||
|
Full frontier + the 1M command in
|
||||||
|
[docs/profiling/usearch-tuning.md](docs/profiling/usearch-tuning.md) and
|
||||||
|
[docs/profiling/scale-baselines.md](docs/profiling/scale-baselines.md).
|
||||||
|
`docs/specs/07-vector-retrieval.md` updated: per-query `ef_search` is
|
||||||
|
IMPLEMENTED, not deferred.
|
||||||
|
|
||||||
|
**ANN candidate generation in RETRIEVE (m12p2) — the G1 unblock: `for_you`/`related` source candidates by nearest-neighbour, `trending` by a cached per-signal-type top-K, so the feed stays relevant AND bounded as the corpus grows past the scan cap**
|
||||||
|
|
||||||
|
- **ANN in retrieve.** `CandidateStrategy::Ann` is now wired into the RETRIEVE
|
||||||
|
executor (it previously fell back to a scan with a warning). The db layer
|
||||||
|
resolves the query vector — the user's **preference vector** for `for_you`, the
|
||||||
|
**seed item's embedding** (`similar_to`) for `related` — and Stage 1 runs an
|
||||||
|
`O(ef_search)` HNSW search over the item content slot instead of scanning an
|
||||||
|
arbitrary low-id slice of the universe. `for_you` and `related` are now `Ann`
|
||||||
|
profiles. Graceful: no registry / no preference vector / no seed ⇒ it degrades
|
||||||
|
to a scan (anonymous reads and cold-start users still serve), so every
|
||||||
|
embedding-less schema and pre-m12p2 caller is unchanged.
|
||||||
|
- **Cached `SignalRanked`.** The O(N) ledger scan behind `SignalRanked` is now a
|
||||||
|
cached per-signal-type top-K (`signals/ledger/hot_top_k.rs`): O(K) on the
|
||||||
|
served path, with a bounded O(N) rebuild only when stale. Decay preserves
|
||||||
|
relative order (same λ), so a cache is valid until the next write; small
|
||||||
|
ledgers rebuild on any write (always fresh), large ledgers throttle the rebuild
|
||||||
|
off the read hot path (1s). `trending` now uses `SignalRanked(view)`, so it
|
||||||
|
ranks the actually-viewed corpus at any id — not the low-id scan slice.
|
||||||
|
- **`related` over HTTP.** `GET /feed?profile=related&similar_to=<id>` resolves
|
||||||
|
the seed's embedding and runs ANN — "more like this" on the read surface
|
||||||
|
(`similar_to` added to `FeedQuery`, threaded through all three feed handlers).
|
||||||
|
- **Harness `/feed` measurement.** `tidal-stress` gains `--feed-profile <name>`
|
||||||
|
(force every feed read to one profile, for per-profile retrieve p99) and
|
||||||
|
`--seed-preferences` (build a preference vector per user so `for_you` exercises
|
||||||
|
ANN, not the scan fallback).
|
||||||
|
- **Verified real** against a 1536-dim standalone server: **trending retrieve
|
||||||
|
p99 = 3.5–7.7ms** (under the 10ms G1 target) under concurrent writes, via the
|
||||||
|
cached top-K; `for_you` retrieve is ANN-backed (preference vectors built) at
|
||||||
|
p99 ≈ 24ms, dominated by the Stage-3 preference-boost recompute (per-candidate
|
||||||
|
embedding read) — flagged for m12p3's index/score tuning (the risk register's
|
||||||
|
"materialized-score layer"). ANN candidate recall is the m12p1 `/vector_search`
|
||||||
|
probe (0.9997). New engine tests prove ANN/`related`/`trending` reach the
|
||||||
|
relevant items at high ids a scan can never reach, plus cache freshness.
|
||||||
|
|
||||||
|
**Read-recall harness — measurement truth for ranking-at-scale (m12p1): recall@k + true p99 at the production shape, so G1/G2 steer on real numbers instead of absent ones**
|
||||||
|
|
||||||
|
- **Pure k-NN probe.** `TidalDb::vector_search_items(query, k, ef_search)` returns
|
||||||
|
the raw HNSW nearest neighbours over the item content embedding slot — NO
|
||||||
|
profile scoring, fusion, or diversity — so the result is the ANN index quality
|
||||||
|
in isolation (the G2 metric). Exposed as `POST /vector_search` on the standalone
|
||||||
|
router and the multi-process region node (merge-by-distance across hosted shard
|
||||||
|
groups); a dimension-mismatched query is a 400, not a 500.
|
||||||
|
- **The oracle.** `tidal-stress --verify-recall` seeds the corpus with
|
||||||
|
**deterministic, id-keyed** embeddings (reproducible with `--skip-seed`), holds
|
||||||
|
a **brute-force cosine ground truth** in RAM, and ramps `/vector_search` probes
|
||||||
|
**open-loop** (coordinated-omission corrected). It reports per-stage **true p99**
|
||||||
|
(a genuine tail, not a closed-loop mean) AND **mean recall@k**, plus the
|
||||||
|
**read-knee** — the highest sustained QPS where `p99 ≤ target AND recall@k ≥
|
||||||
|
target` both hold — with a machine-readable JSON summary and `--fail-on-knee`
|
||||||
|
PASS/FAIL exit. Knobs: `--recall-k`, `--recall-queries`, `--read-p99-target-ms`,
|
||||||
|
`--recall-target`, `--recall-ef-search`.
|
||||||
|
- **Verified end-to-end** against a real standalone server at 1536-dim:
|
||||||
|
**recall@10 = 0.9997** at 20k items (HNSW M=16/ef=400/F16 vs brute-force cosine,
|
||||||
|
far above the 0.95 G2 target); the harness also exercises the read-knee verdict
|
||||||
|
(both branches) and gate exit codes. The 100k/1M exit-gate runs use the same
|
||||||
|
harness on the k3s cluster (the brute-force oracle needs ~6 GB RAM at 1M).
|
||||||
|
- **No more mean-as-p99.** Repaired the fabricated p99 column in
|
||||||
|
`docs/profiling/social-scale.md` (the `social` bench is Criterion = mean only)
|
||||||
|
and the `scale.rs` / scale-baselines framing; every closed-loop number is now
|
||||||
|
labelled an *isolated per-op mean (regression tripwire)*, with the p99/recall
|
||||||
|
tail SLOs signed off only by the open-loop harness. Added a 1536-dim
|
||||||
|
HNSW-vs-brute `recall@10` bench (`benches/vector.rs`) and a recall-harness
|
||||||
|
section to `docs/profiling/scale-baselines.md`.
|
||||||
|
|
||||||
**Sharding × replication + rebalancing (m11p6) — the "replicated XOR sharded" split is over: S shard groups, each a replication group at RF with its own elected leader, leaders balanced across nodes; any gateway hash-routes**
|
**Sharding × replication + rebalancing (m11p6) — the "replicated XOR sharded" split is over: S shard groups, each a replication group at RF with its own elected leader, leaders balanced across nodes; any gateway hash-routes**
|
||||||
|
|
||||||
- **One write surface.** `/items`//`/embeddings`//`/signals` now hash-route the
|
- **One write surface.** `/items`//`/embeddings`//`/signals` now hash-route the
|
||||||
|
|||||||
@ -103,3 +103,175 @@ tidalDB's **isolated per-op mean cost** sits well within all three
|
|||||||
acceptance-criteria targets at 1M items. The dominant cost is SEARCH text_only at ~29ms — driven by Tantivy posting list traversal across 1M documents. The LogMergePolicy tuning (< 20 segments at steady state) keeps this below the 100ms target with headroom. **The p99 tail SLOs themselves are signed off by the open-loop `tidal-stress` ramp, not by these closed-loop means** (see the measurement contract above).
|
acceptance-criteria targets at 1M items. The dominant cost is SEARCH text_only at ~29ms — driven by Tantivy posting list traversal across 1M documents. The LogMergePolicy tuning (< 20 segments at steady state) keeps this below the 100ms target with headroom. **The p99 tail SLOs themselves are signed off by the open-loop `tidal-stress` ramp, not by these closed-loop means** (see the measurement contract above).
|
||||||
|
|
||||||
Signal writes at 82ns confirm the DashMap hot-path is not a bottleneck at this scale. The 5M-entry LRU trimming threshold (DEFAULT_MAX_SIGNAL_ENTRIES) provides ample headroom for the 100K-item signal coverage in this benchmark (~200K entries = ~218MB).
|
Signal writes at 82ns confirm the DashMap hot-path is not a bottleneck at this scale. The 5M-entry LRU trimming threshold (DEFAULT_MAX_SIGNAL_ENTRIES) provides ample headroom for the 100K-item signal coverage in this benchmark (~200K entries = ~218MB).
|
||||||
|
|
||||||
|
## Read-recall harness — recall@k + true p99 (m12p1, open-loop)
|
||||||
|
|
||||||
|
Every number above is an *isolated per-op mean* and says nothing about **recall**
|
||||||
|
— the fraction of the true nearest neighbours an ANN query actually returns.
|
||||||
|
m12p1 adds the open-loop harness that measures recall@k AND true p99 at the
|
||||||
|
production shape, so G1 (p99 ≤ 10ms) and G2 (recall@10 ≥ 0.95) can be steered
|
||||||
|
against real numbers instead of absent ones.
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
1. `tidal-stress --verify-recall` seeds the corpus with **deterministic,
|
||||||
|
id-keyed** embeddings, so the generator reconstructs the exact indexed vectors
|
||||||
|
and computes a **brute-force cosine ground truth** in RAM (the exact answer the
|
||||||
|
HNSW index approximates).
|
||||||
|
2. It ramps `POST /vector_search` probes **open-loop** (coordinated-omission
|
||||||
|
corrected) and scores each response's `recall@k` against the precomputed
|
||||||
|
ground truth, recording the **true p99** (a genuine tail, not a closed-loop
|
||||||
|
mean).
|
||||||
|
3. The verdict reports the **read-knee**: the highest sustained QPS at which
|
||||||
|
`p99 ≤ target AND recall@k ≥ target` both hold, plus a machine-readable JSON
|
||||||
|
summary and a `--fail-on-knee` PASS/FAIL exit for the soak gate.
|
||||||
|
|
||||||
|
`/vector_search` is a **pure k-NN probe** (`TidalDb::vector_search_items`): raw
|
||||||
|
HNSW nearest neighbours with NO profile scoring, fusion, or diversity, so the
|
||||||
|
number is the ANN index quality in isolation — exactly the G2 metric. It is a
|
||||||
|
single-index measurement: run it against a standalone node or a cluster at the
|
||||||
|
S=1 shape (every replica holds the full corpus). RAM for the brute-force oracle
|
||||||
|
is `corpus × dim × 4` bytes (~0.6 GB at 100k/1536D, ~6 GB at 1M/1536D).
|
||||||
|
|
||||||
|
### Measured (local, standalone, 1536-dim)
|
||||||
|
|
||||||
|
Verified end-to-end against a real standalone server (`--schema` content_vector =
|
||||||
|
1536, release build, Apple Silicon laptop):
|
||||||
|
|
||||||
|
| Corpus / dim | recall@10 (vs brute-force cosine) | per-query cost | p99 @ 100 rps |
|
||||||
|
|--------------|-----------------------------------|----------------|---------------|
|
||||||
|
| 20,000 / 1536D | **0.9997** | ~16 ms (single request) | ~37 ms |
|
||||||
|
|
||||||
|
The HNSW index (M=16, ef_construction=400, ef_search=200, F16) returns essentially
|
||||||
|
the exact neighbour set — recall@10 ≈ 0.9997, far above the 0.95 G2 target. The
|
||||||
|
~16–19 ms per-query cost on a single laptop node is well over the 10 ms G1 target
|
||||||
|
and saturates past ~400 rps; G1 is the work of m12p2 (ANN candidate-gen in
|
||||||
|
RETRIEVE) + m12p3 (index tuning + per-request `ef_search`), measured by this same
|
||||||
|
harness at scale.
|
||||||
|
|
||||||
|
### Running the exit-gate shape (100k AND 1M)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 100k corpus, 1536-dim, against a single-index node:
|
||||||
|
tidal-stress --target http://<node>:9500 --verify-recall \
|
||||||
|
--corpus 100000 --embedding-dim 1536 --recall-queries 1000 \
|
||||||
|
--ramp "200:30,500:30,1000:30,2000:30" \
|
||||||
|
--read-p99-target-ms 10 --recall-target 0.95 \
|
||||||
|
--json-summary recall-100k.json --fail-on-knee
|
||||||
|
|
||||||
|
# 1M corpus: same, --corpus 1000000 (oracle needs ~6 GB RAM on the generator).
|
||||||
|
```
|
||||||
|
|
||||||
|
The 1M run requires the k3s cluster (RAM + a seeded 1M/1536D corpus) and is
|
||||||
|
tracked as the cluster-side step of the m12p1 exit gate; the harness, the probe
|
||||||
|
endpoint, and the verdict are proven locally at 20k/1536D above.
|
||||||
|
|
||||||
|
## RETRIEVE with ANN candidate generation (m12p2)
|
||||||
|
|
||||||
|
m12p2 replaced the arbitrary low-id scan slice in candidate generation with
|
||||||
|
relevance-bounded sources: `for_you`/`related` source candidates by ANN
|
||||||
|
nearest-neighbour (over the user's preference vector / the seed item's
|
||||||
|
embedding), and `trending` by a cached per-signal-type top-K. Measured against a
|
||||||
|
real 1536-dim standalone server (release, Apple Silicon laptop):
|
||||||
|
|
||||||
|
| Profile | Candidate source | Retrieve feed p99 | G1 (≤10ms) |
|
||||||
|
|---------|------------------|-------------------|------------|
|
||||||
|
| `trending` | cached `SignalRanked(view)` top-K | **3.5–7.7 ms** (600–1200 rps, concurrent writes) | ✅ met |
|
||||||
|
| `for_you` | ANN over the user preference vector | ~24 ms (100–400 rps) | ⚠ see below |
|
||||||
|
|
||||||
|
- **`trending`** holds p99 ≤ 10ms because the cached top-K serves candidates in
|
||||||
|
O(K) (the O(N) rebuild is throttled off the read hot path), so it ranks the
|
||||||
|
actually-viewed corpus at any id while staying fast even under a concurrent
|
||||||
|
write stream.
|
||||||
|
- **`for_you`** is ANN-backed (the candidate recall is the m12p1 `/vector_search`
|
||||||
|
probe, 0.9997). Its ~24ms p99 on this single laptop node is dominated NOT by
|
||||||
|
the ANN search (~1.5ms raw at 10k/1536D — see `ann_recall_at_10_1536d` in
|
||||||
|
`benches/vector.rs`) but by the **Stage-3 preference boost**, which reads each
|
||||||
|
of the ~240 candidates' embeddings from storage and recomputes cosine to the
|
||||||
|
preference vector. That O(K) per-candidate recompute is the m12p3 target — the
|
||||||
|
risk register's "materialized-score layer" / reuse the ANN distance already
|
||||||
|
computed in candidate-gen. The absolute 10ms at 1M is an m12p3 (`ef_search`
|
||||||
|
tuning) + production-hardware result; m12p2 delivered the algorithmic change
|
||||||
|
(relevance-bounded candidate generation) and surfaced the remaining bottleneck.
|
||||||
|
|
||||||
|
Reproduce the per-profile retrieve p99 with the harness:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# for_you (ANN): build preference vectors, force the profile, gate at 10ms.
|
||||||
|
tidal-stress --target http://<node> --mix "feed=1" \
|
||||||
|
--feed-profile for_you --seed-preferences \
|
||||||
|
--corpus 100000 --embedding-dim 1536 --users 10000 --max-p99-ms 10
|
||||||
|
|
||||||
|
# trending (cached SignalRanked): peach mix writes views; force the profile.
|
||||||
|
tidal-stress --target http://<node> --mix peach --feed-profile trending --skip-seed \
|
||||||
|
--corpus 100000 --embedding-dim 1536 --max-p99-ms 10
|
||||||
|
```
|
||||||
|
|
||||||
|
## Index tuning + recall/memory frontier at the production shape (m12p3)
|
||||||
|
|
||||||
|
m12p3 tuned the HNSW at the **production shape (1536-D)** — earlier tuning was
|
||||||
|
only validated at 128-D — and characterized the recall/latency/memory frontier
|
||||||
|
with a real exact oracle. The full method, the chosen `M`/`ef`, and the
|
||||||
|
reproduce commands live in [usearch-tuning.md](usearch-tuning.md); the headline
|
||||||
|
recall + per-1M memory sizing is below.
|
||||||
|
|
||||||
|
The numbers come from `cargo run --release --example ann_grid_search`: a
|
||||||
|
deterministic id-keyed corpus, an exact `BruteForceIndex` (F32) oracle, recall@10
|
||||||
|
scored per query, and the index's true `memory_usage()` extrapolated to 1M.
|
||||||
|
|
||||||
|
The corpus is a **Gaussian mixture** (clustered), not uniform-random: uniform
|
||||||
|
high-dimensional vectors are pathological for recall@k (every pair ≈ orthogonal,
|
||||||
|
so top-k is an arbitrary draw from an equidistant shell — recall@10 measured
|
||||||
|
~0.97 at 10k but ~0.54 at 100k, a corpus-size artifact, not an index regression).
|
||||||
|
Real embeddings cluster on a manifold; the clustered corpus reflects that, so
|
||||||
|
recall@k is meaningful and scale-stable. Details in
|
||||||
|
[usearch-tuning.md](usearch-tuning.md).
|
||||||
|
|
||||||
|
### Recall@10 at 1536-D (measured, 100k clustered corpus, vs exact brute-force cosine)
|
||||||
|
|
||||||
|
| Config (F16) | recall@10 | p99 (raw ANN) | G2 (≥0.95) | G1 (≤10ms) |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| M=16, ef_c=400, ef_s=128 | 0.9970 | 1.0 ms | ✅ | ✅ |
|
||||||
|
| **M=16, ef_c=400, ef_s=200 (default)** | **0.9970** | **1.4 ms** | ✅ | ✅ |
|
||||||
|
| M=24, ef_c=400, ef_s=200 | 0.9985 | 2.2 ms | ✅ | ✅ |
|
||||||
|
| M=32, ef_c=400, ef_s=200 | 0.9975 | 2.9 ms | ✅ | ✅ |
|
||||||
|
|
||||||
|
The production default (M=16, ef_c=400, F16) **clears both G1 and G2 at
|
||||||
|
100k/1536-D** with wide margin. `ef_search` is the latency lever and recall
|
||||||
|
saturates by ef_s=128, so the per-query override (m12p3) lets a latency-sensitive
|
||||||
|
read drop to ef_s=128 (p99 ≈ 1.0 ms) without losing recall. **Live cross-check:**
|
||||||
|
`tidal-stress --verify-recall` against a real server returned recall@10 = 1.0000
|
||||||
|
at 20k/1536-D (clustered), at the default beam and at `--recall-ef-search 400`.
|
||||||
|
|
||||||
|
### Per-1M memory sizing by quantization (true measured footprint, extrapolated from 100k)
|
||||||
|
|
||||||
|
| Quantization | recall@10 (M=24, ef_c=400, ef_s=400) | mem/1M (GB) | Verdict |
|
||||||
|
|---|---|---|---|
|
||||||
|
| F32 | 0.9985 | 10.53 | exact, 2× RAM |
|
||||||
|
| **F16 (default)** | **0.9960** | **5.53** | −0.25% recall, half RAM — **chosen** |
|
||||||
|
| Int8 | 0.7150 | 3.03 | **rejected**: −28% recall at 1536-D |
|
||||||
|
|
||||||
|
The footprint is `USearch`'s true `memory_usage()` (graph links + quantized
|
||||||
|
vectors), so F16 is **≈ 5.2–5.5 GB/1M**, not the ~3.4 GB a vectors-only estimate
|
||||||
|
(`1536 × 2 B`) gives — the ~1.8 GB difference is the HNSW graph. Size pods at
|
||||||
|
≈ 5.5 GB/1M for F16. **Int8's 1.8 GB option is rejected**: it loses a quarter of
|
||||||
|
the neighbours at 1536-D and would need quantization-aware scaling first.
|
||||||
|
|
||||||
|
### Per-query `ef_search` is now honored
|
||||||
|
|
||||||
|
The `ef_search` column in the frontier table is a **per-request** knob as of
|
||||||
|
m12p3 (it was silently ignored before). A low-latency surface can request a
|
||||||
|
narrow beam and a high-recall surface a wide one against the same shared index,
|
||||||
|
concurrently — see [usearch-tuning.md](usearch-tuning.md#per-query-ef_search-m12p3-now-honored).
|
||||||
|
`tidal-stress --verify-recall --recall-ef-search <N>` sweeps it open-loop.
|
||||||
|
|
||||||
|
### The 1M run
|
||||||
|
|
||||||
|
The 100k numbers above fit one laptop (the F32 oracle is ≈ 0.6 GB). The 1M
|
||||||
|
exit-gate run needs ≈ 6 GB for the oracle plus the HNSW, so it runs on the k3s
|
||||||
|
node:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run --release --example ann_grid_search -- \
|
||||||
|
--corpus 1000000 --dim 1536 --queries 200 --k 10
|
||||||
|
```
|
||||||
|
|||||||
@ -38,12 +38,21 @@ positive engagements — approximately once per active user session.
|
|||||||
|
|
||||||
### Benchmark Results
|
### Benchmark Results
|
||||||
|
|
||||||
| Depth | Mean Latency | p99 Latency | Notes |
|
> **Measurement contract:** the `social` benchmark is Criterion — it reports an
|
||||||
|-------|-------------|-------------|-------|
|
> **isolated per-op cost (mean, single-threaded, closed loop)**, NOT a latency
|
||||||
| Depth-1 (followed creator items) | ~3ms | ~8ms | Direct bitmap union over 100 creators |
|
> distribution. A closed loop cannot observe the tail it hides (it stops sending
|
||||||
| Depth-2 (co-followers' seen items) | ~25ms | ~45ms | Fan-out: 100 creators × 500 followers |
|
> when the system stalls), so these means are **regression tripwires**, not p99
|
||||||
|
> evidence. The depth-2 < 50ms TAIL SLO is signed off only by the open-loop
|
||||||
|
> `tidal-stress` ramp — see [`scale-baselines.md`](scale-baselines.md).
|
||||||
|
|
||||||
**Target: p99 < 50ms — ✅ achieved**
|
| Depth | Isolated per-op cost (mean, closed-loop) | Notes |
|
||||||
|
|-------|------------------------------------------|-------|
|
||||||
|
| Depth-1 (followed creator items) | ~3ms | Direct bitmap union over 100 creators |
|
||||||
|
| Depth-2 (co-followers' seen items) | ~25ms | Fan-out: 100 creators × 500 followers |
|
||||||
|
|
||||||
|
**Tail SLO: depth-2 p99 < 50ms** — the ~25ms *mean* clears the budget with
|
||||||
|
headroom (a necessary, not sufficient, condition); the p99 itself is validated
|
||||||
|
open-loop, never by this closed-loop mean.
|
||||||
|
|
||||||
_Run: `cargo bench --manifest-path tidal/Cargo.toml --bench social -- social_graph_1m`_
|
_Run: `cargo bench --manifest-path tidal/Cargo.toml --bench social -- social_graph_1m`_
|
||||||
|
|
||||||
|
|||||||
@ -2,81 +2,185 @@
|
|||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
Grid search result: **M=16, ef_construction=400** is the optimal default for tidalDB.
|
The production default (`VectorIndexConfig::default()`) is **M=16,
|
||||||
|
ef_construction=400, ef_search=200, F16**. m12p3 re-ran the grid search at the
|
||||||
|
**production shape (1536-D)** — not the historical 128-D — with a real exact
|
||||||
|
brute-force oracle, and validated the F16/Int8 recall and memory frontier there.
|
||||||
|
|
||||||
The production default (`VectorIndexConfig::default()`) was updated to `ef_construction=400`
|
> **What changed in m12p3.** The earlier version of this doc reported an
|
||||||
from `ef_construction=200`. The improvement in recall@10 (~1.5%) justifies the ~2× build overhead
|
> *extrapolated* 128-D table (values from published ANN-Benchmarks, not measured
|
||||||
for a write-rarely, read-frequently index.
|
> here). That is replaced below by **measured** 1536-D numbers from
|
||||||
|
> `cargo run --release --example ann_grid_search`. The example builds a
|
||||||
|
> `UsearchIndex` and a `BruteForceIndex` oracle over the same deterministic,
|
||||||
|
> id-keyed corpus and reports recall@10 vs the exact oracle, single-thread
|
||||||
|
> mean/p99 search latency, build time, and the true `memory_usage()` footprint.
|
||||||
|
|
||||||
## Grid Search Setup
|
## Grid Search Setup (m12p3, measured)
|
||||||
|
|
||||||
| Parameter | Values |
|
| Parameter | Values |
|
||||||
|-----------|--------|
|
|-----------|--------|
|
||||||
| M (connectivity) | 8, 16, 32 |
|
| M (connectivity) | 16, 24, 32 |
|
||||||
| ef_construction | 100, 200, 400 |
|
| ef_construction | 400 |
|
||||||
| ef_search (fixed) | 200 |
|
| ef_search (per-query) | 128, 200, 400, 600 |
|
||||||
| Dataset size | 100K vectors |
|
| Quantization | F32, F16, Int8 |
|
||||||
| Dimensionality | 128D |
|
| Dataset size | 100,000 vectors (and 1M on the k3s node) |
|
||||||
|
| Dimensionality | **1536D** (production embedding width) |
|
||||||
|
| Corpus shape | **clustered (Gaussian mixture)** — see below for why not uniform-random |
|
||||||
| Distance metric | L2 (L2-normalized → equivalent to cosine) |
|
| Distance metric | L2 (L2-normalized → equivalent to cosine) |
|
||||||
| Recall metric | recall@10 (100 query average) |
|
| Recall metric | recall@10 vs exact brute-force cosine (200-query average) |
|
||||||
|
| Hardware | Apple Silicon laptop (release build) |
|
||||||
|
|
||||||
## Method
|
## Method
|
||||||
|
|
||||||
1. Build `UsearchIndex` for each of the 9 `(M, ef_construction)` configurations
|
1. Build a deterministic, id-keyed **clustered** corpus (SplitMix64 Gaussian
|
||||||
2. Build `BruteForceIndex` as ground truth
|
mixture — see below) so the run is reproducible, the recall numbers are
|
||||||
3. Run 100 random unit vector queries, compute `recall@K = |HNSW∩Brute| / K`
|
comparable across machines, and recall@k is a meaningful metric at scale.
|
||||||
4. Record: recall@10, mean search latency (µs), p99 latency, build time (s)
|
2. Build a `BruteForceIndex` (F32) over the same vectors and compute the exact
|
||||||
|
top-10 for every query **once** — the ground truth the HNSW approximates.
|
||||||
|
3. For each `(M, ef_construction)` build the graph once and sweep `ef_search`
|
||||||
|
(a per-query knob — no rebuild). For quantization, build F32/F16/Int8 at the
|
||||||
|
recall-frontier graph.
|
||||||
|
4. Record recall@10, mean + p99 search latency (µs), build time (s), and the true
|
||||||
|
in-memory footprint, extrapolated to 1M vectors.
|
||||||
|
|
||||||
## Results
|
### The corpus must be clustered, not uniform-random
|
||||||
|
|
||||||
Results below are representative based on published HNSW benchmarks (ANN-Benchmarks,
|
A subtle but decisive measurement point: **uniform-random high-dimensional
|
||||||
Malkov & Yashunin, 2018) for 128D random unit vectors at 100K scale.
|
vectors are a pathological ANN benchmark.** By concentration of measure, every
|
||||||
|
pair of random unit vectors in 1536-D sits at cosine ≈ 0, so beyond a tiny
|
||||||
|
perturbation a query's true top-10 is an arbitrary draw from a thick equidistant
|
||||||
|
shell — recall@10 then measures impossible tie-breaking, not index quality, and
|
||||||
|
it gets **worse as the corpus grows** (the shell thickens). Measured on
|
||||||
|
uniform-random data, recall@10 fell from ~0.97 at 10k to **~0.54 at 100k** — a
|
||||||
|
corpus-size artifact, not an index regression.
|
||||||
|
|
||||||
> **Note on data source:** The recall and latency values in this table are estimates
|
Real text/image embeddings instead live on a low-dimensional manifold with
|
||||||
> extrapolated from published ANN-Benchmarks results (Malkov & Yashunin, 2018) for
|
clusters: a point's neighbours are its cluster-mates, distinctly closer than the
|
||||||
> 128D random unit vectors. They are provided as reference, not as measured values
|
bulk. The harness therefore builds a **Gaussian mixture** (`--clusters`,
|
||||||
> from this codebase.
|
`--spread-milli`; default ~100 points/cluster, spread 0.5 ⇒ intra-cluster cosine
|
||||||
>
|
≈ 0.89, inter ≈ 0) — the shape `related`/`for_you` queries actually run against,
|
||||||
> **The authoritative quality guard** is the regression test in
|
where recall@10 is a meaningful, scale-stable metric. All numbers below use it.
|
||||||
> `tidal/tests/vector_usearch.rs` (`recall_at_10_above_threshold`), which verifies
|
|
||||||
> recall@10 > 0.95 for the default config (M=16, ef_construction=400) on every CI run.
|
|
||||||
|
|
||||||
| M | ef_construction | recall@10 | mean latency (µs) | p99 latency (µs) | build time (s) |
|
## Results — HNSW parameter sweep (1536D, F16, 100k clustered corpus, measured)
|
||||||
|---|----------------|-----------|-------------------|------------------|----------------|
|
|
||||||
| 8 | 100 | ~0.942 | ~85 | ~140 | ~2.1 |
|
|
||||||
| 8 | 200 | ~0.967 | ~88 | ~145 | ~3.8 |
|
|
||||||
| 8 | 400 | ~0.975 | ~90 | ~148 | ~7.2 |
|
|
||||||
| **16** | **100** | **~0.966** | **~95** | **~160** | **~4.3** |
|
|
||||||
| **16** | **200** | **~0.978** | **~98** | **~165** | **~8.1** |
|
|
||||||
| **16** | **400** | **~0.993** | **~101** | **~170** | **~15.2** |
|
|
||||||
| 32 | 100 | ~0.975 | ~115 | ~195 | ~9.8 |
|
|
||||||
| 32 | 200 | ~0.985 | ~118 | ~200 | ~18.5 |
|
|
||||||
| 32 | 400 | ~0.995 | ~122 | ~205 | ~35.1 |
|
|
||||||
|
|
||||||
_Run `cargo bench --manifest-path tidal/Cargo.toml --bench vector` to collect actual
|
| M / ef_c / ef_s | recall@10 | mean (µs) | p99 (µs) | build (s) | mem/1M (GB) |
|
||||||
measurements on target hardware._
|
|---|---|---|---|---|---|
|
||||||
|
| **16 / 400 / 128** | **0.9970** | **670** | **1018** | 19.7 | 5.21 |
|
||||||
|
| 16 / 400 / 200 | 0.9970 | 1129 | 1394 | 19.7 | 5.21 |
|
||||||
|
| 16 / 400 / 400 | 0.9970 | 2292 | 2719 | 19.7 | 5.21 |
|
||||||
|
| 16 / 400 / 600 | 0.9970 | 3477 | 4255 | 19.7 | 5.21 |
|
||||||
|
| 24 / 400 / 128 | 0.9985 | 1074 | 1299 | 39.0 | 5.53 |
|
||||||
|
| 24 / 400 / 200 | 0.9985 | 1812 | 2239 | 39.0 | 5.53 |
|
||||||
|
| 24 / 400 / 400 | 0.9985 | 3536 | 4088 | 39.0 | 5.53 |
|
||||||
|
| 32 / 400 / 200 | 0.9975 | 2331 | 2857 | 42.5 | 5.53 |
|
||||||
|
|
||||||
|
Every point clears recall@10 ≥ 0.95 **and** p99 ≤ 10ms (raw single-thread ANN
|
||||||
|
cost). Two readings:
|
||||||
|
|
||||||
|
- **ef_search is the latency lever, and recall saturates early.** At M=16, recall
|
||||||
|
is already 0.997 at ef_s=128 (**p99 1.0ms**) and does not improve with a wider
|
||||||
|
beam — only latency grows (ef_s=600 → p99 4.3ms). So the cheap operating point
|
||||||
|
is ef_s≈128; the [per-query override](#per-query-ef_search-m12p3-now-honored)
|
||||||
|
lets a latency-sensitive caller pick it without rebuilding.
|
||||||
|
- **M=24 buys ~0.15% recall for ~1.6× latency and 2× build** — worth it only when
|
||||||
|
the last fraction of recall matters. M=32 is not better than M=24 here.
|
||||||
|
|
||||||
|
## Results — quantization sweep (1536D, M=24, ef_c=400, ef_s=400, 100k, measured)
|
||||||
|
|
||||||
|
| quantization | recall@10 | mean (µs) | p99 (µs) | mem/1M (GB) |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| F32 | 0.9985 | 3642 | 5126 | 10.53 |
|
||||||
|
| **F16** | **0.9960** | 3569 | 4680 | **5.53** |
|
||||||
|
| Int8 | 0.7150 | 1389 | 1678 | 3.03 |
|
||||||
|
|
||||||
|
- **F16 is the right default:** it costs only **0.25%** recall vs F32 (0.9960 vs
|
||||||
|
0.9985) for **half the memory** — validating the "<1% recall loss" claim *at
|
||||||
|
1536-D*, not just the 128-D it was previously checked at.
|
||||||
|
- **Int8 is NOT viable at 1536-D as a drop-in:** recall collapses to **0.715**
|
||||||
|
(a 28% loss). The memory saving (3.0 vs 5.5 GB/1M) is real but does not justify
|
||||||
|
losing a quarter of the neighbours; Int8 would need quantization-aware scaling
|
||||||
|
before it is usable. The roadmap's "Int8 ≈ 1.8 GB/1M as a RAM fallback" option
|
||||||
|
is therefore **rejected** at this dim on accuracy grounds.
|
||||||
|
|
||||||
|
> **Memory note.** The per-1M figures are the *true* `USearch` footprint
|
||||||
|
> (`memory_usage()`, graph links + quantized vectors), not a vectors-only
|
||||||
|
> estimate. F16 ≈ **5.2 GB/1M**, not the ~3.4 GB the roadmap projected from
|
||||||
|
> `1536 × 2 bytes` alone — the ~1.8 GB difference is the HNSW proximity graph
|
||||||
|
> (M=16). Size pods at ≈ 5.5 GB/1M for F16, ≈ 10.5 GB/1M for F32.
|
||||||
|
|
||||||
|
## Live cross-check (real engine, HTTP)
|
||||||
|
|
||||||
|
The standalone grid above measures the index in isolation. The authoritative
|
||||||
|
end-to-end number is `tidal-stress --verify-recall` against a real server: at
|
||||||
|
20k/1536-D (clustered), the engine's `/vector_search` returned **recall@10 =
|
||||||
|
1.0000** vs the brute-force cosine oracle at the default beam and at
|
||||||
|
`--recall-ef-search 400` — confirming both the index quality and that the
|
||||||
|
per-query `ef_search` is honored over the wire. (The harness's end-to-end p99 of
|
||||||
|
~19 ms there is HTTP round-trip + 1536-float (de)serialization on one laptop, not
|
||||||
|
ANN — the raw search is ~1 ms per the grid; it is a single-node wire cost, not an
|
||||||
|
index limit.)
|
||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
**Chosen: M=16, ef_construction=400**
|
**Default stays M=16, ef_construction=400, ef_search=200, F16** — the grid
|
||||||
|
*validates* it at the production shape: recall@10 0.997, p99 ≈ 1.4 ms raw ANN at
|
||||||
|
100k/1536-D clustered, 5.2 GB/1M. No change is warranted.
|
||||||
|
|
||||||
Rationale:
|
- **Latency-sensitive reads** can drop to `ef_search=128` per request (recall
|
||||||
- M=16 provides the best recall/memory trade-off (standard recommendation from Malkov & Yashunin)
|
still 0.997, p99 ≈ 1.0 ms) via the per-query override.
|
||||||
- ef_construction=400 achieves recall@10 ≈ 0.993, well above the 0.95 acceptance threshold
|
- **Maximum recall** (e.g. an offline eval) can use M=24, recall 0.9985, at ~1.6×
|
||||||
- Build overhead vs. ef=200: ~2× slower build, negligible impact for tidalDB's write-rarely pattern
|
latency — but M=24 is not the default because M=16 already clears G2 (≥0.95) and
|
||||||
- M=32 adds ~1-3% additional recall but doubles graph memory — not worth the trade-off at 1M items
|
G1 (≤10 ms) with wide margin.
|
||||||
|
- **F32** only if an application cannot tolerate the 0.25% F16 gap; doubles RAM.
|
||||||
|
- **Int8 rejected** at 1536-D (recall 0.715).
|
||||||
|
|
||||||
**Rejected: M=32, ef_construction=400**
|
## Per-query `ef_search` (m12p3: now honored)
|
||||||
Reason: ~4× memory overhead vs M=16 with only ~0.2% additional recall.
|
|
||||||
|
`M` and `ef_construction` are graph properties fixed at build time. `ef_search` is
|
||||||
|
a **per-query** knob: a low-latency query can pass `ef_search=64` and a
|
||||||
|
high-recall query `ef_search=400` against the *same shared index*, concurrently.
|
||||||
|
|
||||||
|
Before m12p3, `UsearchIndex` accepted `ef_search` for trait compliance but
|
||||||
|
**ignored it** (logging a warning) because `USearch` 2.24 has no per-call beam
|
||||||
|
argument — the only knob is the index-global `change_expansion_search`. m12p3
|
||||||
|
makes the override real and race-free via an `RwLock` epoch guard
|
||||||
|
(`UsearchIndex::with_expansion`): searches that agree on `ef_search` run in
|
||||||
|
parallel under a shared guard; only a query that changes the live beam width
|
||||||
|
takes the exclusive guard for its `(set, search)` window. The override reaches the
|
||||||
|
wire through `vector_search_items(.., ef_search)` and the `/vector_search`
|
||||||
|
`ef_search` field, and `tidal-stress --verify-recall --recall-ef-search` sweeps it
|
||||||
|
open-loop.
|
||||||
|
|
||||||
|
The `ef_search` axis in the table above is the recall/latency trade this knob now
|
||||||
|
controls per request — higher `ef_search` recovers more true neighbours at a
|
||||||
|
latency cost, exactly as the HNSW theory predicts (spec
|
||||||
|
[07-vector-retrieval.md](../specs/07-vector-retrieval.md)).
|
||||||
|
|
||||||
|
## Brute-force → HNSW crossover (m12p3: dimension-aware)
|
||||||
|
|
||||||
|
The brute-force backend (exact, used for small slots) scans every vector under a
|
||||||
|
read lock, at `count × dim` cost. A single fixed crossover is wrong: 10,000
|
||||||
|
vectors is a 1.3M-FMA scan at 128-D (sub-ms) but a 15.4M-FMA scan at 1536-D (tens
|
||||||
|
of ms, under a lock that also blocks writers). `usearch_min_vectors(dim)` now
|
||||||
|
scales the crossover so a brute-force scan stays within ~4M FMAs: ≈10,000 at/under
|
||||||
|
128-D (unchanged), ≈2,600 at 1536-D. Above the crossover a slot is the production
|
||||||
|
HNSW; below it stays exact. See `storage/vector/registry.rs`.
|
||||||
|
|
||||||
## Regression Guard
|
## Regression Guard
|
||||||
|
|
||||||
The `recall_at_10_above_threshold` test in `tidal/tests/vector_usearch.rs` verifies:
|
`tidal/tests/vector_usearch.rs`:
|
||||||
- Default config (M=16, ef_construction=400) achieves recall@10 > 0.95 at 1K vectors / 128D
|
- `recall_at_10_above_threshold` — default config (M=16, ef_c=400) recall@10 > 0.95.
|
||||||
- Runs on every CI push to catch parameter regressions
|
- `usearch_per_query_ef_search_is_honored` — a wide per-query `ef_search` recovers
|
||||||
|
strictly more true neighbours than a starved beam (proves the override is live).
|
||||||
|
|
||||||
## ef_search Note
|
## Reproduce
|
||||||
|
|
||||||
`ef_search=200` (fixed during grid search) is the default search-time beam width.
|
```bash
|
||||||
Increasing ef_search improves recall at query time at the cost of latency.
|
# Production shape on one laptop (oracle ≈ 0.6 GB at 100k/1536D):
|
||||||
For tidalDB's p99 < 50ms RETRIEVE target, ef_search=200 is appropriate.
|
cargo run --release --example ann_grid_search -- \
|
||||||
|
--corpus 100000 --dim 1536 --queries 200 --k 10
|
||||||
|
|
||||||
|
# 1M shape (oracle ≈ 6 GB; run on the k3s node):
|
||||||
|
cargo run --release --example ann_grid_search -- \
|
||||||
|
--corpus 1000000 --dim 1536 --queries 200 --k 10
|
||||||
|
```
|
||||||
|
|||||||
@ -94,6 +94,19 @@ The key insight: upper layers provide logarithmic navigation to the right neighb
|
|||||||
| **Low-latency (autocomplete, typeahead)** | 16 | 200 | 100 | ef_search=100 halves query time with ~2% recall loss. Acceptable for suggestion candidates that are re-ranked anyway. |
|
| **Low-latency (autocomplete, typeahead)** | 16 | 200 | 100 | ef_search=100 halves query time with ~2% recall loss. Acceptable for suggestion candidates that are re-ranked anyway. |
|
||||||
| **Bulk rebuild (compaction, recovery)** | 16 | 128 | -- | Lower ef_construction for faster rebuilds during compaction. Graph quality is slightly lower but rebuilt indexes serve queries immediately; a background process can rebuild with ef_construction=200 later. |
|
| **Bulk rebuild (compaction, recovery)** | 16 | 128 | -- | Lower ef_construction for faster rebuilds during compaction. Graph quality is slightly lower but rebuilt indexes serve queries immediately; a background process can rebuild with ef_construction=200 later. |
|
||||||
|
|
||||||
|
> **Per-query `ef_search` override — IMPLEMENTED (m12p3).** `M` and
|
||||||
|
> `ef_construction` are graph properties fixed at build time, but `ef_search` is a
|
||||||
|
> per-request knob: the same shared index can serve a low-latency query at
|
||||||
|
> `ef_search=100` and a high-recall query at `ef_search=400` concurrently.
|
||||||
|
> `UsearchIndex` honors a per-query `ef_search` race-free via an `RwLock` epoch
|
||||||
|
> guard (`UsearchIndex::with_expansion`): same-`ef` searches run in parallel under
|
||||||
|
> a shared guard; only a query that changes the live beam width briefly takes the
|
||||||
|
> exclusive guard. `0` selects the slot's construction default. This reaches the
|
||||||
|
> wire through `vector_search_items(.., ef_search)` and the `/vector_search`
|
||||||
|
> `ef_search` field. (Before m12p3, `UsearchIndex` accepted `ef_search` for trait
|
||||||
|
> compliance but ignored it with a warning — `USearch` 2.24 has no per-call beam
|
||||||
|
> argument, only the index-global `change_expansion_search`.)
|
||||||
|
|
||||||
### Distance Metrics
|
### Distance Metrics
|
||||||
|
|
||||||
tidalDB uses **L2 distance over L2-normalized vectors** as the universal distance metric. This is mathematically equivalent to cosine distance for unit vectors:
|
tidalDB uses **L2 distance over L2-normalized vectors** as the universal distance metric. This is mathematically equivalent to cosine distance for unit vectors:
|
||||||
|
|||||||
@ -99,7 +99,8 @@ use super::{
|
|||||||
use crate::{
|
use crate::{
|
||||||
dto::{
|
dto::{
|
||||||
EmbeddingRequest, FeedQuery, FeedResponse, ItemRequest, SearchQueryParams, SearchResponse,
|
EmbeddingRequest, FeedQuery, FeedResponse, ItemRequest, SearchQueryParams, SearchResponse,
|
||||||
SignalRequest, feed_items, search_items,
|
SignalRequest, VectorSearchRequest, VectorSearchResponse, feed_items, search_items,
|
||||||
|
vector_matches,
|
||||||
},
|
},
|
||||||
error::{Result, ServerError},
|
error::{Result, ServerError},
|
||||||
offload::{ClusterWritePool, offload_read},
|
offload::{ClusterWritePool, offload_read},
|
||||||
@ -4074,6 +4075,7 @@ pub fn build_region_router(
|
|||||||
.route("/hardnegs", post(write_hardneg))
|
.route("/hardnegs", post(write_hardneg))
|
||||||
.route("/feed", get(feed))
|
.route("/feed", get(feed))
|
||||||
.route("/search", get(search))
|
.route("/search", get(search))
|
||||||
|
.route("/vector_search", post(vector_search))
|
||||||
.route("/cluster/promote", post(cluster_promote))
|
.route("/cluster/promote", post(cluster_promote))
|
||||||
.route("/cluster/partition", post(cluster_partition))
|
.route("/cluster/partition", post(cluster_partition))
|
||||||
.route("/cluster/heal", post(cluster_heal))
|
.route("/cluster/heal", post(cluster_heal))
|
||||||
@ -6191,6 +6193,10 @@ pub async fn feed(
|
|||||||
if let Some(user_id) = query.user_id {
|
if let Some(user_id) = query.user_id {
|
||||||
builder = builder.for_user(user_id);
|
builder = builder.for_user(user_id);
|
||||||
}
|
}
|
||||||
|
// m12p2: "more like this" seed for `profile=related` ANN candidate-gen.
|
||||||
|
if let Some(seed) = query.similar_to {
|
||||||
|
builder = builder.similar_to(EntityId::new(seed));
|
||||||
|
}
|
||||||
let retrieve = builder
|
let retrieve = builder
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| ClusterAppError(ServerError::Tidal(e.into())))?;
|
.map_err(|e| ClusterAppError(ServerError::Tidal(e.into())))?;
|
||||||
@ -6294,6 +6300,63 @@ pub async fn search(
|
|||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pure k-NN vector search (the m12p1 recall probe). Serves LOCALLY from this
|
||||||
|
/// node's hosted shard groups — no `?region=` forwarding: it is a measurement
|
||||||
|
/// surface, and at the S=1 exit-gate shape every region replica holds the full
|
||||||
|
/// corpus, so any node answers the whole-corpus nearest set. With S>1 the probe
|
||||||
|
/// merges each hosted group's local nearest by distance; whole-corpus recall is
|
||||||
|
/// then bounded by the cross-shard merge (the m12p4 cross-shard read follow-up).
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/vector_search",
|
||||||
|
tag = "data",
|
||||||
|
request_body = VectorSearchRequest,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Nearest items by vector distance, closest-first", body = VectorSearchResponse),
|
||||||
|
(status = 400, description = "Empty/dimension-mismatched query vector, or no embedding slot"),
|
||||||
|
(status = 401, description = "Missing or invalid API key"),
|
||||||
|
),
|
||||||
|
security(("bearerAuth" = [])),
|
||||||
|
)]
|
||||||
|
pub async fn vector_search(
|
||||||
|
State(node): State<Arc<ClusterNode>>,
|
||||||
|
Json(req): Json<VectorSearchRequest>,
|
||||||
|
) -> std::result::Result<Response, ClusterAppError> {
|
||||||
|
if req.vector.is_empty() {
|
||||||
|
return Err(ClusterAppError(ServerError::BadRequest(
|
||||||
|
"vector_search requires a non-empty query vector".into(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let k = req.clamped_k();
|
||||||
|
let ef_search = req.ef_search();
|
||||||
|
let vector = req.vector;
|
||||||
|
|
||||||
|
let dbs = node.hosted_dbs();
|
||||||
|
let (items, _total) = offload_region_read(move || {
|
||||||
|
scatter_merge(
|
||||||
|
&dbs,
|
||||||
|
k,
|
||||||
|
// Distance is "lower = better"; scatter_merge ranks by "higher =
|
||||||
|
// better", so the merge key is the negated distance.
|
||||||
|
|r: &tidaldb::storage::vector::VectorSearchResult| -f64::from(r.distance),
|
||||||
|
|db| {
|
||||||
|
let r = db
|
||||||
|
.vector_search_items(&vector, k, ef_search)
|
||||||
|
.map_err(ServerError::Tidal)?;
|
||||||
|
let n = r.len();
|
||||||
|
Ok((r, n))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(VectorSearchResponse {
|
||||||
|
items: vector_matches(&items),
|
||||||
|
region: None,
|
||||||
|
})
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
/// If `region` names a DIFFERENT region than this node owns, forward the read
|
/// If `region` names a DIFFERENT region than this node owns, forward the read
|
||||||
/// (verbatim query string, marker set, auth passed through) to that region's
|
/// (verbatim query string, marker set, auth passed through) to that region's
|
||||||
/// process and relay its response. Returns `Ok(None)` when the read should be
|
/// process and relay its response. Returns `Ok(None)` when the read should be
|
||||||
|
|||||||
@ -448,6 +448,10 @@ pub async fn feed(
|
|||||||
if let Some(user_id) = query.user_id {
|
if let Some(user_id) = query.user_id {
|
||||||
builder = builder.for_user(user_id);
|
builder = builder.for_user(user_id);
|
||||||
}
|
}
|
||||||
|
// m12p2: "more like this" seed for `profile=related` ANN candidate-gen.
|
||||||
|
if let Some(seed) = query.similar_to {
|
||||||
|
builder = builder.similar_to(EntityId::new(seed));
|
||||||
|
}
|
||||||
let retrieve = builder
|
let retrieve = builder
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| ClusterAppError(ServerError::Tidal(e.into())))?;
|
.map_err(|e| ClusterAppError(ServerError::Tidal(e.into())))?;
|
||||||
|
|||||||
@ -11,7 +11,10 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tidaldb::query::{retrieve::RetrieveResult, search::SearchResultItem};
|
use tidaldb::{
|
||||||
|
query::{retrieve::RetrieveResult, search::SearchResultItem},
|
||||||
|
storage::vector::VectorSearchResult,
|
||||||
|
};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
// ── Request DTOs ─────────────────────────────────────────────────────────────
|
// ── Request DTOs ─────────────────────────────────────────────────────────────
|
||||||
@ -65,6 +68,58 @@ pub struct SignalRequest {
|
|||||||
pub creator_id: Option<u64>,
|
pub creator_id: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `POST /vector_search` body — the m12p1 recall-measurement probe.
|
||||||
|
///
|
||||||
|
/// A raw query vector goes in the body (1536 floats do not belong in a GET query
|
||||||
|
/// string), and the engine returns the `k` items whose stored content embedding
|
||||||
|
/// is nearest by the slot's distance metric — **no** profile scoring, fusion, or
|
||||||
|
/// diversity. Comparing this against a brute-force cosine ground truth yields the
|
||||||
|
/// ANN recall@k the harness reports.
|
||||||
|
///
|
||||||
|
/// `Serialize` so the cluster region node can forward the verbatim body to a peer
|
||||||
|
/// region on a `?region=` read, exactly like the other write/read DTOs.
|
||||||
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct VectorSearchRequest {
|
||||||
|
/// Dense query vector. Must match the item content slot's dimensionality.
|
||||||
|
pub vector: Vec<f32>,
|
||||||
|
/// Number of nearest neighbors to return; clamped to `MAX_LIMIT` (1000) at
|
||||||
|
/// the trust boundary. Defaults to 10 (the recall@10 target's `k`).
|
||||||
|
#[serde(default = "default_k")]
|
||||||
|
#[schema(example = 10)]
|
||||||
|
pub k: u32,
|
||||||
|
/// Optional per-request HNSW beam width override (m12p3 makes this the
|
||||||
|
/// recall/latency knob). Omitted = the slot's configured default.
|
||||||
|
#[serde(default)]
|
||||||
|
pub ef_search: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VectorSearchRequest {
|
||||||
|
/// The requested `k` clamped to [`MAX_LIMIT`] — the single enforcement point
|
||||||
|
/// for the network trust boundary, mirroring the `/feed` and `/search`
|
||||||
|
/// `clamped_limit`. An oversized `k` can never size the engine's result
|
||||||
|
/// buffer.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn clamped_k(&self) -> usize {
|
||||||
|
(if self.k > MAX_LIMIT {
|
||||||
|
MAX_LIMIT
|
||||||
|
} else {
|
||||||
|
self.k
|
||||||
|
}) as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `ef_search` override as a `usize`, if any.
|
||||||
|
#[must_use]
|
||||||
|
pub fn ef_search(&self) -> Option<usize> {
|
||||||
|
self.ef_search.map(|v| v as usize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default `k` (nearest-neighbor count) when `k` is omitted: the recall@10 `k`.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn default_k() -> u32 {
|
||||||
|
10
|
||||||
|
}
|
||||||
|
|
||||||
/// Maximum page size a client may request via `?limit=`.
|
/// Maximum page size a client may request via `?limit=`.
|
||||||
///
|
///
|
||||||
/// `limit` arrives across the network trust boundary and feeds the engine's
|
/// `limit` arrives across the network trust boundary and feeds the engine's
|
||||||
@ -97,6 +152,12 @@ pub struct FeedQuery {
|
|||||||
/// Target region (cluster mode only; rejected with 400 standalone).
|
/// Target region (cluster mode only; rejected with 400 standalone).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub region: Option<String>,
|
pub region: Option<String>,
|
||||||
|
/// Seed item for "more like this" (m12p2): with `profile=related`, the
|
||||||
|
/// engine resolves this item's embedding and sources candidates by ANN
|
||||||
|
/// nearest-neighbour over it. Ignored by profiles that do not use it.
|
||||||
|
#[serde(default)]
|
||||||
|
#[param(example = 42)]
|
||||||
|
pub similar_to: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FeedQuery {
|
impl FeedQuery {
|
||||||
@ -226,6 +287,41 @@ pub struct SearchItem {
|
|||||||
pub semantic_score: Option<f64>,
|
pub semantic_score: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `POST /vector_search` response body — the raw ANN result, closest-first.
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct VectorSearchResponse {
|
||||||
|
/// Nearest items, ordered by ascending distance (closest first).
|
||||||
|
pub items: Vec<VectorMatch>,
|
||||||
|
/// Region the search was served from (cluster mode); `null` standalone.
|
||||||
|
pub region: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One nearest-neighbor match: the entity and its distance from the query.
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct VectorMatch {
|
||||||
|
/// Entity ID of the matched item.
|
||||||
|
pub entity_id: u64,
|
||||||
|
/// L2-squared distance from the query vector (lower = more similar). For the
|
||||||
|
/// L2-normalized vectors tidalDB stores, this lies in `[0.0, 4.0]` and is
|
||||||
|
/// monotonic with cosine distance.
|
||||||
|
pub distance: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map one engine [`VectorSearchResult`] into a wire [`VectorMatch`].
|
||||||
|
#[must_use]
|
||||||
|
pub const fn vector_match(r: &VectorSearchResult) -> VectorMatch {
|
||||||
|
VectorMatch {
|
||||||
|
entity_id: r.id,
|
||||||
|
distance: r.distance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a slice of engine [`VectorSearchResult`]s into wire [`VectorMatch`]es.
|
||||||
|
#[must_use]
|
||||||
|
pub fn vector_matches(items: &[VectorSearchResult]) -> Vec<VectorMatch> {
|
||||||
|
items.iter().map(vector_match).collect()
|
||||||
|
}
|
||||||
|
|
||||||
// ── Engine-result → DTO mapping ──────────────────────────────────────────────
|
// ── Engine-result → DTO mapping ──────────────────────────────────────────────
|
||||||
|
|
||||||
/// Map one engine [`RetrieveResult`] into a wire [`FeedItem`].
|
/// Map one engine [`RetrieveResult`] into a wire [`FeedItem`].
|
||||||
@ -289,6 +385,7 @@ mod tests {
|
|||||||
profile: default_profile(),
|
profile: default_profile(),
|
||||||
limit,
|
limit,
|
||||||
region: None,
|
region: None,
|
||||||
|
similar_to: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -90,6 +90,7 @@ impl Modify for SecurityAddon {
|
|||||||
crate::router::write_signal,
|
crate::router::write_signal,
|
||||||
crate::router::feed,
|
crate::router::feed,
|
||||||
crate::router::search,
|
crate::router::search,
|
||||||
|
crate::router::vector_search,
|
||||||
),
|
),
|
||||||
components(schemas(
|
components(schemas(
|
||||||
crate::dto::ItemRequest,
|
crate::dto::ItemRequest,
|
||||||
@ -100,6 +101,9 @@ impl Modify for SecurityAddon {
|
|||||||
crate::dto::SignalValue,
|
crate::dto::SignalValue,
|
||||||
crate::dto::SearchResponse,
|
crate::dto::SearchResponse,
|
||||||
crate::dto::SearchItem,
|
crate::dto::SearchItem,
|
||||||
|
crate::dto::VectorSearchRequest,
|
||||||
|
crate::dto::VectorSearchResponse,
|
||||||
|
crate::dto::VectorMatch,
|
||||||
)),
|
)),
|
||||||
modifiers(&SecurityAddon),
|
modifiers(&SecurityAddon),
|
||||||
tags(
|
tags(
|
||||||
@ -209,6 +213,7 @@ pub struct ClusterApiDoc;
|
|||||||
crate::cluster::node::write_hardneg,
|
crate::cluster::node::write_hardneg,
|
||||||
crate::cluster::node::feed,
|
crate::cluster::node::feed,
|
||||||
crate::cluster::node::search,
|
crate::cluster::node::search,
|
||||||
|
crate::cluster::node::vector_search,
|
||||||
crate::cluster::node::sharded_create_item,
|
crate::cluster::node::sharded_create_item,
|
||||||
crate::cluster::node::sharded_write_embedding,
|
crate::cluster::node::sharded_write_embedding,
|
||||||
crate::cluster::node::sharded_write_signal,
|
crate::cluster::node::sharded_write_signal,
|
||||||
@ -224,6 +229,9 @@ pub struct ClusterApiDoc;
|
|||||||
crate::dto::SignalValue,
|
crate::dto::SignalValue,
|
||||||
crate::dto::SearchResponse,
|
crate::dto::SearchResponse,
|
||||||
crate::dto::SearchItem,
|
crate::dto::SearchItem,
|
||||||
|
crate::dto::VectorSearchRequest,
|
||||||
|
crate::dto::VectorSearchResponse,
|
||||||
|
crate::dto::VectorMatch,
|
||||||
crate::cluster::node::LocalStatusResponse,
|
crate::cluster::node::LocalStatusResponse,
|
||||||
crate::cluster::node::AggregatedStatusResponse,
|
crate::cluster::node::AggregatedStatusResponse,
|
||||||
crate::cluster::node::AggregatedRegionStatus,
|
crate::cluster::node::AggregatedRegionStatus,
|
||||||
@ -263,6 +271,7 @@ mod tests {
|
|||||||
"/signals",
|
"/signals",
|
||||||
"/feed",
|
"/feed",
|
||||||
"/search",
|
"/search",
|
||||||
|
"/vector_search",
|
||||||
] {
|
] {
|
||||||
assert!(paths.contains_key(p), "standalone doc missing path {p}");
|
assert!(paths.contains_key(p), "standalone doc missing path {p}");
|
||||||
}
|
}
|
||||||
@ -316,6 +325,7 @@ mod tests {
|
|||||||
"/hardnegs",
|
"/hardnegs",
|
||||||
"/feed",
|
"/feed",
|
||||||
"/search",
|
"/search",
|
||||||
|
"/vector_search",
|
||||||
"/cluster/status/local",
|
"/cluster/status/local",
|
||||||
"/cluster/status",
|
"/cluster/status",
|
||||||
"/cluster/promote",
|
"/cluster/promote",
|
||||||
|
|||||||
@ -30,7 +30,8 @@ use tower_http::{
|
|||||||
use crate::{
|
use crate::{
|
||||||
dto::{
|
dto::{
|
||||||
EmbeddingRequest, FeedQuery, FeedResponse, ItemRequest, SearchQueryParams, SearchResponse,
|
EmbeddingRequest, FeedQuery, FeedResponse, ItemRequest, SearchQueryParams, SearchResponse,
|
||||||
SignalRequest, feed_items, search_items,
|
SignalRequest, VectorSearchRequest, VectorSearchResponse, feed_items, search_items,
|
||||||
|
vector_matches,
|
||||||
},
|
},
|
||||||
error::{Result, ServerError},
|
error::{Result, ServerError},
|
||||||
state::ServerState,
|
state::ServerState,
|
||||||
@ -116,6 +117,7 @@ pub fn build_router(
|
|||||||
.route("/signals", post(write_signal))
|
.route("/signals", post(write_signal))
|
||||||
.route("/feed", get(feed))
|
.route("/feed", get(feed))
|
||||||
.route("/search", get(search))
|
.route("/search", get(search))
|
||||||
|
.route("/vector_search", post(vector_search))
|
||||||
.layer(axum::extract::DefaultBodyLimit::max(BODY_LIMIT_BYTES))
|
.layer(axum::extract::DefaultBodyLimit::max(BODY_LIMIT_BYTES))
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
@ -359,6 +361,11 @@ pub(crate) async fn feed(
|
|||||||
if let Some(user_id) = query.user_id {
|
if let Some(user_id) = query.user_id {
|
||||||
builder = builder.for_user(user_id);
|
builder = builder.for_user(user_id);
|
||||||
}
|
}
|
||||||
|
// m12p2: seed for "more like this" — `profile=related` resolves this item's
|
||||||
|
// embedding and sources candidates by ANN over it.
|
||||||
|
if let Some(seed) = query.similar_to {
|
||||||
|
builder = builder.similar_to(EntityId::new(seed));
|
||||||
|
}
|
||||||
let retrieve = builder.build().map_err(|e| TidalErrorWrapper(e.into()))?;
|
let retrieve = builder.build().map_err(|e| TidalErrorWrapper(e.into()))?;
|
||||||
|
|
||||||
// `retrieve` is a synchronous, CPU-bound engine call (candidate scan +
|
// `retrieve` is a synchronous, CPU-bound engine call (candidate scan +
|
||||||
@ -426,6 +433,49 @@ pub(crate) async fn search(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/vector_search",
|
||||||
|
tag = "data",
|
||||||
|
request_body = VectorSearchRequest,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Nearest items by vector distance, closest-first", body = VectorSearchResponse),
|
||||||
|
(status = 400, description = "Empty/dimension-mismatched query vector, or no embedding slot"),
|
||||||
|
(status = 401, description = "Missing or invalid API key"),
|
||||||
|
),
|
||||||
|
security(("bearerAuth" = [])),
|
||||||
|
)]
|
||||||
|
pub(crate) async fn vector_search(
|
||||||
|
State(state): State<Arc<ServerState>>,
|
||||||
|
Json(req): Json<VectorSearchRequest>,
|
||||||
|
) -> Result<Json<VectorSearchResponse>, AppError> {
|
||||||
|
// Reject an empty vector at the boundary with a clear 400 rather than letting
|
||||||
|
// it reach the engine as a dimension mismatch.
|
||||||
|
if req.vector.is_empty() {
|
||||||
|
return Err(AppError(ServerError::BadRequest(
|
||||||
|
"vector_search requires a non-empty query vector".into(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let k = req.clamped_k();
|
||||||
|
let ef_search = req.ef_search();
|
||||||
|
let vector = req.vector;
|
||||||
|
|
||||||
|
// Pure k-NN is a synchronous, CPU-bound index search; offload to the blocking
|
||||||
|
// pool so a burst of recall probes cannot pin a reactor worker — the same
|
||||||
|
// treatment `/feed` and `/search` give their engine calls.
|
||||||
|
let offload_state = Arc::clone(&state);
|
||||||
|
let result = crate::offload::offload_read(move || {
|
||||||
|
offload_state.vector_search(None, &vector, k, ef_search)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(AppError)?;
|
||||||
|
|
||||||
|
Ok(Json(VectorSearchResponse {
|
||||||
|
items: vector_matches(&result),
|
||||||
|
region: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
/// Readiness probe: 200 when ready, 503 when shutting down.
|
/// Readiness probe: 200 when ready, 503 when shutting down.
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
|
|||||||
@ -171,6 +171,28 @@ impl ServerState {
|
|||||||
self.db.search(query).map_err(ServerError::from)
|
self.db.search(query).map_err(ServerError::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pure k-nearest-neighbor vector search (the m12p1 recall probe) within the
|
||||||
|
/// (standalone) region. Returns the raw ANN result — no profile scoring or
|
||||||
|
/// diversity — for recall@k measurement against a brute-force ground truth.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`ServerError`] if a region is specified (standalone mode is
|
||||||
|
/// single-region) or the underlying DB vector search fails (no slot, lock
|
||||||
|
/// poisoned, or a query-vector dimension mismatch).
|
||||||
|
pub fn vector_search(
|
||||||
|
&self,
|
||||||
|
region_name: Option<&str>,
|
||||||
|
vector: &[f32],
|
||||||
|
k: usize,
|
||||||
|
ef_search: Option<usize>,
|
||||||
|
) -> Result<Vec<tidaldb::storage::vector::VectorSearchResult>> {
|
||||||
|
ensure_standalone(region_name)?;
|
||||||
|
self.db
|
||||||
|
.vector_search_items(vector, k, ef_search)
|
||||||
|
.map_err(ServerError::from)
|
||||||
|
}
|
||||||
|
|
||||||
/// Count items in the (standalone) region.
|
/// Count items in the (standalone) region.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
|
|||||||
@ -59,6 +59,46 @@ fn region_schema() -> Schema {
|
|||||||
builder.build().unwrap()
|
builder.build().unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The `region_schema` plus an 8-dim Item `content` embedding slot, so the
|
||||||
|
/// `/vector_search` recall probe (m12p1) resolves a slot and the replicated
|
||||||
|
/// embeddings get indexed on the follower.
|
||||||
|
fn region_schema_emb() -> Schema {
|
||||||
|
let mut builder = SchemaBuilder::new();
|
||||||
|
let _ = builder
|
||||||
|
.signal(
|
||||||
|
"view",
|
||||||
|
EntityKind::Item,
|
||||||
|
DecaySpec::Exponential {
|
||||||
|
half_life: Duration::from_secs(7 * 24 * 3600),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.windows(&[Window::OneHour])
|
||||||
|
.velocity(false)
|
||||||
|
.add();
|
||||||
|
builder.embedding_slot("content", EntityKind::Item, 8);
|
||||||
|
builder.build().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a region node whose schema carries the `content` embedding slot. Mirrors
|
||||||
|
/// [`build_region`] (off-reactor gRPC construction) with the embeddings schema.
|
||||||
|
fn build_region_emb(topology: TopologySpec, region: &str, dir: &tempfile::TempDir) -> ClusterNode {
|
||||||
|
let region = region.to_string();
|
||||||
|
let data_dir = dir.path().to_path_buf();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
ClusterNode::new(
|
||||||
|
&topology,
|
||||||
|
®ion,
|
||||||
|
region_schema_emb(),
|
||||||
|
Vec::new(),
|
||||||
|
Some(data_dir),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.join()
|
||||||
|
.unwrap()
|
||||||
|
.expect("region node builds with real gRPC transport")
|
||||||
|
}
|
||||||
|
|
||||||
/// Reserve a free loopback port and return its address.
|
/// Reserve a free loopback port and return its address.
|
||||||
fn free_addr() -> SocketAddr {
|
fn free_addr() -> SocketAddr {
|
||||||
TcpListener::bind("127.0.0.1:0")
|
TcpListener::bind("127.0.0.1:0")
|
||||||
@ -316,6 +356,108 @@ fn region_node_replicates_over_grpc() {
|
|||||||
rt.shutdown_timeout(Duration::from_secs(2));
|
rt.shutdown_timeout(Duration::from_secs(2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// m12p1: the region node serves `POST /vector_search` over a corpus whose
|
||||||
|
/// embeddings replicated to the follower over real gRPC. Embeddings written to
|
||||||
|
/// the leader (forwarded + WAL-replicated, kind-2) are INDEXED on the follower,
|
||||||
|
/// and a k-NN query on the FOLLOWER returns the nearest item closest-first.
|
||||||
|
#[test]
|
||||||
|
fn region_node_serves_vector_search_over_replicated_corpus() {
|
||||||
|
let pair = Pair::new();
|
||||||
|
|
||||||
|
let leader_dir = region_dir();
|
||||||
|
let leader = build_region_emb(pair.topology(), &pair.leader_name, &leader_dir);
|
||||||
|
let follower_dir = region_dir();
|
||||||
|
let follower = build_region_emb(pair.topology(), &pair.follower_name, &follower_dir);
|
||||||
|
|
||||||
|
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.worker_threads(2)
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
serve(
|
||||||
|
&rt,
|
||||||
|
build_region_router(Arc::new(leader), mk_test_creds()),
|
||||||
|
pair.leader_http,
|
||||||
|
);
|
||||||
|
serve(
|
||||||
|
&rt,
|
||||||
|
build_region_router(Arc::new(follower), mk_test_creds()),
|
||||||
|
pair.follower_http,
|
||||||
|
);
|
||||||
|
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let leader_base = format!("http://{}", pair.leader_http);
|
||||||
|
let follower_base = format!("http://{}", pair.follower_http);
|
||||||
|
|
||||||
|
// 8 items on distinct axes (item i → unit vector on axis i-1). Items are not
|
||||||
|
// WAL-replicated in this mode, so broadcast them to both nodes; embeddings ARE
|
||||||
|
// WAL-replicated, so write them only on the leader and let them flow to the
|
||||||
|
// follower.
|
||||||
|
let axis_vec = |axis: usize| -> Vec<f32> {
|
||||||
|
let mut v = vec![0.0_f32; 8];
|
||||||
|
v[axis] = 1.0;
|
||||||
|
v
|
||||||
|
};
|
||||||
|
for i in 1..=8u64 {
|
||||||
|
for base in [&leader_base, &follower_base] {
|
||||||
|
let resp = client
|
||||||
|
.post(format!("{base}/items"))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"entity_id": i,
|
||||||
|
"metadata": { "title": format!("item {i}") }
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.unwrap();
|
||||||
|
assert!(resp.status().is_success(), "POST /items: {}", resp.status());
|
||||||
|
}
|
||||||
|
let resp = client
|
||||||
|
.post(format!("{leader_base}/embeddings"))
|
||||||
|
.json(&serde_json::json!({ "entity_id": i, "values": axis_vec((i - 1) as usize) }))
|
||||||
|
.send()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
resp.status().is_success(),
|
||||||
|
"POST /embeddings on leader: {}",
|
||||||
|
resp.status()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Follower converges: all 8 embedding events applied, lag back to 0.
|
||||||
|
poll_status(&client, &follower_base, |applied, lag| {
|
||||||
|
applied >= 8 && lag == 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// k-NN on the FOLLOWER: an axis-0 query must return item 1 first (its exact
|
||||||
|
// vector), with k honored and distances ascending.
|
||||||
|
let resp = client
|
||||||
|
.post(format!("{follower_base}/vector_search"))
|
||||||
|
.json(&serde_json::json!({ "vector": axis_vec(0), "k": 3 }))
|
||||||
|
.send()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
resp.status(),
|
||||||
|
reqwest::StatusCode::OK,
|
||||||
|
"POST /vector_search on follower"
|
||||||
|
);
|
||||||
|
let body: serde_json::Value = resp.json().unwrap();
|
||||||
|
let items = body["items"].as_array().expect("items array");
|
||||||
|
assert_eq!(items.len(), 3, "k=3 nearest from the follower");
|
||||||
|
assert_eq!(
|
||||||
|
items[0]["entity_id"].as_u64(),
|
||||||
|
Some(1),
|
||||||
|
"the axis-0 item is nearest on the replicated follower; got {body}"
|
||||||
|
);
|
||||||
|
let dists: Vec<f64> = items
|
||||||
|
.iter()
|
||||||
|
.map(|it| it["distance"].as_f64().unwrap())
|
||||||
|
.collect();
|
||||||
|
for w in dists.windows(2) {
|
||||||
|
assert!(w[0] <= w[1], "distances must ascend: {dists:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
rt.shutdown_timeout(Duration::from_secs(2));
|
||||||
|
}
|
||||||
|
|
||||||
/// m11p6 sharding × replication (in-process): 2 nodes × 2 shards × RF=2, with
|
/// m11p6 sharding × replication (in-process): 2 nodes × 2 shards × RF=2, with
|
||||||
/// shard 0 led by node A and shard 1 led by node B (balanced leaders). Every
|
/// shard 0 led by node A and shard 1 led by node B (balanced leaders). Every
|
||||||
/// node hosts a replica of BOTH groups. A write routes by entity hash to its
|
/// node hosts a replica of BOTH groups. A write routes by entity hash to its
|
||||||
|
|||||||
188
tidal-server/tests/vector_search.rs
Normal file
188
tidal-server/tests/vector_search.rs
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
// Integration-test exemption (same posture as the other tidal-server tests).
|
||||||
|
#![allow(clippy::unwrap_used, clippy::cast_possible_truncation)]
|
||||||
|
//! End-to-end coverage for the m12p1 `POST /vector_search` recall probe.
|
||||||
|
//!
|
||||||
|
//! Drives the real standalone handler in-process via `tower::ServiceExt::oneshot`
|
||||||
|
//! (no TCP bind): seed items + embeddings, then POST a query vector and assert
|
||||||
|
//! the raw k-NN result — closest-first ordering, `k` honored — plus the boundary
|
||||||
|
//! 400s (empty vector, dimension mismatch). This pins the surface the
|
||||||
|
//! `tidal-stress --verify-recall` harness measures against.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
http::{Method, Request, StatusCode},
|
||||||
|
};
|
||||||
|
use tidal_server::{router::build_router, state::ServerState};
|
||||||
|
use tidaldb::TidalDb;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
/// Dimensionality of the default schema's `content_vector` slot.
|
||||||
|
const DIM: usize = 128;
|
||||||
|
|
||||||
|
fn make_app() -> axum::Router {
|
||||||
|
let (schema, profiles) = tidal_server::config::load_schema(None).unwrap();
|
||||||
|
let db = TidalDb::builder()
|
||||||
|
.ephemeral()
|
||||||
|
.with_schema(schema)
|
||||||
|
.with_profiles(profiles)
|
||||||
|
.open()
|
||||||
|
.unwrap();
|
||||||
|
let state = Arc::new(ServerState::new(db));
|
||||||
|
build_router(
|
||||||
|
state,
|
||||||
|
Arc::new(tidal_server::cluster::security::ClusterCreds::unauthenticated()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A 128-dim one-hot-ish vector: component `axis` set to `mag`, rest 0 — except
|
||||||
|
/// we nudge a second axis a hair so no vector is exactly zero-norm.
|
||||||
|
fn axis_vector(axis: usize, mag: f32) -> Vec<f32> {
|
||||||
|
let mut v = vec![0.0_f32; DIM];
|
||||||
|
v[axis] = mag;
|
||||||
|
v[(axis + 1) % DIM] = 0.01;
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_json(app: &axum::Router, uri: &str, body: serde_json::Value) -> StatusCode {
|
||||||
|
app.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method(Method::POST)
|
||||||
|
.uri(uri)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(Body::from(serde_json::to_vec(&body).unwrap()))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.status()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_json_full(
|
||||||
|
app: &axum::Router,
|
||||||
|
uri: &str,
|
||||||
|
body: serde_json::Value,
|
||||||
|
) -> (StatusCode, serde_json::Value) {
|
||||||
|
let response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method(Method::POST)
|
||||||
|
.uri(uri)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(Body::from(serde_json::to_vec(&body).unwrap()))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let status = response.status();
|
||||||
|
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null);
|
||||||
|
(status, json)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seed(app: &axum::Router) {
|
||||||
|
// Items on distinct axes: item 1 ~ axis 0, item 3 ~ axis 0 (close to item 1),
|
||||||
|
// item 2 ~ axis 64 (far). A query on axis 0 must rank 1 and 3 above 2.
|
||||||
|
for (id, v) in [
|
||||||
|
(1u64, axis_vector(0, 1.0)),
|
||||||
|
(2u64, axis_vector(64, 1.0)),
|
||||||
|
(3u64, axis_vector(0, 0.8)),
|
||||||
|
] {
|
||||||
|
let s = post_json(
|
||||||
|
app,
|
||||||
|
"/items",
|
||||||
|
serde_json::json!({ "entity_id": id, "metadata": {} }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(s, StatusCode::CREATED, "item {id}");
|
||||||
|
let s = post_json(
|
||||||
|
app,
|
||||||
|
"/embeddings",
|
||||||
|
serde_json::json!({ "entity_id": id, "values": v }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(s, StatusCode::NO_CONTENT, "embedding {id}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn vector_search_returns_nearest_closest_first() {
|
||||||
|
let app = make_app();
|
||||||
|
seed(&app).await;
|
||||||
|
|
||||||
|
let (status, body) = post_json_full(
|
||||||
|
&app,
|
||||||
|
"/vector_search",
|
||||||
|
serde_json::json!({ "vector": axis_vector(0, 1.0), "k": 3 }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "body: {body}");
|
||||||
|
|
||||||
|
let items = body["items"].as_array().expect("items array");
|
||||||
|
assert_eq!(items.len(), 3, "k=3 nearest");
|
||||||
|
|
||||||
|
// Closest-first: an axis-0 query ranks the two axis-0 items (1, 3) above the
|
||||||
|
// far axis-64 item (2).
|
||||||
|
let ids: Vec<u64> = items
|
||||||
|
.iter()
|
||||||
|
.map(|it| it["entity_id"].as_u64().unwrap())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(ids[0], 1, "the exact-axis item is nearest");
|
||||||
|
assert!(
|
||||||
|
ids[..2].contains(&3),
|
||||||
|
"the near-axis item ranks above the far one; got {ids:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(ids[2], 2, "the far axis-64 item is last");
|
||||||
|
|
||||||
|
// Distances are present and ascending.
|
||||||
|
let dists: Vec<f64> = items
|
||||||
|
.iter()
|
||||||
|
.map(|it| it["distance"].as_f64().unwrap())
|
||||||
|
.collect();
|
||||||
|
for w in dists.windows(2) {
|
||||||
|
assert!(w[0] <= w[1], "distances must ascend: {dists:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn vector_search_k_defaults_to_ten_and_clamps_to_corpus() {
|
||||||
|
let app = make_app();
|
||||||
|
seed(&app).await;
|
||||||
|
// k omitted → defaults to 10, but only 3 items exist, so 3 come back.
|
||||||
|
let (status, body) = post_json_full(
|
||||||
|
&app,
|
||||||
|
"/vector_search",
|
||||||
|
serde_json::json!({ "vector": axis_vector(0, 1.0) }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(body["items"].as_array().unwrap().len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn vector_search_empty_vector_is_400() {
|
||||||
|
let app = make_app();
|
||||||
|
seed(&app).await;
|
||||||
|
let v: Vec<f32> = vec![];
|
||||||
|
let status = post_json(&app, "/vector_search", serde_json::json!({ "vector": v })).await;
|
||||||
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn vector_search_dimension_mismatch_is_400() {
|
||||||
|
let app = make_app();
|
||||||
|
seed(&app).await;
|
||||||
|
// 4-dim query against a 128-dim slot → a client error, not a 500.
|
||||||
|
let status = post_json(
|
||||||
|
&app,
|
||||||
|
"/vector_search",
|
||||||
|
serde_json::json!({ "vector": vec![0.1f32, 0.2, 0.3, 0.4] }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
@ -32,6 +32,7 @@ fn make_workload(mix: &str) -> Workload {
|
|||||||
1.3, // hot_skew
|
1.3, // hot_skew
|
||||||
24, // feed_limit
|
24, // feed_limit
|
||||||
128, // embedding_dim
|
128, // embedding_dim
|
||||||
|
None, // forced_profile (use the weighted feed-profile mix)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,12 +12,46 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
use reqwest::header::{AUTHORIZATION, HeaderValue};
|
use reqwest::header::{AUTHORIZATION, HeaderValue};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::sync::Semaphore;
|
use tokio::sync::Semaphore;
|
||||||
|
|
||||||
use crate::error::{Result, StressError};
|
use crate::error::{Result, StressError};
|
||||||
use crate::metrics::StatusClass;
|
use crate::metrics::StatusClass;
|
||||||
use crate::workload::{Body, HttpMethod, ItemMetadata, Plan};
|
use crate::workload::{Body, HttpMethod, ItemMetadata, Plan};
|
||||||
|
|
||||||
|
/// `POST /vector_search` request body for the recall probe — serialized straight
|
||||||
|
/// to the wire (borrows the query vector; no owned copy).
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct VectorQuery<'a> {
|
||||||
|
vector: &'a [f32],
|
||||||
|
k: usize,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
ef_search: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `POST /signals` body carrying `user_id`, used to BUILD a user's preference
|
||||||
|
/// vector (a positive-engagement `like` on an embedded item). The standalone
|
||||||
|
/// surface reads `user_id`; the regular load `Body::Signal` omits it.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct PreferenceLike {
|
||||||
|
entity_id: u64,
|
||||||
|
signal: &'static str,
|
||||||
|
weight: f64,
|
||||||
|
user_id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The parts of the `/vector_search` response the recall oracle reads: the ranked
|
||||||
|
/// entity IDs (distances are ignored — recall@k is a set-overlap metric).
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct VectorSearchResponseBody {
|
||||||
|
items: Vec<VectorSearchResponseItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct VectorSearchResponseItem {
|
||||||
|
entity_id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
/// Async client with optional bearer auth. Cheap to clone (Arc inside reqwest).
|
/// Async client with optional bearer auth. Cheap to clone (Arc inside reqwest).
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct HttpClient {
|
pub struct HttpClient {
|
||||||
@ -127,6 +161,50 @@ impl HttpClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Issue a `POST {base}/vector_search` recall probe (m12p1) and return both
|
||||||
|
/// the capacity [`StatusClass`] and, on a 2xx, the returned entity IDs in
|
||||||
|
/// rank order (closest-first). The IDs are what the recall oracle compares
|
||||||
|
/// against its brute-force ground truth; a non-2xx or a body it cannot parse
|
||||||
|
/// yields `None` for the IDs (the status still classifies the outcome).
|
||||||
|
///
|
||||||
|
/// Latency is measured by the caller around this call (from the request's
|
||||||
|
/// intended send time), preserving the coordinated-omission correction.
|
||||||
|
pub async fn vector_search_ids(
|
||||||
|
&self,
|
||||||
|
base: &str,
|
||||||
|
vector: &[f32],
|
||||||
|
k: usize,
|
||||||
|
ef_search: Option<usize>,
|
||||||
|
) -> (StatusClass, Option<Vec<u64>>) {
|
||||||
|
let url = format!("{base}/vector_search");
|
||||||
|
let body = VectorQuery {
|
||||||
|
vector,
|
||||||
|
k,
|
||||||
|
ef_search,
|
||||||
|
};
|
||||||
|
let rb = self.inner.post(&url).json(&body);
|
||||||
|
match self.apply_auth(rb).send().await {
|
||||||
|
Ok(resp) => {
|
||||||
|
let class = StatusClass::from_status(resp.status().as_u16());
|
||||||
|
if class != StatusClass::Ok {
|
||||||
|
// Drain so the connection returns to the pool.
|
||||||
|
let _ = resp.bytes().await;
|
||||||
|
return (class, None);
|
||||||
|
}
|
||||||
|
// The recall oracle needs the ranked IDs; parse them out. A parse
|
||||||
|
// failure on a 2xx is reported as Ok-without-ids (a recall miss is
|
||||||
|
// not a transport error) so the latency sample is still honest.
|
||||||
|
let ids = resp
|
||||||
|
.json::<VectorSearchResponseBody>()
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.map(|b| b.items.into_iter().map(|i| i.entity_id).collect());
|
||||||
|
(class, ids)
|
||||||
|
}
|
||||||
|
Err(_) => (StatusClass::Transport, None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetch `GET {base}/cluster/status` (unauthenticated) and return the leader
|
/// Fetch `GET {base}/cluster/status` (unauthenticated) and return the leader
|
||||||
/// name and the worst replication lag across regions — used to watch whether
|
/// name and the worst replication lag across regions — used to watch whether
|
||||||
/// follower lag grows under write load between ramp stages. `None` on any fault.
|
/// follower lag grows under write load between ramp stages. `None` on any fault.
|
||||||
@ -145,6 +223,36 @@ impl HttpClient {
|
|||||||
Some((leader, max_lag))
|
Some((leader, max_lag))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// POST a preference-building `like` (with `user_id`), retrying 429 like the
|
||||||
|
/// corpus seeder. Returns whether it ultimately succeeded.
|
||||||
|
async fn post_preference(&self, url: &str, body: &PreferenceLike) -> bool {
|
||||||
|
const MAX_ATTEMPTS: u32 = 8;
|
||||||
|
for attempt in 0..MAX_ATTEMPTS {
|
||||||
|
let rb = self.inner.post(url).json(body);
|
||||||
|
match self.apply_auth(rb).send().await {
|
||||||
|
Ok(resp) => {
|
||||||
|
let code = resp.status().as_u16();
|
||||||
|
let _ = resp.bytes().await;
|
||||||
|
if (200..=299).contains(&code) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if code == 429 {
|
||||||
|
tokio::time::sleep(Duration::from_millis(40 + u64::from(attempt) * 20))
|
||||||
|
.await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if code == 503 {
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Err(_) => return false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
/// One-off POST returning the raw status, for the seeder (which needs to know
|
/// One-off POST returning the raw status, for the seeder (which needs to know
|
||||||
/// success vs 429-retry rather than a capacity class).
|
/// success vs 429-retry rather than a capacity class).
|
||||||
async fn post_json(&self, url: &str, body: &Body) -> Option<u16> {
|
async fn post_json(&self, url: &str, body: &Body) -> Option<u16> {
|
||||||
@ -161,12 +269,17 @@ impl HttpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Register `count` items (id 1..=count) plus a `dim`-wide content embedding for
|
/// Register `count` items (id 1..=count) plus a `dim`-wide content embedding for
|
||||||
/// each, against `base`.
|
/// each, against `base`, using RANDOM embedding values.
|
||||||
///
|
///
|
||||||
/// Use the LEADER url so items broadcast to every region and `/feed` on any
|
/// Use the LEADER url so items broadcast to every region and `/feed` on any
|
||||||
/// region can rank them. Bounded-concurrency, retries 429. Returns the number of
|
/// region can rank them. Bounded-concurrency, retries 429. Returns the number of
|
||||||
/// items confirmed registered.
|
/// items confirmed registered.
|
||||||
///
|
///
|
||||||
|
/// The ramp mode seeds random embeddings because their exact values are
|
||||||
|
/// irrelevant to the write-path cost it measures. The recall mode instead calls
|
||||||
|
/// [`seed_corpus_with`] with a DETERMINISTIC id-keyed generator so the
|
||||||
|
/// generator's brute-force ground truth matches what the engine indexed.
|
||||||
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Returns [`StressError::Seed`] if the bounded-concurrency semaphore is closed
|
/// Returns [`StressError::Seed`] if the bounded-concurrency semaphore is closed
|
||||||
@ -178,6 +291,38 @@ pub async fn seed_corpus(
|
|||||||
dim: usize,
|
dim: usize,
|
||||||
concurrency: usize,
|
concurrency: usize,
|
||||||
) -> Result<u64> {
|
) -> Result<u64> {
|
||||||
|
seed_corpus_with(client, base, count, concurrency, move |_id| {
|
||||||
|
// A fresh ThreadRng per call (it is !Send, so it cannot cross the await
|
||||||
|
// inside the task; building the Vec up front keeps it off the await path).
|
||||||
|
let mut rng = rand::rng();
|
||||||
|
(0..dim).map(|_| rng.random::<f32>() - 0.5).collect()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register `count` items (id 1..=count) plus a content embedding for each, where
|
||||||
|
/// each item's embedding values come from `embedding(id)`.
|
||||||
|
///
|
||||||
|
/// This is the seam the recall harness uses: passing a deterministic id-keyed
|
||||||
|
/// generator makes the seeded corpus exactly reproducible, so the in-RAM
|
||||||
|
/// brute-force ground truth the oracle computes is bit-for-bit the corpus the
|
||||||
|
/// engine indexed. Same bounded-concurrency + 429-retry contract as
|
||||||
|
/// [`seed_corpus`].
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`StressError::Seed`] if the bounded-concurrency semaphore is closed
|
||||||
|
/// while acquiring a permit.
|
||||||
|
pub async fn seed_corpus_with<F>(
|
||||||
|
client: &HttpClient,
|
||||||
|
base: &str,
|
||||||
|
count: u64,
|
||||||
|
concurrency: usize,
|
||||||
|
embedding: F,
|
||||||
|
) -> Result<u64>
|
||||||
|
where
|
||||||
|
F: Fn(u64) -> Vec<f32> + Send + Sync + Clone + 'static,
|
||||||
|
{
|
||||||
let sem = Arc::new(Semaphore::new(concurrency.max(1)));
|
let sem = Arc::new(Semaphore::new(concurrency.max(1)));
|
||||||
let done = Arc::new(AtomicU64::new(0));
|
let done = Arc::new(AtomicU64::new(0));
|
||||||
let items_url = format!("{base}/items");
|
let items_url = format!("{base}/items");
|
||||||
@ -194,13 +339,10 @@ pub async fn seed_corpus(
|
|||||||
let items_url = items_url.clone();
|
let items_url = items_url.clone();
|
||||||
let emb_url = emb_url.clone();
|
let emb_url = emb_url.clone();
|
||||||
let done = done.clone();
|
let done = done.clone();
|
||||||
|
let embedding = embedding.clone();
|
||||||
handles.push(tokio::spawn(async move {
|
handles.push(tokio::spawn(async move {
|
||||||
let _permit = permit;
|
let _permit = permit;
|
||||||
let category = SEED_CATEGORIES[(id as usize) % SEED_CATEGORIES.len()];
|
let category = SEED_CATEGORIES[(id as usize) % SEED_CATEGORIES.len()];
|
||||||
// Build bodies and DROP the RNG before the awaits (ThreadRng is !Send).
|
|
||||||
let (item, emb) = {
|
|
||||||
let mut rng = rand::rng();
|
|
||||||
let values: Vec<f32> = (0..dim).map(|_| rng.random::<f32>() - 0.5).collect();
|
|
||||||
let item = Body::Item {
|
let item = Body::Item {
|
||||||
entity_id: id,
|
entity_id: id,
|
||||||
metadata: ItemMetadata {
|
metadata: ItemMetadata {
|
||||||
@ -210,9 +352,7 @@ pub async fn seed_corpus(
|
|||||||
};
|
};
|
||||||
let emb = Body::Embedding {
|
let emb = Body::Embedding {
|
||||||
entity_id: id,
|
entity_id: id,
|
||||||
values,
|
values: embedding(id),
|
||||||
};
|
|
||||||
(item, emb)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// The item and embedding writes are independent — the engine keys the
|
// The item and embedding writes are independent — the engine keys the
|
||||||
@ -235,6 +375,61 @@ pub async fn seed_corpus(
|
|||||||
Ok(done.load(Ordering::Relaxed))
|
Ok(done.load(Ordering::Relaxed))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a preference vector for each user `1..=users` (m12p2).
|
||||||
|
///
|
||||||
|
/// Each user sends one positive `like` signal (carrying `user_id`) on a
|
||||||
|
/// deterministic embedded item, so the `for_you` profile's ANN candidate
|
||||||
|
/// generation engages for those users instead of degrading to a scan.
|
||||||
|
///
|
||||||
|
/// Each user `u` likes item `((u - 1) % corpus) + 1`, whose embedding becomes (a
|
||||||
|
/// scaled copy of) the user's preference vector. Bounded concurrency; retries
|
||||||
|
/// 429. Returns the number of preference likes confirmed.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`StressError::Seed`] if the bounded-concurrency semaphore is closed.
|
||||||
|
pub async fn seed_preferences(
|
||||||
|
client: &HttpClient,
|
||||||
|
base: &str,
|
||||||
|
users: u64,
|
||||||
|
corpus: u64,
|
||||||
|
concurrency: usize,
|
||||||
|
) -> Result<u64> {
|
||||||
|
let sem = Arc::new(Semaphore::new(concurrency.max(1)));
|
||||||
|
let done = Arc::new(AtomicU64::new(0));
|
||||||
|
let url = format!("{base}/signals");
|
||||||
|
let corpus = corpus.max(1);
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
|
||||||
|
for u in 1..=users {
|
||||||
|
let permit = sem
|
||||||
|
.clone()
|
||||||
|
.acquire_owned()
|
||||||
|
.await
|
||||||
|
.map_err(|e| StressError::Seed(format!("semaphore closed: {e}")))?;
|
||||||
|
let client = client.clone();
|
||||||
|
let url = url.clone();
|
||||||
|
let done = done.clone();
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
let _permit = permit;
|
||||||
|
let item = ((u - 1) % corpus) + 1;
|
||||||
|
let body = PreferenceLike {
|
||||||
|
entity_id: item,
|
||||||
|
signal: "like",
|
||||||
|
weight: 1.0,
|
||||||
|
user_id: u,
|
||||||
|
};
|
||||||
|
if client.post_preference(&url, &body).await {
|
||||||
|
done.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for h in handles {
|
||||||
|
let _ = h.await;
|
||||||
|
}
|
||||||
|
Ok(done.load(Ordering::Relaxed))
|
||||||
|
}
|
||||||
|
|
||||||
const SEED_CATEGORIES: [&str; 12] = [
|
const SEED_CATEGORIES: [&str; 12] = [
|
||||||
"anime",
|
"anime",
|
||||||
"gaming",
|
"gaming",
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
|
pub mod recall;
|
||||||
pub mod scheduler;
|
pub mod scheduler;
|
||||||
pub mod summary;
|
pub mod summary;
|
||||||
pub mod workload;
|
pub mod workload;
|
||||||
|
|||||||
@ -16,11 +16,14 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
|
||||||
use tidal_stress::client::{HttpClient, seed_corpus};
|
use tidal_stress::client::{HttpClient, seed_corpus, seed_corpus_with, seed_preferences};
|
||||||
use tidal_stress::error::{Result, StressError};
|
use tidal_stress::error::{Result, StressError};
|
||||||
use tidal_stress::metrics::{self, StageStats};
|
use tidal_stress::metrics::{self, StageStats};
|
||||||
|
use tidal_stress::recall::{GroundTruth, QueryPool, embedding_for, run_recall_stage};
|
||||||
use tidal_stress::scheduler::{Stage, parse_ramp, run_stage};
|
use tidal_stress::scheduler::{Stage, parse_ramp, run_stage};
|
||||||
use tidal_stress::summary::{GateConfig, RunSummary, StageSummary};
|
use tidal_stress::summary::{
|
||||||
|
GateConfig, RecallRunSummary, RecallStageSummary, RunSummary, StageSummary,
|
||||||
|
};
|
||||||
use tidal_stress::workload::{OpKind, Workload, parse_mix};
|
use tidal_stress::workload::{OpKind, Workload, parse_mix};
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
@ -109,6 +112,20 @@ struct Cli {
|
|||||||
#[arg(long, default_value_t = 24)]
|
#[arg(long, default_value_t = 24)]
|
||||||
feed_limit: u32,
|
feed_limit: u32,
|
||||||
|
|
||||||
|
/// Force EVERY feed read to this ranking profile instead of the default
|
||||||
|
/// for_you/trending/hot/new mix, so a `--mix reads` ramp measures one
|
||||||
|
/// profile's retrieve p99 in isolation (m12p2 G1 check). E.g. for_you,
|
||||||
|
/// trending, related.
|
||||||
|
#[arg(long)]
|
||||||
|
feed_profile: Option<String>,
|
||||||
|
|
||||||
|
/// Before the ramp, build a preference vector for every user (one positive
|
||||||
|
/// `like` per user on an embedded item) so `for_you` exercises ANN candidate
|
||||||
|
/// generation rather than degrading to a scan (m12p2). Standalone only (the
|
||||||
|
/// signal user_id is read there); needs the corpus to carry embeddings.
|
||||||
|
#[arg(long, default_value_t = false)]
|
||||||
|
seed_preferences: bool,
|
||||||
|
|
||||||
/// Embedding width. MUST match the deployed schema's content_vector
|
/// Embedding width. MUST match the deployed schema's content_vector
|
||||||
/// dimensions. thepeach production is 1536 (text-embedding-3-small); the
|
/// dimensions. thepeach production is 1536 (text-embedding-3-small); the
|
||||||
/// 128 default is the legacy smoke width. A mismatch fails the seed.
|
/// 128 default is the legacy smoke width. A mismatch fails the seed.
|
||||||
@ -152,6 +169,47 @@ struct Cli {
|
|||||||
/// (the capacity knee). The nightly soak's PASS/FAIL signal.
|
/// (the capacity knee). The nightly soak's PASS/FAIL signal.
|
||||||
#[arg(long, default_value_t = false)]
|
#[arg(long, default_value_t = false)]
|
||||||
fail_on_knee: bool,
|
fail_on_knee: bool,
|
||||||
|
|
||||||
|
// ── m12p1 read-recall harness ────────────────────────────────────────────
|
||||||
|
/// Run the READ-RECALL harness instead of the signal ramp: seed a corpus with
|
||||||
|
/// deterministic embeddings, then ramp `/vector_search` probes open-loop,
|
||||||
|
/// reporting true p99 + recall@k vs a brute-force cosine ground truth and the
|
||||||
|
/// read-knee (highest QPS where p99 ≤ target AND recall ≥ target). Requires a
|
||||||
|
/// single-index deployment (standalone, or a cluster at the S=1 shape where
|
||||||
|
/// every replica holds the full corpus).
|
||||||
|
#[arg(long, default_value_t = false)]
|
||||||
|
verify_recall: bool,
|
||||||
|
|
||||||
|
/// `k` for recall@k and the nearest-neighbor query (the G2 metric uses 10).
|
||||||
|
#[arg(long, default_value_t = 10)]
|
||||||
|
recall_k: usize,
|
||||||
|
|
||||||
|
/// Size of the precomputed query pool (each gets a brute-force ground-truth
|
||||||
|
/// top-k up front; the ramp samples from it). More queries = a steadier recall
|
||||||
|
/// estimate but a longer precompute.
|
||||||
|
#[arg(long, default_value_t = 1000)]
|
||||||
|
recall_queries: usize,
|
||||||
|
|
||||||
|
/// Read-knee latency target (ms): the p99 a stage must hold to count toward the
|
||||||
|
/// knee. Defaults to the G1 target (10ms).
|
||||||
|
#[arg(long, default_value_t = 10.0)]
|
||||||
|
read_p99_target_ms: f64,
|
||||||
|
|
||||||
|
/// Read-knee recall target: the mean recall@k a stage must hold to count toward
|
||||||
|
/// the knee. Defaults to the G2 target (0.95).
|
||||||
|
#[arg(long, default_value_t = 0.95)]
|
||||||
|
recall_target: f64,
|
||||||
|
|
||||||
|
/// Per-request HNSW beam width (`ef_search`) override for the recall probe
|
||||||
|
/// (m12p3 makes this the recall/latency knob). Omitted = the slot default.
|
||||||
|
#[arg(long)]
|
||||||
|
recall_ef_search: Option<u32>,
|
||||||
|
|
||||||
|
/// Noise added to each corpus base point when building a query vector. Small
|
||||||
|
/// noise keeps a well-defined nearest cluster (a realistic preference read);
|
||||||
|
/// 0 makes queries exact corpus points (recall trivially saturates).
|
||||||
|
#[arg(long, default_value_t = 0.1)]
|
||||||
|
recall_query_noise: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
// SLO thresholds for the per-stage verdict. tidalDB's own RETRIEVE SLA is p99
|
// SLO thresholds for the per-stage verdict. tidalDB's own RETRIEVE SLA is p99
|
||||||
@ -232,6 +290,13 @@ async fn run() -> Result<()> {
|
|||||||
.clone()
|
.clone()
|
||||||
.map_or_else(|| cli.targets.clone(), |url| vec![url]);
|
.map_or_else(|| cli.targets.clone(), |url| vec![url]);
|
||||||
|
|
||||||
|
// m12p1: the read-recall harness is a distinct flow (deterministic seed →
|
||||||
|
// brute-force ground truth → open-loop /vector_search ramp). Branch here,
|
||||||
|
// before the signal-ramp seeding/workload, and return its verdict.
|
||||||
|
if cli.verify_recall {
|
||||||
|
return run_verify_recall(&cli, client, &leader_url, read_bases, &stages).await;
|
||||||
|
}
|
||||||
|
|
||||||
println!("tidal-stress — thepeach feed workload");
|
println!("tidal-stress — thepeach feed workload");
|
||||||
println!(" targets : {}", cli.targets.join(", "));
|
println!(" targets : {}", cli.targets.join(", "));
|
||||||
println!(
|
println!(
|
||||||
@ -293,6 +358,33 @@ async fn run() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// m12p2: build preference vectors so `for_you` exercises ANN candidate
|
||||||
|
// generation (not a scan fallback) during the read ramp.
|
||||||
|
if cli.seed_preferences {
|
||||||
|
println!(
|
||||||
|
"preferences: building {} user preference vectors (one like each) via {} ...",
|
||||||
|
cli.users, leader_url
|
||||||
|
);
|
||||||
|
let t0 = std::time::Instant::now();
|
||||||
|
let built = seed_preferences(
|
||||||
|
&client,
|
||||||
|
&leader_url,
|
||||||
|
cli.users,
|
||||||
|
cli.corpus,
|
||||||
|
cli.seed_concurrency,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
println!(
|
||||||
|
"preferences: {built}/{} users in {:.1}s (for_you now ANN-backed)\n",
|
||||||
|
cli.users,
|
||||||
|
t0.elapsed().as_secs_f64()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(profile) = &cli.feed_profile {
|
||||||
|
println!(" feed profile : forced to '{profile}' (per-profile retrieve p99)\n");
|
||||||
|
}
|
||||||
|
|
||||||
let workload = Arc::new(Workload::new(
|
let workload = Arc::new(Workload::new(
|
||||||
read_bases,
|
read_bases,
|
||||||
write_bases,
|
write_bases,
|
||||||
@ -302,6 +394,7 @@ async fn run() -> Result<()> {
|
|||||||
cli.hot_skew,
|
cli.hot_skew,
|
||||||
cli.feed_limit,
|
cli.feed_limit,
|
||||||
cli.embedding_dim,
|
cli.embedding_dim,
|
||||||
|
cli.feed_profile.clone(),
|
||||||
));
|
));
|
||||||
|
|
||||||
let _ = std::io::stdout().flush();
|
let _ = std::io::stdout().flush();
|
||||||
@ -484,6 +577,338 @@ fn print_verdict(
|
|||||||
println!("═══════════════════════════════════════════════════════════════");
|
println!("═══════════════════════════════════════════════════════════════");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── m12p1 read-recall harness ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Drive the read-recall harness: deterministic seed → brute-force ground truth
|
||||||
|
/// → open-loop `/vector_search` ramp → per-stage true-p99 + recall@k + read-knee.
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
|
async fn run_verify_recall(
|
||||||
|
cli: &Cli,
|
||||||
|
client: Arc<HttpClient>,
|
||||||
|
seed_url: &str,
|
||||||
|
read_bases: Vec<String>,
|
||||||
|
stages: &[Stage],
|
||||||
|
) -> Result<()> {
|
||||||
|
let dim = cli.embedding_dim;
|
||||||
|
if cli.recall_k == 0 || cli.recall_k > 1000 {
|
||||||
|
return Err(StressError::BadGate(format!(
|
||||||
|
"--recall-k must be in 1..=1000, got {}",
|
||||||
|
cli.recall_k
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if cli.recall_queries == 0 {
|
||||||
|
return Err(StressError::BadGate("--recall-queries must be > 0".into()));
|
||||||
|
}
|
||||||
|
for (flag, v) in [
|
||||||
|
("--read-p99-target-ms", cli.read_p99_target_ms),
|
||||||
|
("--recall-target", cli.recall_target),
|
||||||
|
("--recall-query-noise", cli.recall_query_noise),
|
||||||
|
] {
|
||||||
|
if !v.is_finite() || v < 0.0 {
|
||||||
|
return Err(StressError::BadGate(format!(
|
||||||
|
"{flag} must be finite and non-negative, got {v}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("tidal-stress — read-recall harness (m12p1)");
|
||||||
|
println!(" targets : {}", read_bases.join(", "));
|
||||||
|
println!(
|
||||||
|
" corpus / dim : {} items / {}-dim{}",
|
||||||
|
cli.corpus,
|
||||||
|
dim,
|
||||||
|
if dim == 1536 {
|
||||||
|
" (thepeach production width)"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" recall : recall@{} vs brute-force cosine, {} query pool",
|
||||||
|
cli.recall_k, cli.recall_queries
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" read-knee : p99 ≤ {:.1}ms AND recall@{} ≥ {:.3}\n",
|
||||||
|
cli.read_p99_target_ms, cli.recall_k, cli.recall_target
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Seed (deterministic) ────────────────────────────────────────────────
|
||||||
|
if cli.skip_seed {
|
||||||
|
println!("seed: skipped (--skip-seed) — assuming a prior DETERMINISTIC recall seed\n");
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
"seed: registering {} items + {}-dim deterministic embeddings via {} ...",
|
||||||
|
cli.corpus, dim, seed_url
|
||||||
|
);
|
||||||
|
let t0 = std::time::Instant::now();
|
||||||
|
let seeded = seed_corpus_with(&client, seed_url, cli.corpus, cli.seed_concurrency, {
|
||||||
|
move |id| embedding_for(id, dim)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
println!(
|
||||||
|
"seed: {seeded}/{} items in {:.1}s\n",
|
||||||
|
cli.corpus,
|
||||||
|
t0.elapsed().as_secs_f64()
|
||||||
|
);
|
||||||
|
// Recall ground truth assumes EVERY id 1..=corpus is indexed; a missing id
|
||||||
|
// can never be returned, depressing recall artificially. Refuse to report a
|
||||||
|
// recall number computed against a corpus the engine does not fully hold.
|
||||||
|
if seeded < cli.corpus {
|
||||||
|
return Err(StressError::Seed(format!(
|
||||||
|
"only {seeded}/{} items registered — recall@k would be understated; \
|
||||||
|
fix the seed (auth/schema/dim) before measuring",
|
||||||
|
cli.corpus
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ground truth + query pool ───────────────────────────────────────────
|
||||||
|
let ram_gb = (cli.corpus as f64 * dim as f64 * 4.0) / 1e9;
|
||||||
|
println!(
|
||||||
|
"ground truth: building brute-force oracle ({} items × {}-dim ≈ {:.1} GB RAM) ...",
|
||||||
|
cli.corpus, dim, ram_gb
|
||||||
|
);
|
||||||
|
let t1 = std::time::Instant::now();
|
||||||
|
let gt = GroundTruth::build(cli.corpus, dim);
|
||||||
|
println!("ground truth: built in {:.1}s", t1.elapsed().as_secs_f64());
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"ground truth: precomputing {} query top-{} (brute force, parallel) ...",
|
||||||
|
cli.recall_queries, cli.recall_k
|
||||||
|
);
|
||||||
|
let t2 = std::time::Instant::now();
|
||||||
|
let pool = Arc::new(QueryPool::build(
|
||||||
|
>,
|
||||||
|
cli.recall_queries,
|
||||||
|
cli.recall_k,
|
||||||
|
cli.recall_query_noise as f32,
|
||||||
|
));
|
||||||
|
println!(
|
||||||
|
"ground truth: {} queries ready in {:.1}s\n",
|
||||||
|
pool.len(),
|
||||||
|
t2.elapsed().as_secs_f64()
|
||||||
|
);
|
||||||
|
// Free the big corpus buffer before the ramp — the pool holds everything the
|
||||||
|
// hot path needs (queries + their ground-truth id lists).
|
||||||
|
drop(gt);
|
||||||
|
|
||||||
|
let _ = std::io::stdout().flush();
|
||||||
|
|
||||||
|
// ── Ramp ────────────────────────────────────────────────────────────────
|
||||||
|
let bases = Arc::new(read_bases);
|
||||||
|
let ef_search = cli.recall_ef_search.map(|v| v as usize);
|
||||||
|
let max_error_rate = cli.max_error_pct.map_or(SLO_ERROR_RATE, |p| p / 100.0);
|
||||||
|
let mut summaries: Vec<RecallStageSummary> = Vec::new();
|
||||||
|
|
||||||
|
for (i, stage) in stages.iter().enumerate() {
|
||||||
|
let label = format!("{}/{}", i + 1, stages.len());
|
||||||
|
let stats = run_recall_stage(
|
||||||
|
pool.clone(),
|
||||||
|
bases.clone(),
|
||||||
|
client.clone(),
|
||||||
|
stage,
|
||||||
|
cli.recall_k,
|
||||||
|
ef_search,
|
||||||
|
cli.max_inflight,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
print!(
|
||||||
|
"{}",
|
||||||
|
render_recall_stage(&label, stage.target_rps, &stats, cli.recall_k)
|
||||||
|
);
|
||||||
|
let _ = std::io::stdout().flush();
|
||||||
|
|
||||||
|
let ok_per_sec = stats.ok as f64 / stats.elapsed.as_secs_f64().max(1e-9);
|
||||||
|
summaries.push(RecallStageSummary {
|
||||||
|
target_rps: stage.target_rps,
|
||||||
|
achieved_rps: stats.achieved_rps(),
|
||||||
|
ok_per_sec,
|
||||||
|
error_rate: stats.error_rate(),
|
||||||
|
client_shed: stats.client_shed,
|
||||||
|
p99_ms: ms(stats.p99()),
|
||||||
|
p50_ms: ms(stats.hist.percentile(0.50)),
|
||||||
|
max_ms: ms(stats.hist.max()),
|
||||||
|
mean_recall: stats.mean_recall(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let run = RecallRunSummary {
|
||||||
|
stages: summaries,
|
||||||
|
k: cli.recall_k,
|
||||||
|
corpus: cli.corpus,
|
||||||
|
embedding_dim: dim,
|
||||||
|
p99_target_ms: cli.read_p99_target_ms,
|
||||||
|
recall_target: cli.recall_target,
|
||||||
|
max_error_rate,
|
||||||
|
};
|
||||||
|
print_recall_verdict(&run);
|
||||||
|
|
||||||
|
if let Some(path) = &cli.json_summary {
|
||||||
|
std::fs::write(path, run.to_json()).map_err(|source| StressError::Summary {
|
||||||
|
path: path.clone(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
println!("\nwrote JSON summary → {path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gates ────────────────────────────────────────────────────────────────
|
||||||
|
let armed = cli.fail_on_knee || cli.max_p99_ms.is_some() || cli.max_error_pct.is_some();
|
||||||
|
if armed {
|
||||||
|
let mut breaches: Vec<String> = Vec::new();
|
||||||
|
if cli.fail_on_knee && run.read_knee().is_none() {
|
||||||
|
breaches.push(format!(
|
||||||
|
"no read-knee: no stage held p99 ≤ {:.1}ms AND recall@{} ≥ {:.3} [--fail-on-knee]",
|
||||||
|
cli.read_p99_target_ms, cli.recall_k, cli.recall_target
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(max) = cli.max_p99_ms {
|
||||||
|
for (i, s) in run.stages.iter().enumerate() {
|
||||||
|
if s.p99_ms > max {
|
||||||
|
breaches.push(format!(
|
||||||
|
"stage {} p99 {:.2}ms exceeds --max-p99-ms {max:.1}",
|
||||||
|
i + 1,
|
||||||
|
s.p99_ms
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(max) = cli.max_error_pct {
|
||||||
|
for (i, s) in run.stages.iter().enumerate() {
|
||||||
|
if s.error_rate * 100.0 > max {
|
||||||
|
breaches.push(format!(
|
||||||
|
"stage {} error rate {:.2}% exceeds --max-error-pct {max:.2}",
|
||||||
|
i + 1,
|
||||||
|
s.error_rate * 100.0
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if breaches.is_empty() {
|
||||||
|
println!("\nregression gates: PASS");
|
||||||
|
} else {
|
||||||
|
return Err(StressError::Gate(breaches.join("; ")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Milliseconds from a `Duration`.
|
||||||
|
fn ms(d: Duration) -> f64 {
|
||||||
|
d.as_secs_f64() * 1000.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render one recall ramp stage as a human-readable block.
|
||||||
|
fn render_recall_stage(
|
||||||
|
label: &str,
|
||||||
|
target_rps: f64,
|
||||||
|
stats: &tidal_stress::recall::RecallStageStats,
|
||||||
|
k: usize,
|
||||||
|
) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
out.push_str(&format!(
|
||||||
|
"\n── stage {label} (target {target_rps:.0} rps, achieved {:.0} rps, {:.1}s) ──\n",
|
||||||
|
stats.achieved_rps(),
|
||||||
|
stats.elapsed.as_secs_f64(),
|
||||||
|
));
|
||||||
|
out.push_str(&format!(
|
||||||
|
" vector_search: count {} ok/s {:.0} p50 {:.2}ms p90 {:.2}ms p99 {:.2}ms max {:.2}ms\n",
|
||||||
|
stats.total,
|
||||||
|
stats.ok as f64 / stats.elapsed.as_secs_f64().max(1e-9),
|
||||||
|
ms(stats.hist.percentile(0.50)),
|
||||||
|
ms(stats.hist.percentile(0.90)),
|
||||||
|
ms(stats.p99()),
|
||||||
|
ms(stats.hist.max()),
|
||||||
|
));
|
||||||
|
out.push_str(&format!(
|
||||||
|
" recall@{k} {:.4} ({} scored) | error {:.2}% | client-shed {} | schedule-lag p99 {:.2}ms / max {:.2}ms\n",
|
||||||
|
stats.mean_recall(),
|
||||||
|
stats.recall_count,
|
||||||
|
stats.error_rate() * 100.0,
|
||||||
|
stats.client_shed,
|
||||||
|
ms(stats.p99_schedule_lag),
|
||||||
|
ms(stats.max_schedule_lag),
|
||||||
|
));
|
||||||
|
if stats.client_shed > 0 {
|
||||||
|
out.push_str(&format!(
|
||||||
|
" ⚠ percentiles tail-under-measured: {} request(s) shed (in-flight cap hit)\n",
|
||||||
|
stats.client_shed,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print the read-recall verdict: the read-knee and what it means for G1/G2.
|
||||||
|
fn print_recall_verdict(run: &RecallRunSummary) {
|
||||||
|
println!("\n══════════════════════ READ-RECALL VERDICT ══════════════════════");
|
||||||
|
match run.read_knee() {
|
||||||
|
None => {
|
||||||
|
println!(
|
||||||
|
"No stage held p99 ≤ {:.1}ms AND recall@{} ≥ {:.3} — the read SLA is not met at any rate in this ramp.",
|
||||||
|
run.p99_target_ms, run.k, run.recall_target
|
||||||
|
);
|
||||||
|
// Name which target each stage missed so the failure is actionable.
|
||||||
|
for (i, s) in run.stages.iter().enumerate() {
|
||||||
|
let lat = if s.p99_ms <= run.p99_target_ms {
|
||||||
|
"p99 ok"
|
||||||
|
} else {
|
||||||
|
"p99 OVER"
|
||||||
|
};
|
||||||
|
let rec = if s.mean_recall >= run.recall_target {
|
||||||
|
"recall ok"
|
||||||
|
} else {
|
||||||
|
"recall LOW"
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
" stage {}/{} (target {:.0} rps): p99 {:.2}ms [{lat}], recall@{} {:.4} [{rec}]",
|
||||||
|
i + 1,
|
||||||
|
run.stages.len(),
|
||||||
|
s.target_rps,
|
||||||
|
s.p99_ms,
|
||||||
|
run.k,
|
||||||
|
s.mean_recall,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(k_idx) => {
|
||||||
|
let s = &run.stages[k_idx];
|
||||||
|
println!(
|
||||||
|
"Read-knee: stage {}/{} — sustained {:.0} read req/s within SLA.",
|
||||||
|
k_idx + 1,
|
||||||
|
run.stages.len(),
|
||||||
|
s.achieved_rps,
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" at the knee : p99 {:.2}ms (≤ {:.1}ms target), recall@{} {:.4} (≥ {:.3} target)",
|
||||||
|
s.p99_ms, run.p99_target_ms, run.k, s.mean_recall, run.recall_target,
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" G1 (p99 ≤ {:.0}ms) : {}",
|
||||||
|
run.p99_target_ms,
|
||||||
|
if s.p99_ms <= run.p99_target_ms {
|
||||||
|
"MET at the knee"
|
||||||
|
} else {
|
||||||
|
"not met"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" G2 (recall@{} ≥ {:.2}) : {}",
|
||||||
|
run.k,
|
||||||
|
run.recall_target,
|
||||||
|
if s.mean_recall >= run.recall_target {
|
||||||
|
"MET at the knee"
|
||||||
|
} else {
|
||||||
|
"not met"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
" corpus / dim : {} items / {}-dim (recall is the ANN index quality at this shape)",
|
||||||
|
run.corpus, run.embedding_dim
|
||||||
|
);
|
||||||
|
println!("══════════════════════════════════════════════════════════════════");
|
||||||
|
}
|
||||||
|
|
||||||
fn init_tracing() {
|
fn init_tracing() {
|
||||||
let env_filter = std::env::var("TIDAL_STRESS_LOG").unwrap_or_else(|_| "warn".into());
|
let env_filter = std::env::var("TIDAL_STRESS_LOG").unwrap_or_else(|_| "warn".into());
|
||||||
let _ = tracing_subscriber::fmt()
|
let _ = tracing_subscriber::fmt()
|
||||||
|
|||||||
532
tidal-stress/src/recall.rs
Normal file
532
tidal-stress/src/recall.rs
Normal file
@ -0,0 +1,532 @@
|
|||||||
|
//! The m12p1 recall oracle + open-loop read-recall ramp.
|
||||||
|
//!
|
||||||
|
//! Every perf claim before m12p1 was latency-only: recall — the fraction of the
|
||||||
|
//! true nearest neighbors an ANN query actually returns — was unmeasured at the
|
||||||
|
//! production shape (1536-dim, 100k–1M items). This module closes that gap. It
|
||||||
|
//! is the harness the whole milestone steers against.
|
||||||
|
//!
|
||||||
|
//! # How the oracle works
|
||||||
|
//!
|
||||||
|
//! 1. The corpus is seeded with **deterministic, id-keyed** embeddings
|
||||||
|
//! ([`embedding_for`]), so the generator can reconstruct, bit-for-bit, the
|
||||||
|
//! exact vectors the engine indexed — no need to capture them over the wire.
|
||||||
|
//! 2. [`GroundTruth`] holds that corpus in RAM and computes **brute-force cosine
|
||||||
|
//! top-k** for any query — the exact answer the ANN index is approximating.
|
||||||
|
//! (Order by cosine == order by the engine's L2-on-normalized distance, so the
|
||||||
|
//! two are directly comparable; see `storage::vector`.)
|
||||||
|
//! 3. A [`QueryPool`] of realistic queries (corpus points perturbed by noise) is
|
||||||
|
//! precomputed once, each with its ground-truth top-k, so the hot request path
|
||||||
|
//! does only a set-overlap to score `recall@k`.
|
||||||
|
//! 4. [`run_recall_stage`] fires `/vector_search` probes open-loop (the same
|
||||||
|
//! coordinated-omission-corrected methodology as the signal ramp) and records,
|
||||||
|
//! per request, the true latency AND the achieved recall.
|
||||||
|
//!
|
||||||
|
//! The verdict reports per-stage **true p99** (not a closed-loop mean) and **mean
|
||||||
|
//! recall@10**, and finds the **read-knee**: the highest sustained QPS at which
|
||||||
|
//! `p99 ≤ target AND recall@10 ≥ target` both hold.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tokio::sync::{Semaphore, mpsc};
|
||||||
|
use tokio::time::Instant;
|
||||||
|
|
||||||
|
use crate::client::HttpClient;
|
||||||
|
use crate::metrics::{LatencyHistogram, StatusClass};
|
||||||
|
use crate::scheduler::Stage;
|
||||||
|
|
||||||
|
// ── Deterministic corpus + query generation ──────────────────────────────────
|
||||||
|
|
||||||
|
/// SplitMix64 — a tiny, fast, dependency-free deterministic generator. Seeding it
|
||||||
|
/// from an entity id makes the whole corpus reproducible from the id alone, which
|
||||||
|
/// is what lets the generator hold the ground truth without ever reading it back
|
||||||
|
/// from the engine. The same scheme is mirrored in the engine-side recall test.
|
||||||
|
fn splitmix64(state: &mut u64) -> u64 {
|
||||||
|
*state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||||
|
let mut z = *state;
|
||||||
|
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||||
|
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||||
|
z ^ (z >> 31)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One uniform `f32` in `[-0.5, 0.5)` drawn from `state`.
|
||||||
|
fn next_f32(state: &mut u64) -> f32 {
|
||||||
|
// Top 24 bits → a uniform mantissa in [0,1), then recentre to [-0.5, 0.5).
|
||||||
|
let bits = (splitmix64(state) >> 40) as u32;
|
||||||
|
(bits as f32 / 16_777_216.0) - 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Corpus ids per cluster center. ~100/cluster mirrors the neighbourhood size of
|
||||||
|
/// a real embedding manifold and guarantees ≥ `k` genuine neighbours per query
|
||||||
|
/// for any `k` ≤ 100.
|
||||||
|
const CLUSTER_SIZE: u64 = 100;
|
||||||
|
|
||||||
|
/// Per-component spread of the Gaussian mixture (`vector = center + SPREAD·noise`,
|
||||||
|
/// both unit). `SPREAD = 0.5` ⇒ intra-cluster cosine ≈ 0.89, inter-cluster ≈ 0 —
|
||||||
|
/// the clear neighbour structure of real embeddings.
|
||||||
|
const CLUSTER_SPREAD: f32 = 0.5;
|
||||||
|
|
||||||
|
/// A normalized pseudo-random unit vector seeded by `seed`.
|
||||||
|
fn unit_seeded(seed: u64, dim: usize) -> Vec<f32> {
|
||||||
|
let mut state = seed;
|
||||||
|
let mut v: Vec<f32> = (0..dim).map(|_| next_f32(&mut state)).collect();
|
||||||
|
let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||||
|
if norm > f32::EPSILON {
|
||||||
|
for x in &mut v {
|
||||||
|
*x /= norm;
|
||||||
|
}
|
||||||
|
} else if let Some(first) = v.first_mut() {
|
||||||
|
*first = 1.0;
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The deterministic content embedding for an item id — a **clustered**
|
||||||
|
/// (Gaussian-mixture) vector.
|
||||||
|
///
|
||||||
|
/// `id` is assigned to cluster `id / CLUSTER_SIZE`; the vector is that cluster's
|
||||||
|
/// unit center plus `CLUSTER_SPREAD ×` a per-id unit noise vector. The result is
|
||||||
|
/// returned raw (un-normalized); the engine L2-normalizes on write and the oracle
|
||||||
|
/// ranks by cosine, both scale-invariant, so the cluster structure is identical
|
||||||
|
/// on both sides.
|
||||||
|
///
|
||||||
|
/// **Why clustered, not uniform-random.** Uniform-random high-dimensional vectors
|
||||||
|
/// are pathological for recall@k: by concentration of measure every pair sits at
|
||||||
|
/// cosine ≈ 0, so beyond a tiny perturbation a query's true top-k is an arbitrary
|
||||||
|
/// draw from a thick equidistant shell — recall@k then measures impossible
|
||||||
|
/// tie-breaking, not index quality, and (measured) *falls* as the corpus grows
|
||||||
|
/// (≈0.97 at 10k → ≈0.54 at 100k at 1536-D). Real text/image embeddings live on a
|
||||||
|
/// low-dimensional manifold with clusters; this models that so recall@k is a
|
||||||
|
/// meaningful index-quality metric at scale (m12p3).
|
||||||
|
#[must_use]
|
||||||
|
pub fn embedding_for(id: u64, dim: usize) -> Vec<f32> {
|
||||||
|
let cluster = id / CLUSTER_SIZE;
|
||||||
|
// Distinct stream constants for the center vs the per-id noise so they are
|
||||||
|
// independent; offset+odd-multiply keeps adjacent ids/clusters well-separated.
|
||||||
|
let center = unit_seeded(
|
||||||
|
cluster
|
||||||
|
.wrapping_mul(0x9E37_79B9_7F4A_7C15)
|
||||||
|
.wrapping_add(0x00C0_FFEE),
|
||||||
|
dim,
|
||||||
|
);
|
||||||
|
let noise = unit_seeded(id.wrapping_mul(0x2545_F491_4F6C_DD1D).wrapping_add(1), dim);
|
||||||
|
center
|
||||||
|
.iter()
|
||||||
|
.zip(&noise)
|
||||||
|
.map(|(c, n)| c + CLUSTER_SPREAD * n)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Brute-force ground truth ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// The seeded corpus held in RAM for exact nearest-neighbor computation.
|
||||||
|
///
|
||||||
|
/// Stored as one flat `Vec<f32>` (id `i` occupies `[(i-1)*dim, i*dim)`) plus a
|
||||||
|
/// per-vector L2 norm so cosine is a single dot-product + divide. At 1536-dim
|
||||||
|
/// this costs `n * dim * 4` bytes (~6 GB at 1M) — the honest price of a real
|
||||||
|
/// brute-force oracle; smaller corpora (100k ≈ 600 MB) fit comfortably.
|
||||||
|
pub struct GroundTruth {
|
||||||
|
flat: Vec<f32>,
|
||||||
|
norms: Vec<f32>,
|
||||||
|
dim: usize,
|
||||||
|
n: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GroundTruth {
|
||||||
|
/// Build the deterministic corpus for ids `1..=n` at `dim` dimensions.
|
||||||
|
#[must_use]
|
||||||
|
pub fn build(n: u64, dim: usize) -> Self {
|
||||||
|
let mut flat = Vec::with_capacity((n as usize) * dim);
|
||||||
|
let mut norms = Vec::with_capacity(n as usize);
|
||||||
|
for id in 1..=n {
|
||||||
|
let v = embedding_for(id, dim);
|
||||||
|
let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||||
|
norms.push(norm);
|
||||||
|
flat.extend_from_slice(&v);
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
flat,
|
||||||
|
norms,
|
||||||
|
dim,
|
||||||
|
n,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn n(&self) -> u64 {
|
||||||
|
self.n
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn dim(&self) -> usize {
|
||||||
|
self.dim
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The raw stored vector for `id` (1-based).
|
||||||
|
#[must_use]
|
||||||
|
fn vector(&self, id: u64) -> &[f32] {
|
||||||
|
let start = ((id - 1) as usize) * self.dim;
|
||||||
|
&self.flat[start..start + self.dim]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `k` ids whose corpus vector is most cosine-similar to `query`,
|
||||||
|
/// best-first. The exact answer the ANN index approximates.
|
||||||
|
#[must_use]
|
||||||
|
pub fn top_k(&self, query: &[f32], k: usize) -> Vec<u64> {
|
||||||
|
let q_norm = query.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||||
|
if q_norm == 0.0 || k == 0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
// A bounded top-k kept as a min-by-score Vec (k is tiny, ~10): cheaper and
|
||||||
|
// allocation-lighter than a full sort of n scored pairs per query.
|
||||||
|
let mut top: Vec<(f32, u64)> = Vec::with_capacity(k + 1);
|
||||||
|
for id in 1..=self.n {
|
||||||
|
let xn = self.norms[(id - 1) as usize];
|
||||||
|
if xn == 0.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let dot: f32 = query.iter().zip(self.vector(id)).map(|(a, b)| a * b).sum();
|
||||||
|
let score = dot / (q_norm * xn); // cosine; higher = nearer
|
||||||
|
if top.len() < k {
|
||||||
|
top.push((score, id));
|
||||||
|
if top.len() == k {
|
||||||
|
// Smallest score first so the worst-kept is at index 0.
|
||||||
|
top.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
}
|
||||||
|
} else if score > top[0].0 {
|
||||||
|
top[0] = (score, id);
|
||||||
|
// Re-sink the new minimum to the front (k is tiny).
|
||||||
|
top.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Return best-first.
|
||||||
|
top.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
top.into_iter().map(|(_, id)| id).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Query pool (queries + precomputed ground truth) ──────────────────────────
|
||||||
|
|
||||||
|
/// A pool of realistic query vectors, each paired with its brute-force
|
||||||
|
/// ground-truth top-k.
|
||||||
|
///
|
||||||
|
/// Precomputing the (expensive) ground truth once decouples it from the hot
|
||||||
|
/// request path, where scoring a response is then just a set overlap.
|
||||||
|
pub struct QueryPool {
|
||||||
|
pub queries: Vec<Vec<f32>>,
|
||||||
|
pub truth: Vec<Vec<u64>>,
|
||||||
|
pub k: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QueryPool {
|
||||||
|
/// Build `pool_size` queries and their ground-truth top-`k`.
|
||||||
|
///
|
||||||
|
/// Each query is a corpus point (spread across the catalog) perturbed by
|
||||||
|
/// deterministic noise, so it has a well-defined nearest cluster — the shape
|
||||||
|
/// of a real preference-vector read, and far more informative than uniformly
|
||||||
|
/// random high-dimensional queries whose neighbors are near-equidistant.
|
||||||
|
///
|
||||||
|
/// Ground truth is computed in parallel across the available cores; the work
|
||||||
|
/// is embarrassingly parallel (one independent brute-force scan per query).
|
||||||
|
#[must_use]
|
||||||
|
pub fn build(gt: &GroundTruth, pool_size: usize, k: usize, noise: f32) -> Self {
|
||||||
|
let n = gt.n().max(1);
|
||||||
|
// Spread base points across the corpus with a large odd stride so the pool
|
||||||
|
// is not clustered on the low ids.
|
||||||
|
let queries: Vec<Vec<f32>> = (0..pool_size)
|
||||||
|
.map(|i| {
|
||||||
|
let base = ((i as u64).wrapping_mul(2_654_435_761) % n) + 1;
|
||||||
|
let mut q = gt.vector(base).to_vec();
|
||||||
|
let mut state = (i as u64)
|
||||||
|
.wrapping_mul(0x100_0000_01B3)
|
||||||
|
.wrapping_add(0xABCD);
|
||||||
|
for c in &mut q {
|
||||||
|
*c += noise * next_f32(&mut state);
|
||||||
|
}
|
||||||
|
q
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let threads = std::thread::available_parallelism()
|
||||||
|
.map(std::num::NonZeroUsize::get)
|
||||||
|
.unwrap_or(4)
|
||||||
|
.min(pool_size.max(1));
|
||||||
|
let chunk = pool_size.div_ceil(threads.max(1));
|
||||||
|
let truth: Vec<Vec<u64>> = std::thread::scope(|s| {
|
||||||
|
// Spawn every chunk BEFORE joining any (the collect is load-bearing —
|
||||||
|
// joining inline would serialize the scans), then concatenate in order.
|
||||||
|
#[allow(clippy::needless_collect)]
|
||||||
|
let handles: Vec<_> = queries
|
||||||
|
.chunks(chunk.max(1))
|
||||||
|
.map(|qs| s.spawn(move || qs.iter().map(|q| gt.top_k(q, k)).collect::<Vec<_>>()))
|
||||||
|
.collect();
|
||||||
|
handles
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|h| h.join().unwrap_or_default())
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
|
||||||
|
Self { queries, truth, k }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.queries.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.queries.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `recall@k`: the fraction of the ground-truth top-k that the engine returned.
|
||||||
|
///
|
||||||
|
/// `returned` may be shorter than `k` (a thin corpus or a degraded index); the
|
||||||
|
/// denominator is the ground-truth size, never the returned size, so a short
|
||||||
|
/// answer is correctly penalised.
|
||||||
|
#[must_use]
|
||||||
|
pub fn recall_at_k(returned: &[u64], truth: &[u64]) -> f64 {
|
||||||
|
if truth.is_empty() {
|
||||||
|
return 1.0; // nothing to find ⇒ vacuously perfect
|
||||||
|
}
|
||||||
|
let truth_set: std::collections::HashSet<u64> = truth.iter().copied().collect();
|
||||||
|
let hits = returned.iter().filter(|id| truth_set.contains(id)).count();
|
||||||
|
hits as f64 / truth.len() as f64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Open-loop recall stage ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// One recall request's result, sent from a worker to the collector.
|
||||||
|
struct RecallOutcome {
|
||||||
|
class: StatusClass,
|
||||||
|
/// CO-corrected latency (from the request's intended send time).
|
||||||
|
latency: Duration,
|
||||||
|
/// Achieved recall@k, present only when the response carried a usable id list.
|
||||||
|
recall: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything one recall ramp stage produced.
|
||||||
|
pub struct RecallStageStats {
|
||||||
|
pub hist: LatencyHistogram,
|
||||||
|
pub ok: u64,
|
||||||
|
pub errors: u64,
|
||||||
|
pub total: u64,
|
||||||
|
pub recall_sum: f64,
|
||||||
|
pub recall_count: u64,
|
||||||
|
pub elapsed: Duration,
|
||||||
|
pub client_shed: u64,
|
||||||
|
pub p99_schedule_lag: Duration,
|
||||||
|
pub max_schedule_lag: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecallStageStats {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
hist: LatencyHistogram::default(),
|
||||||
|
ok: 0,
|
||||||
|
errors: 0,
|
||||||
|
total: 0,
|
||||||
|
recall_sum: 0.0,
|
||||||
|
recall_count: 0,
|
||||||
|
elapsed: Duration::ZERO,
|
||||||
|
client_shed: 0,
|
||||||
|
p99_schedule_lag: Duration::ZERO,
|
||||||
|
max_schedule_lag: Duration::ZERO,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record(&mut self, o: &RecallOutcome) {
|
||||||
|
self.total += 1;
|
||||||
|
if o.class == StatusClass::Ok {
|
||||||
|
self.ok += 1;
|
||||||
|
self.hist.record(o.latency);
|
||||||
|
} else {
|
||||||
|
self.errors += 1;
|
||||||
|
}
|
||||||
|
if let Some(r) = o.recall {
|
||||||
|
self.recall_sum += r;
|
||||||
|
self.recall_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Achieved throughput: completed requests per second over the stage.
|
||||||
|
#[must_use]
|
||||||
|
pub fn achieved_rps(&self) -> f64 {
|
||||||
|
let s = self.elapsed.as_secs_f64();
|
||||||
|
if s <= 0.0 { 0.0 } else { self.total as f64 / s }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn error_rate(&self) -> f64 {
|
||||||
|
if self.total == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
self.errors as f64 / self.total as f64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mean recall@k across the stage's successfully-parsed responses.
|
||||||
|
#[must_use]
|
||||||
|
pub fn mean_recall(&self) -> f64 {
|
||||||
|
if self.recall_count == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
self.recall_sum / self.recall_count as f64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True p99 latency (bucket-estimated, but a genuine tail over an open loop —
|
||||||
|
/// NOT a closed-loop mean).
|
||||||
|
#[must_use]
|
||||||
|
pub fn p99(&self) -> Duration {
|
||||||
|
self.hist.percentile(0.99)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run one recall ramp stage open-loop and return its aggregated stats.
|
||||||
|
///
|
||||||
|
/// Mirrors [`crate::scheduler::run_stage`]'s constant-arrival-rate methodology
|
||||||
|
/// (fire at the target rate regardless of outstanding responses; measure each
|
||||||
|
/// latency from its intended send time; count — never block on — an in-flight
|
||||||
|
/// shed), specialised to the single `/vector_search` op and extended to score
|
||||||
|
/// recall against the precomputed pool.
|
||||||
|
pub async fn run_recall_stage(
|
||||||
|
pool: Arc<QueryPool>,
|
||||||
|
bases: Arc<Vec<String>>,
|
||||||
|
client: Arc<HttpClient>,
|
||||||
|
stage: &Stage,
|
||||||
|
k: usize,
|
||||||
|
ef_search: Option<usize>,
|
||||||
|
max_inflight: usize,
|
||||||
|
) -> RecallStageStats {
|
||||||
|
let (tx, mut rx) = mpsc::unbounded_channel::<RecallOutcome>();
|
||||||
|
let collector = tokio::spawn(async move {
|
||||||
|
let mut stats = RecallStageStats::new();
|
||||||
|
while let Some(o) = rx.recv().await {
|
||||||
|
stats.record(&o);
|
||||||
|
}
|
||||||
|
stats
|
||||||
|
});
|
||||||
|
|
||||||
|
let sem = Arc::new(Semaphore::new(max_inflight));
|
||||||
|
let next_base = AtomicUsize::new(0);
|
||||||
|
let mut shed: u64 = 0;
|
||||||
|
let mut lag_hist = LatencyHistogram::default();
|
||||||
|
|
||||||
|
let start = Instant::now();
|
||||||
|
let deadline = start + stage.duration;
|
||||||
|
let period = Duration::from_secs_f64(1.0 / stage.target_rps.max(1e-9));
|
||||||
|
let mut next = start;
|
||||||
|
let mut dispatched: u64 = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let now = Instant::now();
|
||||||
|
if now >= deadline {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if next <= now {
|
||||||
|
match sem.clone().try_acquire_owned() {
|
||||||
|
Ok(permit) => {
|
||||||
|
let intended = next;
|
||||||
|
lag_hist.record(now.saturating_duration_since(intended));
|
||||||
|
|
||||||
|
// Pick the query (round-robin over the pool) and a target base
|
||||||
|
// (round-robin over the gateways — every replica holds the full
|
||||||
|
// corpus at the S=1 recall shape).
|
||||||
|
let qi = (dispatched as usize) % pool.len().max(1);
|
||||||
|
let base_idx = next_base.fetch_add(1, Ordering::Relaxed) % bases.len().max(1);
|
||||||
|
|
||||||
|
let pool = pool.clone();
|
||||||
|
let bases = bases.clone();
|
||||||
|
let client = client.clone();
|
||||||
|
let tx = tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _permit = permit;
|
||||||
|
let query = &pool.queries[qi];
|
||||||
|
let truth = &pool.truth[qi];
|
||||||
|
let base = &bases[base_idx];
|
||||||
|
let (class, ids) =
|
||||||
|
client.vector_search_ids(base, query, k, ef_search).await;
|
||||||
|
let latency = Instant::now().saturating_duration_since(intended);
|
||||||
|
let recall = ids.map(|ids| recall_at_k(&ids, truth));
|
||||||
|
let _ = tx.send(RecallOutcome {
|
||||||
|
class,
|
||||||
|
latency,
|
||||||
|
recall,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(_) => shed += 1,
|
||||||
|
}
|
||||||
|
next += period;
|
||||||
|
dispatched += 1;
|
||||||
|
if dispatched.is_multiple_of(256) {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tokio::time::sleep_until(next).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let elapsed = start.elapsed();
|
||||||
|
|
||||||
|
drop(tx);
|
||||||
|
let mut stats = collector.await.unwrap_or_else(|_| RecallStageStats::new());
|
||||||
|
stats.elapsed = elapsed;
|
||||||
|
stats.client_shed = shed;
|
||||||
|
stats.p99_schedule_lag = lag_hist.percentile(0.99);
|
||||||
|
stats.max_schedule_lag = lag_hist.max();
|
||||||
|
stats
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn embedding_is_deterministic_and_sized() {
|
||||||
|
let a = embedding_for(7, 32);
|
||||||
|
let b = embedding_for(7, 32);
|
||||||
|
assert_eq!(a, b, "same id+dim must reproduce the same vector");
|
||||||
|
assert_eq!(a.len(), 32);
|
||||||
|
assert_ne!(embedding_for(7, 32), embedding_for(8, 32), "ids differ");
|
||||||
|
// Non-zero norm (the engine rejects zero-norm embeddings).
|
||||||
|
assert!(a.iter().map(|x| x * x).sum::<f32>() > 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ground_truth_ranks_self_first() {
|
||||||
|
let gt = GroundTruth::build(200, 16);
|
||||||
|
// Querying with a corpus vector returns that id first (cosine 1.0).
|
||||||
|
let q = embedding_for(42, 16);
|
||||||
|
let top = gt.top_k(&q, 10);
|
||||||
|
assert_eq!(top.len(), 10);
|
||||||
|
assert_eq!(top[0], 42, "an item's own vector is its nearest neighbor");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recall_at_k_counts_overlap_over_truth_size() {
|
||||||
|
let truth = vec![1, 2, 3, 4, 5];
|
||||||
|
// 3 of 5 truth ids present (extra/ordering ignored).
|
||||||
|
assert!((recall_at_k(&[1, 2, 3, 99, 100], &truth) - 0.6).abs() < 1e-9);
|
||||||
|
assert!((recall_at_k(&[], &truth) - 0.0).abs() < 1e-9);
|
||||||
|
assert!((recall_at_k(&[1, 2, 3, 4, 5], &truth) - 1.0).abs() < 1e-9);
|
||||||
|
// Empty truth ⇒ vacuously perfect (nothing to find).
|
||||||
|
assert!((recall_at_k(&[], &[]) - 1.0).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn query_pool_truth_aligns_with_queries() {
|
||||||
|
let gt = GroundTruth::build(500, 16);
|
||||||
|
let pool = QueryPool::build(>, 20, 10, 0.05);
|
||||||
|
assert_eq!(pool.queries.len(), 20);
|
||||||
|
assert_eq!(pool.truth.len(), 20, "every query has a ground-truth list");
|
||||||
|
for t in &pool.truth {
|
||||||
|
assert_eq!(t.len(), 10, "ground truth is top-k");
|
||||||
|
}
|
||||||
|
// A perturbed corpus point's nearest neighbor should be its own base or a
|
||||||
|
// close id — the brute force must return a non-empty, sane list.
|
||||||
|
assert!(pool.truth.iter().all(|t| !t.is_empty()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -204,6 +204,131 @@ impl RunSummary {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Recall harness summary (m12p1) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/// One recall ramp stage's machine-readable roll-up.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct RecallStageSummary {
|
||||||
|
pub target_rps: f64,
|
||||||
|
pub achieved_rps: f64,
|
||||||
|
pub ok_per_sec: f64,
|
||||||
|
pub error_rate: f64,
|
||||||
|
pub client_shed: u64,
|
||||||
|
/// True p99 query latency (ms) over the open loop.
|
||||||
|
pub p99_ms: f64,
|
||||||
|
/// p50 / max query latency (ms), for the human table.
|
||||||
|
pub p50_ms: f64,
|
||||||
|
pub max_ms: f64,
|
||||||
|
/// Mean recall@k across this stage's parsed responses.
|
||||||
|
pub mean_recall: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecallStageSummary {
|
||||||
|
/// Whether this stage cleared BOTH the latency and recall targets (and took
|
||||||
|
/// no client shed / error budget breach) — the read-knee membership test.
|
||||||
|
#[must_use]
|
||||||
|
pub fn meets_targets(
|
||||||
|
&self,
|
||||||
|
p99_target_ms: f64,
|
||||||
|
recall_target: f64,
|
||||||
|
max_error_rate: f64,
|
||||||
|
) -> bool {
|
||||||
|
self.p99_ms <= p99_target_ms
|
||||||
|
&& self.mean_recall >= recall_target
|
||||||
|
&& self.error_rate <= max_error_rate
|
||||||
|
&& self.client_shed == 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole recall run's machine-readable summary + the read-knee.
|
||||||
|
pub struct RecallRunSummary {
|
||||||
|
pub stages: Vec<RecallStageSummary>,
|
||||||
|
pub k: usize,
|
||||||
|
pub corpus: u64,
|
||||||
|
pub embedding_dim: usize,
|
||||||
|
pub p99_target_ms: f64,
|
||||||
|
pub recall_target: f64,
|
||||||
|
pub max_error_rate: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecallRunSummary {
|
||||||
|
/// The read-knee: the index of the highest-throughput stage that cleared both
|
||||||
|
/// targets, or `None` if no stage did. "Highest throughput" is keyed on the
|
||||||
|
/// stage's ACHIEVED rps (a stage that fell behind its target still counts at
|
||||||
|
/// what it actually sustained).
|
||||||
|
#[must_use]
|
||||||
|
pub fn read_knee(&self) -> Option<usize> {
|
||||||
|
let mut best: Option<(usize, f64)> = None;
|
||||||
|
for (i, s) in self.stages.iter().enumerate() {
|
||||||
|
if s.meets_targets(self.p99_target_ms, self.recall_target, self.max_error_rate) {
|
||||||
|
if let Some((_, rps)) = best {
|
||||||
|
if s.achieved_rps > rps {
|
||||||
|
best = Some((i, s.achieved_rps));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
best = Some((i, s.achieved_rps));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best.map(|(i, _)| i)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the run passed: at least one stage cleared both targets.
|
||||||
|
#[must_use]
|
||||||
|
pub fn passed(&self) -> bool {
|
||||||
|
self.read_knee().is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the flat JSON summary (hand-rolled; all values numeric/bool/null).
|
||||||
|
#[must_use]
|
||||||
|
pub fn to_json(&self) -> String {
|
||||||
|
let mut s = String::new();
|
||||||
|
let knee = self.read_knee();
|
||||||
|
s.push_str("{\n");
|
||||||
|
let _ = writeln!(s, " \"mode\": \"verify-recall\",");
|
||||||
|
let _ = writeln!(s, " \"passed\": {},", self.passed());
|
||||||
|
let _ = writeln!(s, " \"k\": {},", self.k);
|
||||||
|
let _ = writeln!(s, " \"corpus\": {},", self.corpus);
|
||||||
|
let _ = writeln!(s, " \"embedding_dim\": {},", self.embedding_dim);
|
||||||
|
let _ = writeln!(s, " \"p99_target_ms\": {},", jnum(self.p99_target_ms, 3));
|
||||||
|
let _ = writeln!(s, " \"recall_target\": {},", jnum(self.recall_target, 4));
|
||||||
|
if let Some(i) = knee {
|
||||||
|
let _ = writeln!(s, " \"read_knee_stage\": {},", i + 1);
|
||||||
|
let _ = writeln!(
|
||||||
|
s,
|
||||||
|
" \"read_knee_rps\": {},",
|
||||||
|
jnum(self.stages[i].achieved_rps, 1)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
s.push_str(" \"read_knee_stage\": null,\n");
|
||||||
|
s.push_str(" \"read_knee_rps\": null,\n");
|
||||||
|
}
|
||||||
|
s.push_str(" \"stages\": [\n");
|
||||||
|
for (i, st) in self.stages.iter().enumerate() {
|
||||||
|
let meets =
|
||||||
|
st.meets_targets(self.p99_target_ms, self.recall_target, self.max_error_rate);
|
||||||
|
s.push_str(" {");
|
||||||
|
let _ = write!(s, "\"target_rps\": {}, ", jnum(st.target_rps, 1));
|
||||||
|
let _ = write!(s, "\"achieved_rps\": {}, ", jnum(st.achieved_rps, 1));
|
||||||
|
let _ = write!(s, "\"ok_per_sec\": {}, ", jnum(st.ok_per_sec, 1));
|
||||||
|
let _ = write!(s, "\"error_rate\": {}, ", jnum(st.error_rate, 6));
|
||||||
|
let _ = write!(s, "\"client_shed\": {}, ", st.client_shed);
|
||||||
|
let _ = write!(s, "\"p50_ms\": {}, ", jnum(st.p50_ms, 3));
|
||||||
|
let _ = write!(s, "\"p99_ms\": {}, ", jnum(st.p99_ms, 3));
|
||||||
|
let _ = write!(s, "\"max_ms\": {}, ", jnum(st.max_ms, 3));
|
||||||
|
let _ = write!(s, "\"mean_recall\": {}, ", jnum(st.mean_recall, 4));
|
||||||
|
let _ = write!(s, "\"meets_targets\": {meets}");
|
||||||
|
s.push('}');
|
||||||
|
if i + 1 < self.stages.len() {
|
||||||
|
s.push(',');
|
||||||
|
}
|
||||||
|
s.push('\n');
|
||||||
|
}
|
||||||
|
s.push_str(" ]\n}\n");
|
||||||
|
s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Format an `f64` for JSON at `decimals` precision, or `null` if it is
|
/// Format an `f64` for JSON at `decimals` precision, or `null` if it is
|
||||||
/// non-finite (NaN/inf are not valid JSON — a single unguarded field would
|
/// non-finite (NaN/inf are not valid JSON — a single unguarded field would
|
||||||
/// otherwise corrupt the whole artifact). Defense in depth: the inputs are
|
/// otherwise corrupt the whole artifact). Defense in depth: the inputs are
|
||||||
@ -392,4 +517,82 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(s.overall_p99_ms >= s.signal_p99_ms);
|
assert!(s.overall_p99_ms >= s.signal_p99_ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn recall_stage(target_rps: f64, p99_ms: f64, recall: f64) -> RecallStageSummary {
|
||||||
|
RecallStageSummary {
|
||||||
|
target_rps,
|
||||||
|
achieved_rps: target_rps,
|
||||||
|
ok_per_sec: target_rps,
|
||||||
|
error_rate: 0.0,
|
||||||
|
client_shed: 0,
|
||||||
|
p99_ms,
|
||||||
|
p50_ms: p99_ms / 2.0,
|
||||||
|
max_ms: p99_ms * 1.5,
|
||||||
|
mean_recall: recall,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_knee_is_highest_throughput_stage_meeting_both_targets() {
|
||||||
|
// Stages 1-2 meet both targets; stage 3 blows p99; stage 4 blows recall.
|
||||||
|
let run = RecallRunSummary {
|
||||||
|
stages: vec![
|
||||||
|
recall_stage(500.0, 4.0, 0.97),
|
||||||
|
recall_stage(1500.0, 8.0, 0.96),
|
||||||
|
recall_stage(3000.0, 22.0, 0.95), // p99 over 10ms target
|
||||||
|
recall_stage(5000.0, 6.0, 0.80), // recall under 0.95 target
|
||||||
|
],
|
||||||
|
k: 10,
|
||||||
|
corpus: 1_000_000,
|
||||||
|
embedding_dim: 1536,
|
||||||
|
p99_target_ms: 10.0,
|
||||||
|
recall_target: 0.95,
|
||||||
|
max_error_rate: 0.01,
|
||||||
|
};
|
||||||
|
// The knee is the highest-throughput PASSING stage (index 1, 1500 rps).
|
||||||
|
assert_eq!(run.read_knee(), Some(1));
|
||||||
|
assert!(run.passed());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_knee_none_when_no_stage_meets_targets() {
|
||||||
|
let run = RecallRunSummary {
|
||||||
|
stages: vec![recall_stage(500.0, 50.0, 0.90)],
|
||||||
|
k: 10,
|
||||||
|
corpus: 100_000,
|
||||||
|
embedding_dim: 1536,
|
||||||
|
p99_target_ms: 10.0,
|
||||||
|
recall_target: 0.95,
|
||||||
|
max_error_rate: 0.01,
|
||||||
|
};
|
||||||
|
assert_eq!(run.read_knee(), None);
|
||||||
|
assert!(!run.passed());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recall_json_round_trips_through_a_real_parser() {
|
||||||
|
let run = RecallRunSummary {
|
||||||
|
stages: vec![
|
||||||
|
recall_stage(500.0, 4.5, 0.972),
|
||||||
|
recall_stage(3000.0, 21.0, 0.95),
|
||||||
|
],
|
||||||
|
k: 10,
|
||||||
|
corpus: 1_000_000,
|
||||||
|
embedding_dim: 1536,
|
||||||
|
p99_target_ms: 10.0,
|
||||||
|
recall_target: 0.95,
|
||||||
|
max_error_rate: 0.01,
|
||||||
|
};
|
||||||
|
let parsed: serde_json::Value =
|
||||||
|
serde_json::from_str(&run.to_json()).expect("recall to_json must be valid JSON");
|
||||||
|
assert_eq!(parsed["mode"], "verify-recall");
|
||||||
|
assert_eq!(parsed["passed"], true);
|
||||||
|
assert_eq!(parsed["k"], 10);
|
||||||
|
assert_eq!(parsed["embedding_dim"], 1536);
|
||||||
|
assert_eq!(parsed["read_knee_stage"], 1);
|
||||||
|
let stages = parsed["stages"].as_array().expect("stages array");
|
||||||
|
assert_eq!(stages.len(), 2);
|
||||||
|
assert_eq!(stages[0]["meets_targets"], true);
|
||||||
|
assert_eq!(stages[1]["meets_targets"], false); // p99 21ms > 10ms
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -208,6 +208,9 @@ pub struct Workload {
|
|||||||
feed_limit: u32,
|
feed_limit: u32,
|
||||||
embedding_dim: usize,
|
embedding_dim: usize,
|
||||||
categories: Vec<&'static str>,
|
categories: Vec<&'static str>,
|
||||||
|
/// When set, EVERY feed read uses this profile instead of the weighted mix —
|
||||||
|
/// so a ramp can measure one profile's retrieve p99 in isolation (m12p2).
|
||||||
|
forced_profile: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default category vocabulary — stands in for thepeach companion/post tags so
|
/// Default category vocabulary — stands in for thepeach companion/post tags so
|
||||||
@ -239,6 +242,7 @@ impl Workload {
|
|||||||
hot_skew: f64,
|
hot_skew: f64,
|
||||||
feed_limit: u32,
|
feed_limit: u32,
|
||||||
embedding_dim: usize,
|
embedding_dim: usize,
|
||||||
|
forced_profile: Option<String>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
// for_you is the deployed default and the one E2 will A/B; trending/hot/new
|
// for_you is the deployed default and the one E2 will A/B; trending/hot/new
|
||||||
// exercise the other built-in sort paths. (related/following need
|
// exercise the other built-in sort paths. (related/following need
|
||||||
@ -267,6 +271,7 @@ impl Workload {
|
|||||||
feed_limit,
|
feed_limit,
|
||||||
embedding_dim,
|
embedding_dim,
|
||||||
categories: CATEGORIES.to_vec(),
|
categories: CATEGORIES.to_vec(),
|
||||||
|
forced_profile,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -312,7 +317,10 @@ impl Workload {
|
|||||||
match op {
|
match op {
|
||||||
OpKind::FeedRead => {
|
OpKind::FeedRead => {
|
||||||
let user = self.pick_user(rng);
|
let user = self.pick_user(rng);
|
||||||
let profile = self.pick_profile(rng);
|
let profile = self
|
||||||
|
.forced_profile
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_else(|| self.pick_profile(rng));
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"{}/feed?profile={profile}&user_id={user}&limit={}",
|
"{}/feed?profile={profile}&user_id={user}&limit={}",
|
||||||
self.reads.pick(),
|
self.reads.pick(),
|
||||||
@ -486,6 +494,7 @@ mod tests {
|
|||||||
1.5,
|
1.5,
|
||||||
24,
|
24,
|
||||||
128,
|
128,
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
let mut rng = rand::rng();
|
let mut rng = rand::rng();
|
||||||
let mut low = 0;
|
let mut low = 0;
|
||||||
|
|||||||
@ -101,6 +101,9 @@ name = "actix_embedding"
|
|||||||
[[example]]
|
[[example]]
|
||||||
name = "cli_embedding"
|
name = "cli_embedding"
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "ann_grid_search"
|
||||||
|
|
||||||
[[test]]
|
[[test]]
|
||||||
name = "sandboxed_storage"
|
name = "sandboxed_storage"
|
||||||
required-features = ["test-utils"]
|
required-features = ["test-utils"]
|
||||||
|
|||||||
@ -6,7 +6,17 @@
|
|||||||
|
|
||||||
//! Criterion benchmarks for production-representative load: 1M items.
|
//! Criterion benchmarks for production-representative load: 1M items.
|
||||||
//!
|
//!
|
||||||
//! Validates the m7p3 performance acceptance criteria:
|
//! These measure **isolated per-op cost (mean, single-threaded, closed loop)** —
|
||||||
|
//! a regression tripwire, NOT tail-SLO evidence. Criterion's `[lower mean upper]`
|
||||||
|
//! is a confidence interval on the MEAN; a closed loop stops sending when the
|
||||||
|
//! system stalls, so the queue that would inflate p99 never forms. The tail SLOs
|
||||||
|
//! below are the targets these means must sit comfortably under (a necessary, not
|
||||||
|
//! sufficient, condition); the p99/p999 themselves are signed off only by the
|
||||||
|
//! open-loop, coordinated-omission-corrected `tidal-stress` ramp — see
|
||||||
|
//! `docs/profiling/scale-baselines.md` (and `tidal-stress --verify-recall` for
|
||||||
|
//! recall@k at the production shape).
|
||||||
|
//!
|
||||||
|
//! Tail SLOs (validated open-loop, NOT by these means):
|
||||||
//! - RETRIEVE p99 < 50ms
|
//! - RETRIEVE p99 < 50ms
|
||||||
//! - SEARCH p99 < 100ms
|
//! - SEARCH p99 < 100ms
|
||||||
//! - Signal write p99 < 100µs
|
//! - Signal write p99 < 100µs
|
||||||
|
|||||||
@ -10,10 +10,13 @@
|
|||||||
//! All setup (index construction, vector insertion) is done OUTSIDE the
|
//! All setup (index construction, vector insertion) is done OUTSIDE the
|
||||||
//! `b.iter()` closure. Only the search/insert/delete call is measured.
|
//! `b.iter()` closure. Only the search/insert/delete call is measured.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use criterion::{Criterion, black_box, criterion_group, criterion_main};
|
use criterion::{Criterion, black_box, criterion_group, criterion_main};
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
use tidaldb::storage::vector::{
|
use tidaldb::storage::vector::{
|
||||||
BruteForceIndex, DistanceMetric, QuantizationLevel, VectorId, VectorIndex, VectorIndexConfig,
|
BruteForceIndex, DistanceMetric, QuantizationLevel, UsearchIndex, VectorId, VectorIndex,
|
||||||
|
VectorIndexConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -179,6 +182,72 @@ fn bench_ann_recall_at_100(c: &mut Criterion) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Benchmark: HNSW **recall@10 at the production shape** (1536D, F16) vs an exact
|
||||||
|
/// brute-force ground truth over the SAME 10K vectors.
|
||||||
|
///
|
||||||
|
/// This extends the 128D `bench_ann_recall_at_100` to the production embedding
|
||||||
|
/// width (m12p1): unlike the brute-force variants above (which are exact, so
|
||||||
|
/// recall is trivially 1.0 and the bench measures only the search+compare cost),
|
||||||
|
/// this builds a real `UsearchIndex` (HNSW) and measures the approximation's
|
||||||
|
/// recall against a `BruteForceIndex` oracle on each iteration. It is a LOCAL
|
||||||
|
/// micro-tripwire at one shape; the authoritative recall@10 + true p99 across
|
||||||
|
/// 100k/1M is `tidal-stress --verify-recall` (open-loop, real server).
|
||||||
|
fn bench_ann_recall_at_10_1536d(c: &mut Criterion) {
|
||||||
|
let dim = 1536;
|
||||||
|
let n = 10_000_u64;
|
||||||
|
let recall_k = 10;
|
||||||
|
|
||||||
|
// Generate the corpus ONCE and insert the identical vectors into both indexes
|
||||||
|
// so the brute-force result is a true ground truth for the HNSW result.
|
||||||
|
let mut rng = rand::rng();
|
||||||
|
let vectors: Vec<Vec<f32>> = (0..n).map(|_| random_unit_vector(dim, &mut rng)).collect();
|
||||||
|
|
||||||
|
let brute = BruteForceIndex::new(VectorIndexConfig {
|
||||||
|
dimensions: dim,
|
||||||
|
metric: DistanceMetric::L2,
|
||||||
|
quantization: QuantizationLevel::F32,
|
||||||
|
connectivity: 16,
|
||||||
|
ef_construction: 400,
|
||||||
|
ef_search: 200,
|
||||||
|
});
|
||||||
|
// Production HNSW posture: M=16, ef_construction=400, F16 quantization.
|
||||||
|
let hnsw = UsearchIndex::new(VectorIndexConfig {
|
||||||
|
dimensions: dim,
|
||||||
|
metric: DistanceMetric::L2,
|
||||||
|
quantization: QuantizationLevel::F16,
|
||||||
|
connectivity: 16,
|
||||||
|
ef_construction: 400,
|
||||||
|
ef_search: 200,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
hnsw.reserve(n as usize).unwrap();
|
||||||
|
for (id, v) in vectors.iter().enumerate() {
|
||||||
|
brute.insert(id as VectorId, v).unwrap();
|
||||||
|
hnsw.insert(id as VectorId, v).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = random_unit_vector(dim, &mut rng);
|
||||||
|
// Exact top-`recall_k` ground truth from the brute-force index.
|
||||||
|
let gt: HashSet<VectorId> = brute
|
||||||
|
.search(&query, recall_k, 200)
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|r| r.id)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
c.bench_function("ann_recall_at_10_1536d_10k", |b| {
|
||||||
|
b.iter(|| {
|
||||||
|
let results = hnsw
|
||||||
|
.search(black_box(&query), black_box(recall_k), black_box(200))
|
||||||
|
.unwrap();
|
||||||
|
let hits = results.iter().filter(|r| gt.contains(&r.id)).count();
|
||||||
|
#[allow(clippy::cast_precision_loss)]
|
||||||
|
let recall = hits as f64 / recall_k as f64;
|
||||||
|
black_box(recall)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Benchmark: single vector insert into a pre-filled 10K index.
|
/// Benchmark: single vector insert into a pre-filled 10K index.
|
||||||
fn bench_ann_insert_single(c: &mut Criterion) {
|
fn bench_ann_insert_single(c: &mut Criterion) {
|
||||||
let dim = 128;
|
let dim = 128;
|
||||||
@ -232,6 +301,7 @@ criterion_group!(
|
|||||||
bench_ann_search_filtered_5pct,
|
bench_ann_search_filtered_5pct,
|
||||||
bench_ann_search_brute_force,
|
bench_ann_search_brute_force,
|
||||||
bench_ann_recall_at_100,
|
bench_ann_recall_at_100,
|
||||||
|
bench_ann_recall_at_10_1536d,
|
||||||
bench_ann_insert_single,
|
bench_ann_insert_single,
|
||||||
bench_ann_delete_single,
|
bench_ann_delete_single,
|
||||||
);
|
);
|
||||||
|
|||||||
464
tidal/examples/ann_grid_search.rs
Normal file
464
tidal/examples/ann_grid_search.rs
Normal file
@ -0,0 +1,464 @@
|
|||||||
|
//! m12p3 ANN parameter grid search + quantization recall/memory frontier.
|
||||||
|
//!
|
||||||
|
//! Builds a `UsearchIndex` (HNSW) and an exact `BruteForceIndex` oracle over the
|
||||||
|
//! SAME deterministic, id-keyed corpus, then sweeps the HNSW parameters and the
|
||||||
|
//! quantization level — reporting, for each point, the **measured** recall@k vs
|
||||||
|
//! the exact oracle, the single-thread mean/p99 search latency, the build time,
|
||||||
|
//! and the **true** in-memory footprint (`UsearchIndex::memory_usage`, which
|
||||||
|
//! includes the proximity-graph links, not just the vectors).
|
||||||
|
//!
|
||||||
|
//! This is the tool that produces the documented `M`/`ef` and the F32/F16/Int8
|
||||||
|
//! recall+memory numbers the m12p3 exit gate requires — at the production shape
|
||||||
|
//! (1536-D). Recall here is a *property of the index given its parameters* and is
|
||||||
|
//! independent of load, so a deterministic single-thread harness is the right
|
||||||
|
//! instrument; the authoritative tail-latency-under-load number is the open-loop
|
||||||
|
//! `tidal-stress --verify-recall` ramp (m12p1).
|
||||||
|
//!
|
||||||
|
//! Run (local, 100k/1536-D — the exit-gate shape that fits one laptop):
|
||||||
|
//! ```bash
|
||||||
|
//! cargo run --release --example ann_grid_search -- \
|
||||||
|
//! --corpus 100000 --dim 1536 --queries 200 --k 10
|
||||||
|
//! ```
|
||||||
|
//! The 1M shape needs ≈ 6 GB for the F32 oracle (1M × 1536 × 4 B) plus the HNSW;
|
||||||
|
//! run it on the k3s node with `--corpus 1000000`.
|
||||||
|
|
||||||
|
#![allow(
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_sign_loss
|
||||||
|
)]
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
collections::HashSet,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
use tidaldb::storage::vector::{
|
||||||
|
BruteForceIndex, DistanceMetric, QuantizationLevel, UsearchIndex, VectorId, VectorIndex,
|
||||||
|
VectorIndexConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Deterministic corpus (SplitMix64) — reproducible recall ground truth
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A deterministic, L2-normalized unit vector for `id` at dimensionality `dim`.
|
||||||
|
///
|
||||||
|
/// Same family the m12p1 recall harness uses (`SplitMix64`), so the corpus is
|
||||||
|
/// reproducible across runs and machines — the recall numbers are comparable.
|
||||||
|
fn unit_vector(id: u64, dim: usize) -> Vec<f32> {
|
||||||
|
let mut state = id
|
||||||
|
.wrapping_mul(0x9E37_79B9_7F4A_7C15)
|
||||||
|
.wrapping_add(0x1234_5678_9ABC_DEF0);
|
||||||
|
let mut next = || {
|
||||||
|
state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||||
|
let mut z = state;
|
||||||
|
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||||
|
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||||
|
z ^= z >> 31;
|
||||||
|
// Map the high mantissa bits into [-0.5, 0.5).
|
||||||
|
((z >> 40) as f32 / (1u64 << 24) as f32) - 0.5
|
||||||
|
};
|
||||||
|
let mut v: Vec<f32> = (0..dim).map(|_| next()).collect();
|
||||||
|
normalize(&mut v);
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
/// L2-normalize in place (unit vector), falling back to the first axis if zero.
|
||||||
|
fn normalize(v: &mut [f32]) {
|
||||||
|
let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||||
|
if norm > f32::EPSILON {
|
||||||
|
for x in v.iter_mut() {
|
||||||
|
*x /= norm;
|
||||||
|
}
|
||||||
|
} else if let Some(first) = v.first_mut() {
|
||||||
|
*first = 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A clustered (Gaussian-mixture) corpus vector for `id` — the faithful
|
||||||
|
/// representation of real embedding geometry, and the right instrument for an
|
||||||
|
/// honest recall@k.
|
||||||
|
///
|
||||||
|
/// Uniform-random high-dimensional vectors are PATHOLOGICAL for recall@k: by the
|
||||||
|
/// concentration of measure, every pair sits at cosine ≈ 0, so beyond a tiny
|
||||||
|
/// perturbation a query's "top-10" is an arbitrary draw from a thick equidistant
|
||||||
|
/// shell — recall@10 then measures impossible tie-breaking, not index quality,
|
||||||
|
/// and (worse) it gets *lower* as the corpus grows because the shell thickens.
|
||||||
|
/// (Measured: uniform-random recall@10 fell from ~0.97 at 10k to ~0.54 at 100k —
|
||||||
|
/// a corpus-size artifact, not an index regression.)
|
||||||
|
///
|
||||||
|
/// Real text/image embeddings instead live on a low-dimensional manifold with
|
||||||
|
/// clusters: a point's nearest neighbours are its cluster-mates, distinctly
|
||||||
|
/// closer than the bulk. We model that as a Gaussian mixture: `id` is assigned to
|
||||||
|
/// cluster `id % n_clusters`, and the vector is `center + spread · noise`,
|
||||||
|
/// normalized. With `spread = 0.5`, intra-cluster cosine ≈ 0.8 and inter-cluster
|
||||||
|
/// ≈ 0 — a clear neighbour structure, exactly the shape `related`/`for_you` reads
|
||||||
|
/// query against, where recall@k is a meaningful index-quality metric.
|
||||||
|
fn clustered_vector(centers: &[Vec<f32>], id: u64, dim: usize, spread: f32) -> Vec<f32> {
|
||||||
|
let center = ¢ers[(id as usize) % centers.len()];
|
||||||
|
let noise = unit_vector(id, dim);
|
||||||
|
let mut v: Vec<f32> = center
|
||||||
|
.iter()
|
||||||
|
.zip(&noise)
|
||||||
|
.map(|(c, nz)| c + spread * nz)
|
||||||
|
.collect();
|
||||||
|
normalize(&mut v);
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A realistic query: a corpus point (spread across the catalog) perturbed by
|
||||||
|
/// deterministic noise of magnitude `noise` RELATIVE to the unit base — so the
|
||||||
|
/// query sits a small, controlled angle off a real corpus point. This is the
|
||||||
|
/// shape of a production `related`/`for_you` read (a seed/preference vector near
|
||||||
|
/// real content), and the same realistic model the authoritative m12p1 recall
|
||||||
|
/// harness (`tidal-stress` `QueryPool`) uses.
|
||||||
|
///
|
||||||
|
/// Uniform-random high-dim queries are the WRONG instrument: in 1536-D their
|
||||||
|
/// neighbours are near-equidistant (distance concentration), so recall looks
|
||||||
|
/// pathologically low for reasons unrelated to the index.
|
||||||
|
///
|
||||||
|
/// The noise is scaled by `1/√(dim/12)` so the additive perturbation has total
|
||||||
|
/// magnitude ≈ `noise` against the *unit* base (a raw `noise × U[-0.5,0.5]` per
|
||||||
|
/// component would be ≈ `noise·√(dim/12)` — ~11× too large at 1536-D, which would
|
||||||
|
/// push the query far off its base and understate recall). The query is NOT
|
||||||
|
/// re-normalized: the corpus is unit-norm, so L2 order over it equals cosine
|
||||||
|
/// order even for a non-unit query (`‖c‖=1` ⇒ argmin‖q−c‖² = argmax q·c) — the
|
||||||
|
/// exact metric the engine serves (it normalizes on write, not on query).
|
||||||
|
fn perturbed_query(corpus: &[Vec<f32>], i: usize, noise: f32) -> Vec<f32> {
|
||||||
|
let n = corpus.len().max(1) as u64;
|
||||||
|
let base = ((i as u64).wrapping_mul(2_654_435_761) % n) as usize;
|
||||||
|
let mut q = corpus[base].clone();
|
||||||
|
let dim = q.len().max(1);
|
||||||
|
// Per-component coefficient so ‖noise_vec‖ ≈ noise for a unit base.
|
||||||
|
let coef = noise / (dim as f32 / 12.0).sqrt();
|
||||||
|
let mut state = (i as u64)
|
||||||
|
.wrapping_mul(0x100_0000_01B3)
|
||||||
|
.wrapping_add(0xABCD);
|
||||||
|
let mut next = || {
|
||||||
|
state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||||
|
let mut z = state;
|
||||||
|
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||||
|
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||||
|
z ^= z >> 31;
|
||||||
|
((z >> 40) as f32 / (1u64 << 24) as f32) - 0.5
|
||||||
|
};
|
||||||
|
for c in &mut q {
|
||||||
|
*c += coef * next();
|
||||||
|
}
|
||||||
|
q
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Measurement
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// recall@k of `got` against the exact `truth` id set.
|
||||||
|
fn recall(truth: &HashSet<VectorId>, got: &[VectorId], k: usize) -> f64 {
|
||||||
|
let hits = got.iter().filter(|id| truth.contains(id)).count();
|
||||||
|
hits as f64 / k as f64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One grid/quant point's measured outcome.
|
||||||
|
struct Point {
|
||||||
|
label: String,
|
||||||
|
recall: f64,
|
||||||
|
mean_us: f64,
|
||||||
|
p99_us: f64,
|
||||||
|
build_s: f64,
|
||||||
|
mem_mb: f64,
|
||||||
|
mem_per_1m_gb: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a `UsearchIndex` with the given parameters over `vectors`, returning the
|
||||||
|
/// built index and the wall-clock build time.
|
||||||
|
fn build_hnsw(
|
||||||
|
vectors: &[Vec<f32>],
|
||||||
|
dim: usize,
|
||||||
|
quant: QuantizationLevel,
|
||||||
|
connectivity: usize,
|
||||||
|
ef_construction: usize,
|
||||||
|
ef_search: usize,
|
||||||
|
) -> (UsearchIndex, Duration) {
|
||||||
|
let index = UsearchIndex::new(VectorIndexConfig {
|
||||||
|
dimensions: dim,
|
||||||
|
metric: DistanceMetric::L2,
|
||||||
|
quantization: quant,
|
||||||
|
connectivity,
|
||||||
|
ef_construction,
|
||||||
|
ef_search,
|
||||||
|
})
|
||||||
|
.expect("usearch index construction");
|
||||||
|
// Parallel build, done CORRECTLY: reserve one writer slot per thread up front
|
||||||
|
// (`reserve_with_threads`), THEN insert distinct ids concurrently. A plain
|
||||||
|
// `reserve` allocates a single slot, so concurrent `add` would corrupt the
|
||||||
|
// graph (recall collapses ~0.95→~0.1) — that was tried and rejected. With the
|
||||||
|
// per-thread reservation, USearch's own test does exactly this, and recall
|
||||||
|
// matches a sequential build while the 100k/1536-D build drops from ~10 min to
|
||||||
|
// well under a minute.
|
||||||
|
let threads = std::thread::available_parallelism().map_or(8, std::num::NonZeroUsize::get);
|
||||||
|
index
|
||||||
|
.reserve_with_threads(vectors.len(), threads)
|
||||||
|
.expect("reserve");
|
||||||
|
let start = Instant::now();
|
||||||
|
let chunk = vectors.len().div_ceil(threads).max(1);
|
||||||
|
std::thread::scope(|s| {
|
||||||
|
for (c, slice) in vectors.chunks(chunk).enumerate() {
|
||||||
|
let index = &index;
|
||||||
|
let base = c * chunk;
|
||||||
|
s.spawn(move || {
|
||||||
|
for (i, v) in slice.iter().enumerate() {
|
||||||
|
index.insert((base + i) as VectorId, v).expect("insert");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
(index, start.elapsed())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `queries` against `index` at `ef_search`, scoring recall@k vs `truths`
|
||||||
|
/// and recording per-query latency. Returns `(avg_recall, mean_us, p99_us)`.
|
||||||
|
fn measure(
|
||||||
|
index: &UsearchIndex,
|
||||||
|
queries: &[Vec<f32>],
|
||||||
|
truths: &[HashSet<VectorId>],
|
||||||
|
k: usize,
|
||||||
|
ef_search: usize,
|
||||||
|
) -> (f64, f64, f64) {
|
||||||
|
let mut recalls = 0.0;
|
||||||
|
let mut lat_us: Vec<f64> = Vec::with_capacity(queries.len());
|
||||||
|
for (q, truth) in queries.iter().zip(truths) {
|
||||||
|
let start = Instant::now();
|
||||||
|
let res = index.search(q, k, ef_search).expect("search");
|
||||||
|
lat_us.push(start.elapsed().as_secs_f64() * 1e6);
|
||||||
|
let ids: Vec<VectorId> = res.iter().map(|r| r.id).collect();
|
||||||
|
recalls += recall(truth, &ids, k);
|
||||||
|
}
|
||||||
|
let mean = lat_us.iter().sum::<f64>() / lat_us.len() as f64;
|
||||||
|
lat_us.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
let p99 = lat_us[((lat_us.len() as f64 * 0.99) as usize).min(lat_us.len() - 1)];
|
||||||
|
(recalls / queries.len() as f64, mean, p99)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Footprint in MB now, and extrapolated to 1M vectors (GB), from the measured
|
||||||
|
/// `memory_usage` of the built index over `n` vectors.
|
||||||
|
fn footprint(index: &UsearchIndex, n: usize) -> (f64, f64) {
|
||||||
|
let bytes = index.memory_usage();
|
||||||
|
let mb = bytes as f64 / (1024.0 * 1024.0);
|
||||||
|
let per_1m_gb = (bytes as f64 / n as f64) * 1_000_000.0 / (1024.0 * 1024.0 * 1024.0);
|
||||||
|
(mb, per_1m_gb)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn quant_name(q: QuantizationLevel) -> &'static str {
|
||||||
|
match q {
|
||||||
|
QuantizationLevel::F32 => "F32",
|
||||||
|
QuantizationLevel::F16 => "F16",
|
||||||
|
QuantizationLevel::Int8 => "Int8",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Arg parsing (tiny, dependency-free)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn arg(args: &[String], flag: &str, default: usize) -> usize {
|
||||||
|
args.iter()
|
||||||
|
.position(|a| a == flag)
|
||||||
|
.and_then(|i| args.get(i + 1))
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(default)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_lines)] // a linear measurement script reads best top-to-bottom
|
||||||
|
fn main() {
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
let corpus = arg(&args, "--corpus", 20_000);
|
||||||
|
let dim = arg(&args, "--dim", 1536);
|
||||||
|
let n_queries = arg(&args, "--queries", 200);
|
||||||
|
let k = arg(&args, "--k", 10);
|
||||||
|
// Query perturbation noise (× 1000 on the CLI to keep the arg an integer):
|
||||||
|
// `--noise-milli 50` ⇒ 0.05 relative — a query a few degrees off a real point.
|
||||||
|
let noise = arg(&args, "--noise-milli", 50) as f32 / 1000.0;
|
||||||
|
// Clustered corpus shape (Gaussian mixture — see `clustered_vector`):
|
||||||
|
// `--clusters` defaults to ~100 points/cluster; `--spread-milli 500` ⇒ 0.5.
|
||||||
|
let n_clusters = arg(&args, "--clusters", (corpus / 100).max(1));
|
||||||
|
let spread = arg(&args, "--spread-milli", 500) as f32 / 1000.0;
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
"[grid] building clustered corpus: {corpus} vectors × {dim}-D, {n_clusters} clusters \
|
||||||
|
(spread {spread:.2}), {n_queries} queries (noise {noise:.3}), recall@{k}"
|
||||||
|
);
|
||||||
|
let corpus_start = Instant::now();
|
||||||
|
let centers: Vec<Vec<f32>> = (0..n_clusters as u64)
|
||||||
|
.map(|c| unit_vector(c.wrapping_add(0x00C0_FFEE), dim))
|
||||||
|
.collect();
|
||||||
|
let vectors: Vec<Vec<f32>> = (0..corpus as u64)
|
||||||
|
.map(|id| clustered_vector(¢ers, id, dim, spread))
|
||||||
|
.collect();
|
||||||
|
// Queries are corpus points perturbed by small noise — they stay in-cluster,
|
||||||
|
// so each has a well-defined nearest cluster (the production read shape, the
|
||||||
|
// same model the m12p1 harness uses; see `perturbed_query`).
|
||||||
|
let queries: Vec<Vec<f32>> = (0..n_queries)
|
||||||
|
.map(|q| perturbed_query(&vectors, q, noise))
|
||||||
|
.collect();
|
||||||
|
eprintln!(
|
||||||
|
"[grid] corpus built in {:.1}s",
|
||||||
|
corpus_start.elapsed().as_secs_f64()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Exact ground truth, computed ONCE (independent of HNSW parameters) and
|
||||||
|
// reused across every grid/quant point. F32 brute force == the true answer.
|
||||||
|
eprintln!("[grid] computing exact brute-force ground truth (once)…");
|
||||||
|
let gt_start = Instant::now();
|
||||||
|
let oracle = BruteForceIndex::new(VectorIndexConfig {
|
||||||
|
dimensions: dim,
|
||||||
|
metric: DistanceMetric::L2,
|
||||||
|
quantization: QuantizationLevel::F32,
|
||||||
|
connectivity: 16,
|
||||||
|
ef_construction: 400,
|
||||||
|
ef_search: 400,
|
||||||
|
});
|
||||||
|
for (id, v) in vectors.iter().enumerate() {
|
||||||
|
oracle.insert(id as VectorId, v).expect("oracle insert");
|
||||||
|
}
|
||||||
|
let truths: Vec<HashSet<VectorId>> = queries
|
||||||
|
.iter()
|
||||||
|
.map(|q| {
|
||||||
|
oracle
|
||||||
|
.search(q, k, 0)
|
||||||
|
.expect("oracle search")
|
||||||
|
.iter()
|
||||||
|
.map(|r| r.id)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
eprintln!(
|
||||||
|
"[grid] ground truth ready in {:.1}s",
|
||||||
|
gt_start.elapsed().as_secs_f64()
|
||||||
|
);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Sweep 1 — HNSW graph parameters at F16 (the production quantization).
|
||||||
|
// For each (M, ef_construction) we build the graph ONCE and sweep ef_search
|
||||||
|
// on it (search-time only — no rebuild), since ef_search is a per-query knob.
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
let grid_graphs: &[(usize, usize)] = &[(16, 400), (24, 400), (32, 400)];
|
||||||
|
let ef_searches: &[usize] = &[128, 200, 400, 600];
|
||||||
|
let mut grid_points: Vec<Point> = Vec::new();
|
||||||
|
for &(m, ef_c) in grid_graphs {
|
||||||
|
eprintln!("[grid] building HNSW M={m} ef_c={ef_c} (F16)…");
|
||||||
|
let (index, build) = build_hnsw(&vectors, dim, QuantizationLevel::F16, m, ef_c, 200);
|
||||||
|
let (mem_mb, mem_1m) = footprint(&index, corpus);
|
||||||
|
for &ef_s in ef_searches {
|
||||||
|
let (rec, mean, p99) = measure(&index, &queries, &truths, k, ef_s);
|
||||||
|
grid_points.push(Point {
|
||||||
|
label: format!("M={m}, ef_c={ef_c}, ef_s={ef_s}"),
|
||||||
|
recall: rec,
|
||||||
|
mean_us: mean,
|
||||||
|
p99_us: p99,
|
||||||
|
build_s: build.as_secs_f64(),
|
||||||
|
mem_mb,
|
||||||
|
mem_per_1m_gb: mem_1m,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Sweep 2 — quantization at a graph + beam that clears the 0.95 gate
|
||||||
|
// (M=24, ef_c=400, ef_s=400), so the F32→F16→Int8 recall penalty and the
|
||||||
|
// memory saving are compared on a config that actually meets the target.
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
let quants = [
|
||||||
|
QuantizationLevel::F32,
|
||||||
|
QuantizationLevel::F16,
|
||||||
|
QuantizationLevel::Int8,
|
||||||
|
];
|
||||||
|
let quant_m = 24usize;
|
||||||
|
let quant_ef_construction = 400usize;
|
||||||
|
let quant_beam = 400usize;
|
||||||
|
let mut quant_points: Vec<Point> = Vec::new();
|
||||||
|
for q in quants {
|
||||||
|
eprintln!(
|
||||||
|
"[grid] building HNSW {} M={quant_m} ef_c={quant_ef_construction}…",
|
||||||
|
quant_name(q)
|
||||||
|
);
|
||||||
|
let (index, build) =
|
||||||
|
build_hnsw(&vectors, dim, q, quant_m, quant_ef_construction, quant_beam);
|
||||||
|
let (mem_mb, mem_1m) = footprint(&index, corpus);
|
||||||
|
let (rec, mean, p99) = measure(&index, &queries, &truths, k, quant_beam);
|
||||||
|
quant_points.push(Point {
|
||||||
|
label: format!(
|
||||||
|
"{} (M={quant_m}, ef_c={quant_ef_construction}, ef_s={quant_beam})",
|
||||||
|
quant_name(q)
|
||||||
|
),
|
||||||
|
recall: rec,
|
||||||
|
mean_us: mean,
|
||||||
|
p99_us: p99,
|
||||||
|
build_s: build.as_secs_f64(),
|
||||||
|
mem_mb,
|
||||||
|
mem_per_1m_gb: mem_1m,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Report — markdown tables ready to paste into docs/profiling/.
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
println!(
|
||||||
|
"\n## ANN grid search — measured (corpus={corpus}, dim={dim}, recall@{k}, queries={n_queries})\n"
|
||||||
|
);
|
||||||
|
println!("### HNSW parameter sweep (F16)\n");
|
||||||
|
println!(
|
||||||
|
"| M / ef_c / ef_s | recall@{k} | mean (µs) | p99 (µs) | build (s) | mem (MB) | mem/1M (GB) |"
|
||||||
|
);
|
||||||
|
println!("|---|---|---|---|---|---|---|");
|
||||||
|
for p in &grid_points {
|
||||||
|
println!(
|
||||||
|
"| {} | {:.4} | {:.0} | {:.0} | {:.1} | {:.0} | {:.2} |",
|
||||||
|
p.label, p.recall, p.mean_us, p.p99_us, p.build_s, p.mem_mb, p.mem_per_1m_gb
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\n### Quantization sweep (M=16, ef_c=400, ef_s=200)\n");
|
||||||
|
println!("| quantization | recall@{k} | mean (µs) | p99 (µs) | mem (MB) | mem/1M (GB) |");
|
||||||
|
println!("|---|---|---|---|---|---|");
|
||||||
|
for p in &quant_points {
|
||||||
|
println!(
|
||||||
|
"| {} | {:.4} | {:.0} | {:.0} | {:.0} | {:.2} |",
|
||||||
|
p.label, p.recall, p.mean_us, p.p99_us, p.mem_mb, p.mem_per_1m_gb
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recommend the cheapest grid point (min mean latency) that clears recall ≥ 0.95.
|
||||||
|
let best = grid_points
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.recall >= 0.95)
|
||||||
|
.min_by(|a, b| {
|
||||||
|
a.mean_us
|
||||||
|
.partial_cmp(&b.mean_us)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
});
|
||||||
|
println!("\n### Recommendation\n");
|
||||||
|
match best {
|
||||||
|
Some(p) => println!(
|
||||||
|
"- **Frontier point:** `{}` → recall@{k} {:.4}, mean {:.0} µs, p99 {:.0} µs, {:.2} GB/1M.",
|
||||||
|
p.label, p.recall, p.mean_us, p.p99_us, p.mem_per_1m_gb
|
||||||
|
),
|
||||||
|
None => println!(
|
||||||
|
"- ⚠ NO grid point reached recall@{k} ≥ 0.95 at this shape — widen ef or raise M."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
let smallest_ok = quant_points
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.recall >= 0.95)
|
||||||
|
.min_by(|a, b| {
|
||||||
|
a.mem_per_1m_gb
|
||||||
|
.partial_cmp(&b.mem_per_1m_gb)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
});
|
||||||
|
if let Some(p) = smallest_ok {
|
||||||
|
println!(
|
||||||
|
"- **Smallest quantization clearing recall ≥ 0.95:** `{}` → {:.4} recall, {:.2} GB/1M.",
|
||||||
|
p.label, p.recall, p.mem_per_1m_gb
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ use crate::{
|
|||||||
retrieve::{Results, Retrieve},
|
retrieve::{Results, Retrieve},
|
||||||
search::{Search, SearchExecutor, SearchResults},
|
search::{Search, SearchExecutor, SearchResults},
|
||||||
},
|
},
|
||||||
|
ranking::profile::CandidateStrategy,
|
||||||
schema::{EntityKind, TidalError},
|
schema::{EntityKind, TidalError},
|
||||||
session as session_mod,
|
session as session_mod,
|
||||||
};
|
};
|
||||||
@ -88,6 +89,32 @@ impl TidalDb {
|
|||||||
base_executor = base_executor.with_items_storage(storage);
|
base_executor = base_executor.with_items_storage(storage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// m12p2: ANN candidate generation. Thread the embedding registry and, when
|
||||||
|
// the profile uses `CandidateStrategy::Ann`, resolve the query vector here
|
||||||
|
// (the db layer owns both the seed-embedding read and the preference
|
||||||
|
// vectors): the seed item's embedding for `similar_to`, else the user's
|
||||||
|
// preference vector for `for_user`. `None` ⇒ the executor's Ann arm
|
||||||
|
// degrades to a scan (anonymous read / no preference vector yet). Resolving
|
||||||
|
// only for Ann profiles keeps the common (non-ANN) feed off this path.
|
||||||
|
let ann_query_vector = if self
|
||||||
|
.profile_registry
|
||||||
|
.get(&query.profile.name)
|
||||||
|
.is_ok_and(|p| matches!(p.candidate_strategy, CandidateStrategy::Ann { .. }))
|
||||||
|
{
|
||||||
|
if let Some(seed) = query.similar_to {
|
||||||
|
self.read_item_embedding(seed).ok().flatten()
|
||||||
|
} else if let Some(user) = query.for_user {
|
||||||
|
self.preference_vectors.get(user)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
base_executor = base_executor
|
||||||
|
.with_embedding_registry(&self.embedding_registry)
|
||||||
|
.with_ann_query_vector(ann_query_vector);
|
||||||
|
|
||||||
// M6: wire co-engagement for related profile scoring.
|
// M6: wire co-engagement for related profile scoring.
|
||||||
base_executor = base_executor.with_co_engagement(&self.co_engagement);
|
base_executor = base_executor.with_co_engagement(&self.co_engagement);
|
||||||
|
|
||||||
@ -322,6 +349,65 @@ impl TidalDb {
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pure k-nearest-neighbor vector search over the item content embedding
|
||||||
|
/// slot — the **recall-measurement probe** (m12p1).
|
||||||
|
///
|
||||||
|
/// Unlike [`Self::search`], this runs NO BM25 fusion, profile scoring, or
|
||||||
|
/// diversity enforcement. It returns the raw ANN result: the `k` items whose
|
||||||
|
/// stored content embedding is nearest to `query_vector` under the slot's
|
||||||
|
/// distance metric (L2 over L2-normalized vectors, monotonic with cosine —
|
||||||
|
/// see [`crate::storage::vector`]), ordered closest-first. Comparing this
|
||||||
|
/// output against a brute-force cosine ground truth yields the ANN
|
||||||
|
/// `recall@k` that G2 targets, isolated from the ranking layers that would
|
||||||
|
/// otherwise reorder the set.
|
||||||
|
///
|
||||||
|
/// `ef_search` overrides the slot's default HNSW beam width when `Some`
|
||||||
|
/// (a per-request recall/latency knob); `None` uses the slot default. The
|
||||||
|
/// brute-force and mock indexes ignore it (exact search has no beam width).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`TidalError`] if the embedding-registry lock is poisoned, if the
|
||||||
|
/// schema declares no item content embedding slot, or if the underlying
|
||||||
|
/// index search fails (e.g. a dimension mismatch on `query_vector`).
|
||||||
|
// The registry read guard is deliberately held across the slot lookup AND the
|
||||||
|
// index search: `slot` borrows from the guard, so it cannot be dropped earlier.
|
||||||
|
#[allow(clippy::significant_drop_tightening)]
|
||||||
|
pub fn vector_search_items(
|
||||||
|
&self,
|
||||||
|
query_vector: &[f32],
|
||||||
|
k: usize,
|
||||||
|
ef_search: Option<usize>,
|
||||||
|
) -> crate::Result<Vec<crate::storage::vector::VectorSearchResult>> {
|
||||||
|
let slot_name = self.item_embedding_slot();
|
||||||
|
// A read guard is enough: the index is interior-mutable (Send + Sync) and
|
||||||
|
// concurrent searches against the same slot are the expected production
|
||||||
|
// shape (ranking queries running alongside signal writes).
|
||||||
|
let registry = self.embedding_registry.read().map_err(|_| {
|
||||||
|
TidalError::internal(
|
||||||
|
"vector_search_items",
|
||||||
|
"embedding_registry read lock poisoned",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let slot = registry.get(EntityKind::Item, slot_name).ok_or_else(|| {
|
||||||
|
TidalError::invalid_input(format!(
|
||||||
|
"no '{slot_name}' embedding slot declared for Item; vector search unavailable"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let ef = ef_search.unwrap_or(slot.params.ef_search);
|
||||||
|
slot.index.search(query_vector, k, ef).map_err(|e| {
|
||||||
|
// A dimension-mismatched query vector is a CALLER error (→ 400 at the
|
||||||
|
// HTTP boundary), not an engine fault — keep it distinct from a real
|
||||||
|
// backend failure (→ 500). The latter is genuinely internal.
|
||||||
|
match e {
|
||||||
|
crate::storage::vector::VectorError::DimensionMismatch { .. } => {
|
||||||
|
TidalError::invalid_input(e.to_string())
|
||||||
|
}
|
||||||
|
other => TidalError::internal("vector_search_items", other.to_string()),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Autocomplete suggestions for a query prefix.
|
/// Autocomplete suggestions for a query prefix.
|
||||||
///
|
///
|
||||||
/// Returns matching title terms if `prefix` is non-empty, or trending search
|
/// Returns matching title terms if `prefix` is non-empty, or trending search
|
||||||
|
|||||||
@ -140,10 +140,11 @@ impl TidalDb {
|
|||||||
// than hardcoding brute force. A freshly auto-registered slot has
|
// than hardcoding brute force. A freshly auto-registered slot has
|
||||||
// no vectors yet, so the factory builds the exact `BruteForceIndex`
|
// no vectors yet, so the factory builds the exact `BruteForceIndex`
|
||||||
// (correct for a tiny / cold slot — no graph-build cost, exact
|
// (correct for a tiny / cold slot — no graph-build cost, exact
|
||||||
// results). When the slot's durable vector count crosses
|
// results). When the slot's durable vector count crosses the
|
||||||
// `USEARCH_MIN_VECTORS`, the next process restart's
|
// dimension-aware crossover (`usearch_min_vectors`), the next
|
||||||
// `rebuild_from_store` promotes it to the production HNSW
|
// process restart's `rebuild_from_store` promotes it to the
|
||||||
// (`UsearchIndex`) automatically — no config plumbing required.
|
// production HNSW (`UsearchIndex`) automatically — no config
|
||||||
|
// plumbing required.
|
||||||
let state = EmbeddingSlotState {
|
let state = EmbeddingSlotState {
|
||||||
index: build_slot_index(embedding.len(), 0),
|
index: build_slot_index(embedding.len(), 0),
|
||||||
dimensions: embedding.len(),
|
dimensions: embedding.len(),
|
||||||
|
|||||||
@ -9,8 +9,9 @@ use roaring::RoaringBitmap;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
ranking::executor::{ScoredCandidate, SignalSnapshot},
|
ranking::executor::{ScoredCandidate, SignalSnapshot},
|
||||||
schema::{EntityId, Timestamp},
|
schema::{EntityId, EntityKind},
|
||||||
signals::SignalLedger,
|
signals::SignalLedger,
|
||||||
|
storage::vector::EmbeddingSlotRegistry,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Scan the universe bitmap for all entity IDs.
|
/// Scan the universe bitmap for all entity IDs.
|
||||||
@ -51,67 +52,58 @@ pub(crate) fn scan_candidates(
|
|||||||
/// is no secondary per-signal-type index that would let us avoid the scan, so
|
/// is no secondary per-signal-type index that would let us avoid the scan, so
|
||||||
/// the `O(N)` pass is inherent to the current ledger layout — `N` is the number
|
/// the `O(N)` pass is inherent to the current ledger layout — `N` is the number
|
||||||
/// of live entity/signal cells, not the catalog size, and the per-cell work is a
|
/// of live entity/signal cells, not the catalog size, and the per-cell work is a
|
||||||
/// single `current_score` evaluation. To keep this off the heap, the working
|
/// single `current_score` evaluation.
|
||||||
/// set is **bounded to `O(cap)`** where `cap` is the top-K candidate budget:
|
///
|
||||||
/// rather than collecting every matching cell into an unbounded `Vec` and
|
/// **m12p2:** that O(N) scan is now CACHED. This function delegates to the
|
||||||
/// partitioning once at the end, we cap the buffer at `2 * cap` and
|
/// ledger's per-signal-type top-K cache ([`SignalLedger::hot_top_k_candidates`]),
|
||||||
/// partition-then-truncate to `cap` whenever it fills. That is amortized `O(N)`
|
/// which serves a fresh-enough materialized top-K in O(K) and only pays the
|
||||||
/// time with `O(cap)` memory instead of `O(N)` memory — important when one
|
/// bounded O(N) rebuild when the cache is stale (small ledgers always-fresh;
|
||||||
/// signal type dominates a large ledger. If this scan ever shows up in a
|
/// large ledgers throttled off the read hot path). The rebuild keeps the same
|
||||||
/// profile, add a per-signal-type inverted index (entity IDs sorted by decay
|
/// bounded-buffer scan this function used to perform inline. See
|
||||||
/// score) updated on the write path.
|
/// [`crate::signals::ledger`]'s `hot_top_k` module for the freshness policy.
|
||||||
pub(crate) fn signal_ranked_candidates(
|
pub(crate) fn signal_ranked_candidates(
|
||||||
ledger: &SignalLedger,
|
ledger: &SignalLedger,
|
||||||
signal_name: &str,
|
signal_name: &str,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Vec<EntityId> {
|
) -> Vec<EntityId> {
|
||||||
let max_candidates = (limit * 4).max(200);
|
ledger.hot_top_k_candidates(signal_name, limit)
|
||||||
let Ok(type_id) = ledger.resolve_signal_type(signal_name) else {
|
}
|
||||||
|
|
||||||
|
/// ANN candidate generation (m12p2): the `k` items whose content embedding is
|
||||||
|
/// nearest to `query_vector`, via the slot's HNSW index — `O(ef_search)`, not `O(N)`.
|
||||||
|
///
|
||||||
|
/// This is the G1 unblock: instead of scanning an arbitrary low-id slice of the
|
||||||
|
/// universe (which collapses feed relevance as the corpus grows past the scan
|
||||||
|
/// cap), the candidate set is the actual nearest neighbours of the query vector
|
||||||
|
/// (the user's preference vector for `for_you`, the seed item's embedding for
|
||||||
|
/// `similar_to`). Stage 3 then re-scores this bounded, relevant set by signals.
|
||||||
|
///
|
||||||
|
/// Returns an EMPTY vec on any miss (no registry, absent slot, dimension
|
||||||
|
/// mismatch, poisoned lock) so the caller can fall back to a scan and surface a
|
||||||
|
/// warning rather than silently returning nothing.
|
||||||
|
pub(crate) fn ann_candidates(
|
||||||
|
registry: &RwLock<EmbeddingSlotRegistry>,
|
||||||
|
slot: &str,
|
||||||
|
query_vector: &[f32],
|
||||||
|
k: usize,
|
||||||
|
ef_search: usize,
|
||||||
|
) -> Vec<EntityId> {
|
||||||
|
let Ok(reg) = registry.read() else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
|
let Some(state) = reg.get(EntityKind::Item, slot) else {
|
||||||
let now_ns = Timestamp::now().as_nanos();
|
return Vec::new();
|
||||||
|
|
||||||
// Descending by score; NaN (degenerate decay math) falls back to descending
|
|
||||||
// entity-ID order so output is deterministic and NaNs sink to the bottom.
|
|
||||||
let cmp = |a: &(EntityId, f64), b: &(EntityId, f64)| {
|
|
||||||
b.1.partial_cmp(&a.1)
|
|
||||||
.unwrap_or_else(|| b.0.as_u64().cmp(&a.0.as_u64()))
|
|
||||||
};
|
};
|
||||||
|
// `ef_search == 0` ⇒ use the slot's configured default beam width.
|
||||||
// Bounded buffer: never exceeds 2 * max_candidates entries. When it fills, an
|
let ef = if ef_search == 0 {
|
||||||
// O(buffer-len) select-and-truncate keeps the top max_candidates and drops the
|
state.params.ef_search
|
||||||
// rest, so peak memory is O(max_candidates) regardless of ledger size.
|
} else {
|
||||||
let buffer_cap = max_candidates.saturating_mul(2).max(max_candidates + 1);
|
ef_search
|
||||||
let mut scored: Vec<(EntityId, f64)> = Vec::with_capacity(buffer_cap);
|
};
|
||||||
|
match state.index.search(query_vector, k, ef) {
|
||||||
for entry in ledger.entries() {
|
Ok(results) => results.into_iter().map(|r| EntityId::new(r.id)).collect(),
|
||||||
let (entity_id, signal_type_id) = entry.key();
|
Err(_) => Vec::new(),
|
||||||
if *signal_type_id == type_id {
|
|
||||||
// Use decay score at index 0 with lambda=0 (no additional decay
|
|
||||||
// beyond what was already applied at write time). This is a
|
|
||||||
// simplified ranking for candidate generation -- the full scoring
|
|
||||||
// happens in Stage 3 via ProfileExecutor.
|
|
||||||
let score = entry.value().hot.current_score(0, now_ns, 0.0);
|
|
||||||
scored.push((*entity_id, score));
|
|
||||||
|
|
||||||
if scored.len() >= buffer_cap {
|
|
||||||
// Partition so the top `max_candidates` occupy [0, max_candidates)
|
|
||||||
// then discard the tail, keeping the buffer bounded.
|
|
||||||
scored.select_nth_unstable_by(max_candidates - 1, cmp);
|
|
||||||
scored.truncate(max_candidates);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Final top-K: partition (O(N)) then sort just the survivors (O(K log K)).
|
|
||||||
if scored.len() > max_candidates {
|
|
||||||
scored.select_nth_unstable_by(max_candidates - 1, cmp);
|
|
||||||
scored.truncate(max_candidates);
|
|
||||||
}
|
|
||||||
scored.sort_unstable_by(cmp);
|
|
||||||
|
|
||||||
scored.into_iter().map(|(id, _)| id).collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inject exploration candidates into the scored list.
|
/// Inject exploration candidates into the scored list.
|
||||||
|
|||||||
@ -93,6 +93,16 @@ pub struct RetrieveExecutor<'a> {
|
|||||||
/// retrieve-time preference boost reads embeddings from the same slot
|
/// retrieve-time preference boost reads embeddings from the same slot
|
||||||
/// `write_item_embedding` wrote them to. `None` falls back to "content".
|
/// `write_item_embedding` wrote them to. `None` falls back to "content".
|
||||||
item_embedding_slot: Option<&'a str>,
|
item_embedding_slot: Option<&'a str>,
|
||||||
|
// ── m12p2 ANN candidate generation ────────────────────────────────
|
||||||
|
/// Embedding-slot registry, for `CandidateStrategy::Ann` in Stage 1 — the
|
||||||
|
/// HNSW index the nearest-neighbour candidate search runs against. `None`
|
||||||
|
/// (or an absent slot) ⇒ the `Ann` strategy degrades to a scan.
|
||||||
|
embedding_registry: Option<&'a RwLock<crate::storage::vector::EmbeddingSlotRegistry>>,
|
||||||
|
/// The resolved ANN query vector, set by the db layer when the profile is
|
||||||
|
/// `Ann`: the user's preference vector (`for_user`) or the seed item's
|
||||||
|
/// embedding (`similar_to`). `None` ⇒ no query vector resolvable (anonymous
|
||||||
|
/// read, or a user with no preference vector yet) ⇒ `Ann` degrades to a scan.
|
||||||
|
ann_query_vector: Option<Vec<f32>>,
|
||||||
// ── M6 cohort context ─────────────────────────────────────────────
|
// ── M6 cohort context ─────────────────────────────────────────────
|
||||||
cohort_ledger: Option<&'a crate::cohort::CohortSignalLedger>,
|
cohort_ledger: Option<&'a crate::cohort::CohortSignalLedger>,
|
||||||
cohort_registry: Option<&'a crate::cohort::CohortRegistry>,
|
cohort_registry: Option<&'a crate::cohort::CohortRegistry>,
|
||||||
@ -142,6 +152,8 @@ impl<'a> RetrieveExecutor<'a> {
|
|||||||
session_snapshot: None,
|
session_snapshot: None,
|
||||||
items_storage: None,
|
items_storage: None,
|
||||||
item_embedding_slot: None,
|
item_embedding_slot: None,
|
||||||
|
embedding_registry: None,
|
||||||
|
ann_query_vector: None,
|
||||||
cohort_ledger: None,
|
cohort_ledger: None,
|
||||||
cohort_registry: None,
|
cohort_registry: None,
|
||||||
co_engagement: None,
|
co_engagement: None,
|
||||||
@ -206,6 +218,32 @@ impl<'a> RetrieveExecutor<'a> {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attach the embedding-slot registry for `CandidateStrategy::Ann` (m12p2).
|
||||||
|
///
|
||||||
|
/// Stage 1 runs the nearest-neighbour candidate search against the item
|
||||||
|
/// content slot's HNSW index in this registry. Without it (or without a
|
||||||
|
/// resolved [`with_ann_query_vector`](Self::with_ann_query_vector)), the
|
||||||
|
/// `Ann` strategy degrades to a scan.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn with_embedding_registry(
|
||||||
|
mut self,
|
||||||
|
registry: &'a RwLock<crate::storage::vector::EmbeddingSlotRegistry>,
|
||||||
|
) -> Self {
|
||||||
|
self.embedding_registry = Some(registry);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attach the resolved ANN query vector for `CandidateStrategy::Ann` (m12p2).
|
||||||
|
///
|
||||||
|
/// Resolved by the db layer: the user's preference vector for `for_you`, or
|
||||||
|
/// the seed item's embedding for `similar_to`. `None` leaves the `Ann`
|
||||||
|
/// strategy to degrade to a scan (anonymous read / no preference vector yet).
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_ann_query_vector(mut self, vector: Option<Vec<f32>>) -> Self {
|
||||||
|
self.ann_query_vector = vector;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Attach M6 co-engagement index for `related` profile scoring.
|
/// Attach M6 co-engagement index for `related` profile scoring.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn with_co_engagement(
|
pub const fn with_co_engagement(
|
||||||
|
|||||||
@ -25,6 +25,15 @@ use crate::{
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// ANN candidate over-fetch multiplier (m12p2): fetch `limit * this` nearest
|
||||||
|
/// neighbours so Stage 2/2.5 filtering and Stage 4 diversity can trim back to
|
||||||
|
/// `limit` without starving. Mirrors the SEARCH ANN over-fetch.
|
||||||
|
const ANN_OVERFETCH: usize = 10;
|
||||||
|
|
||||||
|
/// Minimum ANN candidate count regardless of `limit`, so a tiny-limit feed still
|
||||||
|
/// seeds a usable candidate pool.
|
||||||
|
const ANN_CANDIDATE_FLOOR: usize = 200;
|
||||||
|
|
||||||
impl RetrieveExecutor<'_> {
|
impl RetrieveExecutor<'_> {
|
||||||
/// Execute a RETRIEVE query through the 6-stage pipeline.
|
/// Execute a RETRIEVE query through the 6-stage pipeline.
|
||||||
///
|
///
|
||||||
@ -122,26 +131,79 @@ impl RetrieveExecutor<'_> {
|
|||||||
candidate_gen::scan_candidates(self.universe, query.limit, has_user_context)
|
candidate_gen::scan_candidates(self.universe, query.limit, has_user_context)
|
||||||
}
|
}
|
||||||
CandidateStrategy::SignalRanked { signal, .. } => {
|
CandidateStrategy::SignalRanked { signal, .. } => {
|
||||||
candidate_gen::signal_ranked_candidates(self.ledger, signal, query.limit)
|
// m12p2: candidates come from the cached per-signal-type top-K
|
||||||
|
// (O(K)). When the signal is absent from the schema, or no
|
||||||
|
// writes have landed yet, that yields nothing — fall back to a
|
||||||
|
// scan so the read still works (e.g. trending on a fresh corpus
|
||||||
|
// with no views yet) rather than returning an empty feed.
|
||||||
|
let ranked =
|
||||||
|
candidate_gen::signal_ranked_candidates(self.ledger, signal, query.limit);
|
||||||
|
if ranked.is_empty() {
|
||||||
|
warnings.push(format!(
|
||||||
|
"SignalRanked('{signal}') produced no candidates \
|
||||||
|
(signal absent or no writes yet); falling back to scan"
|
||||||
|
));
|
||||||
|
candidate_gen::scan_candidates(self.universe, query.limit, has_user_context)
|
||||||
|
} else {
|
||||||
|
ranked
|
||||||
}
|
}
|
||||||
CandidateStrategy::Ann { .. } => {
|
}
|
||||||
// ANN candidate strategy is not yet wired into RETRIEVE
|
CandidateStrategy::Ann {
|
||||||
// (vector retrieval lives in the SEARCH pipeline). The
|
limit: ann_limit, ..
|
||||||
// fallback to a full scan changes the candidate set the
|
} => {
|
||||||
// profile asked for, so it is surfaced BOTH in the per-query
|
// m12p2: ANN candidate generation in RETRIEVE — O(ef_search)
|
||||||
// `warnings` (caller-visible) AND a `tracing::warn!`
|
// nearest neighbours of the resolved query vector (the user's
|
||||||
// (operator-visible) — a silent degrade with no log line
|
// preference vector for `for_you`, the seed item's embedding
|
||||||
// leaves no way to track how often profiles hit this path.
|
// for `similar_to`), over-fetched so Stage 2/2.5 + diversity
|
||||||
|
// have room. Degrades to a scan (with a caller- AND
|
||||||
|
// operator-visible note) when no registry/query vector is
|
||||||
|
// available — an anonymous read or a user with no preference
|
||||||
|
// vector yet — so the read keeps working instead of erroring.
|
||||||
|
// Over-fetch limit×10, floored so tiny feeds still seed a pool,
|
||||||
|
// and capped by the profile's declared `Ann.limit` so a huge
|
||||||
|
// page size can't drive an unbounded ANN beam.
|
||||||
|
let k = query
|
||||||
|
.limit
|
||||||
|
.saturating_mul(ANN_OVERFETCH)
|
||||||
|
.max(ANN_CANDIDATE_FLOOR)
|
||||||
|
.min((*ann_limit).max(ANN_CANDIDATE_FLOOR));
|
||||||
|
if let (Some(registry), Some(query_vector)) =
|
||||||
|
(self.embedding_registry, self.ann_query_vector.as_deref())
|
||||||
|
{
|
||||||
|
let slot = self.item_embedding_slot.unwrap_or("content");
|
||||||
|
let ann = candidate_gen::ann_candidates(registry, slot, query_vector, k, 0);
|
||||||
|
if ann.is_empty() {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
"ANN candidate strategy not yet wired; falling back to scan".to_string(),
|
"ANN candidate generation returned no candidates; \
|
||||||
|
falling back to scan"
|
||||||
|
.to_string(),
|
||||||
);
|
);
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
profile = %query.profile.name,
|
profile = %query.profile.name,
|
||||||
"ANN candidate strategy requested but not wired in RETRIEVE; \
|
"ANN returned no candidates (empty/absent slot); scan fallback"
|
||||||
falling back to a full scan"
|
);
|
||||||
|
candidate_gen::scan_candidates(
|
||||||
|
self.universe,
|
||||||
|
query.limit,
|
||||||
|
has_user_context,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ann
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
warnings.push(
|
||||||
|
"ANN candidate strategy: no query vector resolvable \
|
||||||
|
(anonymous read or no preference vector yet); falling back to scan"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
tracing::debug!(
|
||||||
|
profile = %query.profile.name,
|
||||||
|
has_registry = self.embedding_registry.is_some(),
|
||||||
|
"ANN strategy without a query vector; scan fallback"
|
||||||
);
|
);
|
||||||
candidate_gen::scan_candidates(self.universe, query.limit, has_user_context)
|
candidate_gen::scan_candidates(self.universe, query.limit, has_user_context)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
CandidateStrategy::Relationship => {
|
CandidateStrategy::Relationship => {
|
||||||
// M3: source candidates from the user's followed creators.
|
// M3: source candidates from the user's followed creators.
|
||||||
// Uses the follows index in user_state + creator_items bitmap to build
|
// Uses the follows index in user_state + creator_items bitmap to build
|
||||||
|
|||||||
@ -143,6 +143,15 @@ pub fn register_builtins(registry: &mut ProfileRegistry) -> Result<(), ProfileEr
|
|||||||
/// cohort ordering matches the global one. Keep the two in sync.
|
/// cohort ordering matches the global one. Keep the two in sync.
|
||||||
fn trending() -> RankingProfile {
|
fn trending() -> RankingProfile {
|
||||||
let mut p = skeleton("trending");
|
let mut p = skeleton("trending");
|
||||||
|
// m12p2: source candidates from the most-viewed items via the cached
|
||||||
|
// per-signal-type top-K, so trending ranks the actually-engaged corpus at
|
||||||
|
// scale (O(K)) instead of an arbitrary low-id scan slice. Sort::Trending then
|
||||||
|
// re-ranks this pool by velocity. Degrades to a scan when no `view` signals
|
||||||
|
// exist yet (executor fallback), so a fresh corpus still serves.
|
||||||
|
p.candidate_strategy = CandidateStrategy::SignalRanked {
|
||||||
|
signal: "view".into(),
|
||||||
|
window: Window::TwentyFourHours,
|
||||||
|
};
|
||||||
p.sort = Some(Sort::Trending);
|
p.sort = Some(Sort::Trending);
|
||||||
// Cohort-rescore signal definition (see doc above); the global path uses the
|
// Cohort-rescore signal definition (see doc above); the global path uses the
|
||||||
// Sort::Trending formula and skips these per spec §11.9.
|
// Sort::Trending formula and skips these per spec §11.9.
|
||||||
@ -276,6 +285,15 @@ const FOLLOWING_MAX_PER_CREATOR: usize = 3;
|
|||||||
/// scoring in the executor.
|
/// scoring in the executor.
|
||||||
fn for_you() -> RankingProfile {
|
fn for_you() -> RankingProfile {
|
||||||
let mut p = skeleton("for_you");
|
let mut p = skeleton("for_you");
|
||||||
|
// m12p2: ANN candidate generation over the user's preference vector — the
|
||||||
|
// nearest content to the user's learned taste, O(ef_search), not an arbitrary
|
||||||
|
// low-id scan slice. Degrades to a scan for anonymous reads or a user with no
|
||||||
|
// preference vector yet (executor handles the fallback). `limit` caps the ANN
|
||||||
|
// candidate pool; the executor over-fetches `query.limit × 10` within it.
|
||||||
|
p.candidate_strategy = CandidateStrategy::Ann {
|
||||||
|
slot: "content".into(),
|
||||||
|
limit: 1000,
|
||||||
|
};
|
||||||
p.sort = Some(Sort::Hot { gravity: 1.5 });
|
p.sort = Some(Sort::Hot { gravity: 1.5 });
|
||||||
p.boosts = vec![
|
p.boosts = vec![
|
||||||
Boost {
|
Boost {
|
||||||
@ -335,6 +353,13 @@ fn following() -> RankingProfile {
|
|||||||
/// content-type boosting.
|
/// content-type boosting.
|
||||||
fn related() -> RankingProfile {
|
fn related() -> RankingProfile {
|
||||||
let mut p = skeleton("related");
|
let mut p = skeleton("related");
|
||||||
|
// m12p2: ANN candidate generation over the seed item's embedding (resolved
|
||||||
|
// from the query's `similar_to`) — true "more like this", O(ef_search).
|
||||||
|
// Degrades to a scan when no `similar_to` is supplied (executor fallback).
|
||||||
|
p.candidate_strategy = CandidateStrategy::Ann {
|
||||||
|
slot: "content".into(),
|
||||||
|
limit: 1000,
|
||||||
|
};
|
||||||
p.sort = Some(Sort::Hot { gravity: 1.2 });
|
p.sort = Some(Sort::Hot { gravity: 1.2 });
|
||||||
p.boosts = vec![
|
p.boosts = vec![
|
||||||
Boost {
|
Boost {
|
||||||
|
|||||||
@ -35,6 +35,11 @@ pub struct SignalLedger {
|
|||||||
signal_name_to_id: HashMap<String, SignalTypeId>,
|
signal_name_to_id: HashMap<String, SignalTypeId>,
|
||||||
/// `SignalTypeId` -> lambda array (cached from schema, immutable after construction).
|
/// `SignalTypeId` -> lambda array (cached from schema, immutable after construction).
|
||||||
signal_lambdas: HashMap<SignalTypeId, Vec<f64>>,
|
signal_lambdas: HashMap<SignalTypeId, Vec<f64>>,
|
||||||
|
/// m12p2: cached per-signal-type top-K backing the `SignalRanked` candidate
|
||||||
|
/// strategy, so its candidate generation is O(K) per query instead of an
|
||||||
|
/// O(N) ledger scan every time. Read-path only (write path just bumps a
|
||||||
|
/// counter); see [`super::hot_top_k`].
|
||||||
|
hot_top_k: super::hot_top_k::HotTopKCache,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SignalLedger {
|
impl SignalLedger {
|
||||||
@ -54,9 +59,25 @@ impl SignalLedger {
|
|||||||
schema,
|
schema,
|
||||||
signal_name_to_id,
|
signal_name_to_id,
|
||||||
signal_lambdas,
|
signal_lambdas,
|
||||||
|
hot_top_k: super::hot_top_k::HotTopKCache::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cached per-signal-type top-K candidates (m12p2), best-first by decayed
|
||||||
|
/// score, capped to `(limit × 4).max(200)`. Backs the `SignalRanked`
|
||||||
|
/// candidate strategy; O(K) per query on the cached path, with a bounded O(N)
|
||||||
|
/// rebuild only when the cache is stale (see [`super::hot_top_k`]). Returns an
|
||||||
|
/// empty vec for an unknown signal name.
|
||||||
|
pub(crate) fn hot_top_k_candidates(&self, signal_name: &str, limit: usize) -> Vec<EntityId> {
|
||||||
|
let Ok(type_id) = self.resolve_signal_type(signal_name) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let needed = limit.saturating_mul(4).max(200);
|
||||||
|
let now_ns = Timestamp::now().as_nanos();
|
||||||
|
self.hot_top_k
|
||||||
|
.candidates(&self.entries, type_id, needed, now_ns)
|
||||||
|
}
|
||||||
|
|
||||||
/// Record a signal event for an entity.
|
/// Record a signal event for an entity.
|
||||||
///
|
///
|
||||||
/// Steps:
|
/// Steps:
|
||||||
@ -542,6 +563,10 @@ impl SignalLedger {
|
|||||||
// Explicitly drop the DashMap entry ref to release the shard lock before
|
// Explicitly drop the DashMap entry ref to release the shard lock before
|
||||||
// returning (satisfies clippy::significant_drop_tightening).
|
// returning (satisfies clippy::significant_drop_tightening).
|
||||||
drop(entry);
|
drop(entry);
|
||||||
|
// m12p2: invalidate the SignalRanked top-K cache (one relaxed atomic add).
|
||||||
|
// This is the single in-memory mutation chokepoint for every signal apply
|
||||||
|
// path (record/replay/replication), so the cache can never miss a write.
|
||||||
|
self.hot_top_k.note_write();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply a WAL event directly to in-memory state, bypassing the WAL write.
|
/// Apply a WAL event directly to in-memory state, bypassing the WAL write.
|
||||||
|
|||||||
160
tidal/src/signals/ledger/hot_top_k.rs
Normal file
160
tidal/src/signals/ledger/hot_top_k.rs
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
//! Cached per-signal-type top-K (m12p2): bound `SignalRanked` candidate
|
||||||
|
//! generation to O(K) per query instead of an O(N) ledger scan on every call.
|
||||||
|
//!
|
||||||
|
//! # Why a cache is correct here
|
||||||
|
//!
|
||||||
|
//! A `SignalRanked` strategy ranks candidates by a signal's decayed score. The
|
||||||
|
//! score of an entity at query time is `accumulated * exp(-lambda * dt)`, and the
|
||||||
|
//! SAME `lambda` applies to every entity of a given signal type, so time decay
|
||||||
|
//! multiplies every entity's score by the same factor — it **never changes the
|
||||||
|
//! relative order**. The membership of the top-K therefore only changes when a
|
||||||
|
//! NEW signal write bumps some entity's accumulated score. That makes a cached
|
||||||
|
//! top-K valid until the next write, not until the next clock tick.
|
||||||
|
//!
|
||||||
|
//! # Freshness policy
|
||||||
|
//!
|
||||||
|
//! - **Small ledgers** (`< SMALL_LEDGER_ENTRIES`): the O(N) rebuild is
|
||||||
|
//! sub-millisecond, so we rebuild whenever a write has landed since the cache
|
||||||
|
//! was built — always fresh, which is what in-process tests and small apps
|
||||||
|
//! expect.
|
||||||
|
//! - **Large ledgers** (`>= SMALL_LEDGER_ENTRIES`): a continuous-write workload
|
||||||
|
//! would otherwise land an O(N) scan on the read hot path and blow the p99, so
|
||||||
|
//! rebuilds are throttled to at most one per [`REFRESH_INTERVAL_NS`]. Trending
|
||||||
|
//! tolerates a second of staleness; the p99 SLO does not tolerate a 50ms scan.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
use dashmap::DashMap;
|
||||||
|
|
||||||
|
use super::super::SignalTypeId;
|
||||||
|
use super::types::EntitySignalEntry;
|
||||||
|
use crate::schema::EntityId;
|
||||||
|
|
||||||
|
/// Ledgers smaller than this rebuild the cache on every post-write query (always
|
||||||
|
/// fresh; the scan is cheap at this size). At or above it, rebuilds throttle to
|
||||||
|
/// [`REFRESH_INTERVAL_NS`] to keep the O(N) scan off the read hot path.
|
||||||
|
const SMALL_LEDGER_ENTRIES: usize = 50_000;
|
||||||
|
|
||||||
|
/// Minimum wall-clock between rebuilds for a large ledger (1 second).
|
||||||
|
const REFRESH_INTERVAL_NS: u64 = 1_000_000_000;
|
||||||
|
|
||||||
|
/// How many entities to materialize per signal type. Bounds cache memory (a few
|
||||||
|
/// thousand `EntityId`s per signal type) and caps the candidate pool any single
|
||||||
|
/// `SignalRanked` query draws from. Comfortably above `MAX_LIMIT × 4`.
|
||||||
|
const REBUILD_K: usize = 4096;
|
||||||
|
|
||||||
|
/// One signal type's materialized top-K plus the bookkeeping that decides
|
||||||
|
/// freshness.
|
||||||
|
struct CachedTopK {
|
||||||
|
/// Top entities by decayed score, best-first (length ≤ [`REBUILD_K`]).
|
||||||
|
items: Vec<EntityId>,
|
||||||
|
/// Wall-clock (ns) the cache was built — drives the large-ledger throttle.
|
||||||
|
built_at_ns: u64,
|
||||||
|
/// The global write counter at build time — if it still matches, no signal
|
||||||
|
/// has been written since, so the cache is exact regardless of elapsed time.
|
||||||
|
built_dirty: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-signal-type cached top-K, with a global write counter for invalidation.
|
||||||
|
pub struct HotTopKCache {
|
||||||
|
per_type: DashMap<SignalTypeId, CachedTopK>,
|
||||||
|
/// Bumped once per signal apply (one relaxed atomic add — negligible on the
|
||||||
|
/// write path). A cache built at an earlier value knows writes have landed.
|
||||||
|
dirty: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HotTopKCache {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
per_type: DashMap::new(),
|
||||||
|
dirty: AtomicU64::new(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record that a signal was written (invalidates caches on their next read).
|
||||||
|
pub fn note_write(&self) {
|
||||||
|
self.dirty.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Top entities for `type_id` by decayed score, best-first, capped to the
|
||||||
|
/// caller's `needed` count. Serves a fresh-enough cache in O(needed); rebuilds
|
||||||
|
/// (one bounded O(N) scan) only when stale per the freshness policy.
|
||||||
|
pub fn candidates(
|
||||||
|
&self,
|
||||||
|
entries: &DashMap<(EntityId, SignalTypeId), EntitySignalEntry>,
|
||||||
|
type_id: SignalTypeId,
|
||||||
|
needed: usize,
|
||||||
|
now_ns: u64,
|
||||||
|
) -> Vec<EntityId> {
|
||||||
|
let needed = needed.min(REBUILD_K);
|
||||||
|
let dirty_now = self.dirty.load(Ordering::Relaxed);
|
||||||
|
|
||||||
|
if let Some(cached) = self.per_type.get(&type_id) {
|
||||||
|
let fresh = if cached.built_dirty == dirty_now {
|
||||||
|
// No writes at all since the build ⇒ exact, regardless of clock.
|
||||||
|
true
|
||||||
|
} else if entries.len() < SMALL_LEDGER_ENTRIES {
|
||||||
|
// Small ledger + new writes ⇒ rebuild (cheap, always fresh).
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
// Large ledger ⇒ throttle the O(N) rebuild off the hot path.
|
||||||
|
now_ns.saturating_sub(cached.built_at_ns) < REFRESH_INTERVAL_NS
|
||||||
|
};
|
||||||
|
if fresh {
|
||||||
|
return cached.items.iter().take(needed).copied().collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let items = rebuild(entries, type_id, now_ns);
|
||||||
|
let out = items.iter().take(needed).copied().collect();
|
||||||
|
self.per_type.insert(
|
||||||
|
type_id,
|
||||||
|
CachedTopK {
|
||||||
|
items,
|
||||||
|
built_at_ns: now_ns,
|
||||||
|
built_dirty: dirty_now,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bounded O(N) ledger scan: the top [`REBUILD_K`] entities of `type_id` by
|
||||||
|
/// decayed score, best-first. Memory stays O(K): a buffer capped at `2·K` is
|
||||||
|
/// partition-truncated to K whenever it fills, so peak allocation is bounded
|
||||||
|
/// regardless of how many entities carry the signal.
|
||||||
|
fn rebuild(
|
||||||
|
entries: &DashMap<(EntityId, SignalTypeId), EntitySignalEntry>,
|
||||||
|
type_id: SignalTypeId,
|
||||||
|
now_ns: u64,
|
||||||
|
) -> Vec<EntityId> {
|
||||||
|
let cap = REBUILD_K;
|
||||||
|
// Descending by score; NaN sinks via a deterministic id tiebreak.
|
||||||
|
let cmp = |a: &(EntityId, f64), b: &(EntityId, f64)| {
|
||||||
|
b.1.partial_cmp(&a.1)
|
||||||
|
.unwrap_or_else(|| b.0.as_u64().cmp(&a.0.as_u64()))
|
||||||
|
};
|
||||||
|
let buffer_cap = cap.saturating_mul(2).max(cap + 1);
|
||||||
|
let mut scored: Vec<(EntityId, f64)> = Vec::with_capacity(buffer_cap);
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let (entity_id, signal_type_id) = entry.key();
|
||||||
|
if *signal_type_id == type_id {
|
||||||
|
// Decay score with lambda=0 (no extra decay beyond what was applied at
|
||||||
|
// write time) — the same simplified candidate-gen ranking the prior
|
||||||
|
// O(N) scan used. Stage 3 does the full scoring.
|
||||||
|
let score = entry.value().hot.current_score(0, now_ns, 0.0);
|
||||||
|
scored.push((*entity_id, score));
|
||||||
|
if scored.len() >= buffer_cap {
|
||||||
|
scored.select_nth_unstable_by(cap - 1, cmp);
|
||||||
|
scored.truncate(cap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if scored.len() > cap {
|
||||||
|
scored.select_nth_unstable_by(cap - 1, cmp);
|
||||||
|
scored.truncate(cap);
|
||||||
|
}
|
||||||
|
scored.sort_unstable_by(cmp);
|
||||||
|
scored.into_iter().map(|(id, _)| id).collect()
|
||||||
|
}
|
||||||
@ -19,6 +19,7 @@
|
|||||||
//! tier) is lock-free once the entry reference is obtained.
|
//! tier) is lock-free once the entry reference is obtained.
|
||||||
|
|
||||||
pub mod core;
|
pub mod core;
|
||||||
|
mod hot_top_k;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
||||||
pub use core::{SignalLedger, StagedLedgerApply};
|
pub use core::{SignalLedger, StagedLedgerApply};
|
||||||
|
|||||||
@ -18,25 +18,62 @@ use crate::schema::EntityKind;
|
|||||||
// Vector backend selection (production HNSW vs. brute-force)
|
// Vector backend selection (production HNSW vs. brute-force)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Minimum live-vector count for a slot before the production HNSW engine
|
/// Upper bound (cap) on the brute-force→HNSW crossover, in live vectors.
|
||||||
/// (`UsearchIndex`) is preferred over the exact `BruteForceIndex`.
|
|
||||||
///
|
///
|
||||||
/// `BruteForceIndex` computes an L2 distance against every stored vector under
|
/// This is the crossover at *low* dimensionality, where a brute-force scan is
|
||||||
/// an `RwLock` read on each search — exact, but O(n) per query and serialized
|
/// cheap per vector. The effective crossover is dimension-scaled by
|
||||||
/// against writes. It is the right choice for tiny slots (no graph-build cost,
|
/// [`usearch_min_vectors`] — see there for why a fixed count is wrong at 1536-D.
|
||||||
/// exact results) but blows past the latency budget at scale. `UsearchIndex`
|
/// Retained as `USEARCH_MIN_VECTORS` (its historical name) because it is the
|
||||||
/// (HNSW) is the mandated production engine (`CODING_GUIDELINES` §4: "126K+ QPS",
|
/// crossover for every slot at or below [`BRUTE_FORCE_SCAN_OPS_BUDGET`] ÷ cap
|
||||||
/// §8: "ANN retrieval at 1M vectors <10ms p99"). Gating on slot size needs NO
|
/// dimensionality (≈ 400-D), which includes the 128-D test/bench shapes.
|
||||||
/// external config plumbing: at process restart, `rebuild_from_store` already
|
|
||||||
/// knows each slot's vector count, so a slot that crossed this threshold is
|
|
||||||
/// rebuilt as HNSW automatically, while a small slot stays exact. The threshold
|
|
||||||
/// is deliberately conservative — well inside the range where brute force is
|
|
||||||
/// still fast — so correctness-sensitive small slots keep exact results.
|
|
||||||
pub(crate) const USEARCH_MIN_VECTORS: usize = 10_000;
|
pub(crate) const USEARCH_MIN_VECTORS: usize = 10_000;
|
||||||
|
|
||||||
|
/// Smallest crossover the dimension scaling will ever pick, in live vectors.
|
||||||
|
///
|
||||||
|
/// Below this many vectors, HNSW recall is poor (too few nodes for a good graph)
|
||||||
|
/// and the brute-force scan is sub-millisecond at any realistic dimensionality,
|
||||||
|
/// so exact search wins regardless of dim. Floors [`usearch_min_vectors`] so an
|
||||||
|
/// extreme dimensionality cannot drive the crossover to a recall-hostile handful.
|
||||||
|
pub(crate) const USEARCH_MIN_VECTORS_FLOOR: usize = 1_000;
|
||||||
|
|
||||||
|
/// Target work budget for a single brute-force scan, in scalar multiply-adds
|
||||||
|
/// (`count × dimensions`). The dimension-aware crossover keeps a brute-force
|
||||||
|
/// slot's per-query scan at or under this many FMAs.
|
||||||
|
///
|
||||||
|
/// `BruteForceIndex::search` computes a scalar (non-SIMD) L2 distance against
|
||||||
|
/// every live vector while holding its `RwLock` read guard, so the scan also
|
||||||
|
/// blocks writers for its duration. At ~4M FMAs the scalar scan is on the order
|
||||||
|
/// of a couple of milliseconds — comfortably inside the retrieve budget — so we
|
||||||
|
/// flip a slot to HNSW once `count × dim` would exceed this. Worked example: at
|
||||||
|
/// the production 1536-D this caps brute force at `⌊4_000_000 ÷ 1536⌋` = 2604
|
||||||
|
/// vectors, where the old fixed `10_000` would have scanned 15.4M FMAs (≈ tens of
|
||||||
|
/// ms) under the read lock — the exact SLA blow-up the m12p3 roadmap flagged.
|
||||||
|
const BRUTE_FORCE_SCAN_OPS_BUDGET: usize = 4_000_000;
|
||||||
|
|
||||||
|
/// Dimension-aware brute-force→HNSW crossover, in live vectors.
|
||||||
|
///
|
||||||
|
/// The cost of a brute-force scan is `count × dim`, so a single vector-count
|
||||||
|
/// crossover is wrong: `10_000` vectors at 128-D is a 1.3M-FMA scan (sub-ms) but
|
||||||
|
/// at 1536-D it is a 15.4M-FMA scan (tens of ms, under a read lock that also
|
||||||
|
/// blocks writers). We instead pick the largest `count` whose scan stays within
|
||||||
|
/// [`BRUTE_FORCE_SCAN_OPS_BUDGET`], clamped to
|
||||||
|
/// `[USEARCH_MIN_VECTORS_FLOOR, USEARCH_MIN_VECTORS]`. At/under ≈ 400-D this
|
||||||
|
/// returns the historical [`USEARCH_MIN_VECTORS`] (so 128-D shapes are
|
||||||
|
/// byte-for-byte unchanged); at 1536-D it returns ≈ 2604, flipping high-dim
|
||||||
|
/// slots to HNSW before the scan can blow the latency budget.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) fn usearch_min_vectors(dimensions: usize) -> usize {
|
||||||
|
// dimensions == 0 cannot occur for a real slot (the write/rebuild paths
|
||||||
|
// reject zero-dim vectors), but guard the divide regardless.
|
||||||
|
if dimensions == 0 {
|
||||||
|
return USEARCH_MIN_VECTORS;
|
||||||
|
}
|
||||||
|
(BRUTE_FORCE_SCAN_OPS_BUDGET / dimensions).clamp(USEARCH_MIN_VECTORS_FLOOR, USEARCH_MIN_VECTORS)
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the vector index backend for a slot, gating on the expected vector
|
/// Build the vector index backend for a slot, gating on the expected vector
|
||||||
/// `count`: `UsearchIndex` (production HNSW) at or above [`USEARCH_MIN_VECTORS`],
|
/// `count`: `UsearchIndex` (production HNSW) at or above the dimension-aware
|
||||||
/// `BruteForceIndex` (exact) below it.
|
/// crossover [`usearch_min_vectors`], `BruteForceIndex` (exact) below it.
|
||||||
///
|
///
|
||||||
/// When the `USearch` backend is selected, capacity is reserved up front
|
/// When the `USearch` backend is selected, capacity is reserved up front
|
||||||
/// (`UsearchIndex::add` requires a prior `reserve`), so the subsequent inserts
|
/// (`UsearchIndex::add` requires a prior `reserve`), so the subsequent inserts
|
||||||
@ -52,7 +89,7 @@ pub(crate) fn build_slot_index(dimensions: usize, count: usize) -> Box<dyn Vecto
|
|||||||
dimensions,
|
dimensions,
|
||||||
..VectorIndexConfig::default()
|
..VectorIndexConfig::default()
|
||||||
};
|
};
|
||||||
if count < USEARCH_MIN_VECTORS {
|
if count < usearch_min_vectors(dimensions) {
|
||||||
return Box::new(super::BruteForceIndex::new(config));
|
return Box::new(super::BruteForceIndex::new(config));
|
||||||
}
|
}
|
||||||
match super::UsearchIndex::new(config.clone()) {
|
match super::UsearchIndex::new(config.clone()) {
|
||||||
@ -341,10 +378,11 @@ impl EmbeddingSlotRegistry {
|
|||||||
/// # Backend selection
|
/// # Backend selection
|
||||||
///
|
///
|
||||||
/// Each slot's backend is chosen by [`build_slot_index`] from the slot's live
|
/// Each slot's backend is chosen by [`build_slot_index`] from the slot's live
|
||||||
/// vector count: a slot at or above [`USEARCH_MIN_VECTORS`] is rebuilt as the
|
/// vector count: a slot at or above the dimension-aware crossover
|
||||||
/// production HNSW engine (`UsearchIndex`); smaller slots stay exact
|
/// [`usearch_min_vectors`] is rebuilt as the production HNSW engine
|
||||||
/// (`BruteForceIndex`). This is the live path that makes `UsearchIndex`
|
/// (`UsearchIndex`); smaller slots stay exact (`BruteForceIndex`). This is the
|
||||||
/// reachable in production after a restart, with no config plumbing.
|
/// live path that makes `UsearchIndex` reachable in production after a restart,
|
||||||
|
/// with no config plumbing.
|
||||||
///
|
///
|
||||||
/// # Fault isolation
|
/// # Fault isolation
|
||||||
///
|
///
|
||||||
@ -514,10 +552,11 @@ impl EmbeddingSlotRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Lazily register the slot on first sight, routing the backend through
|
// Lazily register the slot on first sight, routing the backend through
|
||||||
// the size-gated factory: a slot with >= USEARCH_MIN_VECTORS live
|
// the size-gated factory: a slot at/above the dimension-aware
|
||||||
// vectors is rebuilt as the production HNSW (USearch) engine; smaller
|
// crossover (usearch_min_vectors) is rebuilt as the production HNSW
|
||||||
// slots stay exact (brute force). This is the live path that makes
|
// (USearch) engine; smaller slots stay exact (brute force). This is
|
||||||
// USearch reachable in production restarts — no config flag required.
|
// the live path that makes USearch reachable in production restarts —
|
||||||
|
// no config flag required.
|
||||||
if self.get(entity_kind, &slot_name).is_none() {
|
if self.get(entity_kind, &slot_name).is_none() {
|
||||||
let state = EmbeddingSlotState {
|
let state = EmbeddingSlotState {
|
||||||
index: build_slot_index(dimensions, entries.len()),
|
index: build_slot_index(dimensions, entries.len()),
|
||||||
@ -983,7 +1022,8 @@ mod tests {
|
|||||||
// At threshold: USearch (HNSW), with capacity reserved by the factory so
|
// At threshold: USearch (HNSW), with capacity reserved by the factory so
|
||||||
// inserts succeed without a manual `reserve`. Searchable end-to-end —
|
// inserts succeed without a manual `reserve`. Searchable end-to-end —
|
||||||
// proving the production engine is actually reachable, not dead code.
|
// proving the production engine is actually reachable, not dead code.
|
||||||
let big = build_slot_index(4, USEARCH_MIN_VECTORS);
|
// At 4-D the dimension-aware crossover == USEARCH_MIN_VECTORS (the cap).
|
||||||
|
let big = build_slot_index(4, usearch_min_vectors(4));
|
||||||
big.insert(10, &[1.0, 0.0, 0.0, 0.0]).unwrap();
|
big.insert(10, &[1.0, 0.0, 0.0, 0.0]).unwrap();
|
||||||
big.insert(20, &[0.0, 1.0, 0.0, 0.0]).unwrap();
|
big.insert(20, &[0.0, 1.0, 0.0, 0.0]).unwrap();
|
||||||
let r = big.search(&[1.0, 0.0, 0.0, 0.0], 1, big.len()).unwrap();
|
let r = big.search(&[1.0, 0.0, 0.0, 0.0], 1, big.len()).unwrap();
|
||||||
@ -993,6 +1033,70 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// m12p3: the brute-force→HNSW crossover must scale DOWN with dimensionality
|
||||||
|
/// so a high-dim slot does not scan millions of FMAs under the read lock. At
|
||||||
|
/// 1536-D the crossover must be far below the 128-D/cap value, and a count
|
||||||
|
/// that stays brute force at 128-D must flip to HNSW at 1536-D.
|
||||||
|
#[test]
|
||||||
|
fn usearch_min_vectors_scales_down_with_dimension() {
|
||||||
|
// Low dim: clamped to the historical cap (byte-compatible with pre-m12p3).
|
||||||
|
assert_eq!(usearch_min_vectors(128), USEARCH_MIN_VECTORS);
|
||||||
|
assert_eq!(usearch_min_vectors(4), USEARCH_MIN_VECTORS);
|
||||||
|
|
||||||
|
// Production 1536-D: well below the cap, keeping the scan bounded.
|
||||||
|
let hi = usearch_min_vectors(1536);
|
||||||
|
assert!(
|
||||||
|
hi < USEARCH_MIN_VECTORS
|
||||||
|
&& (USEARCH_MIN_VECTORS_FLOOR..USEARCH_MIN_VECTORS).contains(&hi),
|
||||||
|
"1536-D crossover {hi} must be below the cap and at/above the floor"
|
||||||
|
);
|
||||||
|
// The bounded scan: count × dim must stay within the budget.
|
||||||
|
assert!(
|
||||||
|
hi * 1536 <= BRUTE_FORCE_SCAN_OPS_BUDGET,
|
||||||
|
"1536-D crossover scan {} FMAs exceeds the budget",
|
||||||
|
hi * 1536
|
||||||
|
);
|
||||||
|
|
||||||
|
// Crossover is monotonically non-increasing in dimensionality.
|
||||||
|
assert!(usearch_min_vectors(768) >= usearch_min_vectors(1536));
|
||||||
|
|
||||||
|
// A slot of 5000 vectors: brute force at 128-D, HNSW at 1536-D.
|
||||||
|
assert!(
|
||||||
|
5_000 < usearch_min_vectors(128),
|
||||||
|
"5000 stays brute at 128-D"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
5_000 >= usearch_min_vectors(1536),
|
||||||
|
"5000 flips to HNSW at 1536-D"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extreme dim is floored, never driven to a recall-hostile handful.
|
||||||
|
assert_eq!(usearch_min_vectors(1_000_000), USEARCH_MIN_VECTORS_FLOOR);
|
||||||
|
// Defensive: zero dim cannot divide-by-zero.
|
||||||
|
assert_eq!(usearch_min_vectors(0), USEARCH_MIN_VECTORS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The factory must actually build HNSW at a 1536-D count that the old fixed
|
||||||
|
/// `10_000` threshold would have left as a brute-force scan.
|
||||||
|
#[test]
|
||||||
|
fn build_slot_index_flips_to_hnsw_for_high_dim_midsize_slot() {
|
||||||
|
// 5000 < 10_000, so the OLD code built brute force; the dimension-aware
|
||||||
|
// crossover at 1536-D (~2604) puts 5000 above it → HNSW.
|
||||||
|
let dim = 1536;
|
||||||
|
let count = 5_000;
|
||||||
|
assert!(count >= usearch_min_vectors(dim));
|
||||||
|
let index = build_slot_index(dim, count);
|
||||||
|
// A freshly built HNSW reserved for `count` accepts inserts and searches.
|
||||||
|
let mut v = vec![0.0_f32; dim];
|
||||||
|
v[0] = 1.0;
|
||||||
|
index.insert(1, &v).unwrap();
|
||||||
|
let r = index.search(&v, 1, 64).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
r[0].id, 1,
|
||||||
|
"high-dim midsize slot must build a working HNSW"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
// rebuild fault isolation — findings 8 and 13
|
// rebuild fault isolation — findings 8 and 13
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
|
|||||||
@ -12,20 +12,27 @@
|
|||||||
//! - **`total_slots` tracking**: `USearch`'s `size()` excludes tombstoned entries
|
//! - **`total_slots` tracking**: `USearch`'s `size()` excludes tombstoned entries
|
||||||
//! after `remove()`. We maintain an `AtomicUsize` counter to provide the total
|
//! after `remove()`. We maintain an `AtomicUsize` counter to provide the total
|
||||||
//! occupied graph slot count (including tombstones) via `len()`.
|
//! occupied graph slot count (including tombstones) via `len()`.
|
||||||
//! - **No per-query `ef_search` override**: `USearch`'s `change_expansion_search()`
|
//! - **Per-query `ef_search` override** (m12p3): `USearch` 2.24 has no per-call
|
||||||
//! mutates global index state (not per-query). We accept the parameter for
|
//! beam-width argument — `search(query, count)` reads the index-global
|
||||||
//! trait compliance but use the index-level default set at construction time.
|
//! `expansion_search`, and the only knob is `change_expansion_search()`, which
|
||||||
//! See [`ef_search_unsupported_warn`](UsearchIndex::ef_search_unsupported_warn)
|
//! mutates that shared value for every concurrent search. We make a per-query
|
||||||
//! and the rationale on [`search`](VectorIndex::search). Tracked as deferred
|
//! override race-free with an `RwLock` *epoch guard* (not a per-search mutex):
|
||||||
//! work in `docs/specs/07-vector-retrieval.md` (per-query beam-width override);
|
//! a search holds the lock SHARED while the live expansion already equals the
|
||||||
//! not scheduled for an M0–M10 milestone.
|
//! requested `ef`, so any number of same-`ef` searches run in parallel; only a
|
||||||
|
//! query that DISAGREES with the live expansion takes the lock EXCLUSIVE to
|
||||||
|
//! change it. The uniform-`ef` hot path (the overwhelmingly common case) pays
|
||||||
|
//! one uncontended read-lock acquire, not serialization. See
|
||||||
|
//! [`with_expansion`](UsearchIndex::with_expansion) and [`search`](VectorIndex::search).
|
||||||
//! - **Thread safety**: `usearch::Index` implements `Send + Sync` (backed by
|
//! - **Thread safety**: `usearch::Index` implements `Send + Sync` (backed by
|
||||||
//! C++ thread-safe HNSW). `AtomicUsize` is trivially `Send + Sync`. Therefore
|
//! C++ thread-safe HNSW). `AtomicUsize`/`RwLock` are trivially `Send + Sync`.
|
||||||
//! `UsearchIndex` is `Send + Sync` without any unsafe impl blocks.
|
//! Therefore `UsearchIndex` is `Send + Sync` without any unsafe impl blocks.
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
path::Path,
|
path::Path,
|
||||||
sync::atomic::{AtomicUsize, Ordering},
|
sync::{
|
||||||
|
RwLock,
|
||||||
|
atomic::{AtomicUsize, Ordering},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
@ -83,6 +90,29 @@ pub struct UsearchIndex {
|
|||||||
/// persistence. A ranking query that reads a slightly stale count
|
/// persistence. A ranking query that reads a slightly stale count
|
||||||
/// suffers no correctness issue.
|
/// suffers no correctness issue.
|
||||||
total_slots: AtomicUsize,
|
total_slots: AtomicUsize,
|
||||||
|
/// The `expansion_search` value currently configured on `inner`.
|
||||||
|
///
|
||||||
|
/// Starts at `config.ef_search` (the value passed to `new_index`) and is
|
||||||
|
/// mutated only while holding the WRITE side of [`Self::expansion_lock`], so a
|
||||||
|
/// reader that holds the read side and observes `current_expansion == ef`
|
||||||
|
/// knows the C++ beam width is pinned to `ef` for the whole search.
|
||||||
|
///
|
||||||
|
/// `Relaxed` is sufficient: the value is only ever read/written under
|
||||||
|
/// `expansion_lock`, which already provides the acquire/release ordering.
|
||||||
|
current_expansion: AtomicUsize,
|
||||||
|
/// Guards a *change* of the index-global `expansion_search` against concurrent
|
||||||
|
/// searches, making per-query `ef_search` race-free without serializing the
|
||||||
|
/// hot path.
|
||||||
|
///
|
||||||
|
/// A shared (read) hold admits unlimited concurrent searches that agree on the
|
||||||
|
/// beam width; the exclusive (write) hold is taken only to change the beam
|
||||||
|
/// width — i.e. only by a query whose `ef_search` differs from the live value.
|
||||||
|
/// This is the distinction the old "a mutex around search + expansion change
|
||||||
|
/// is unacceptable on the hot path" note missed: an `RwLock` read does NOT
|
||||||
|
/// serialize searches that share an `ef_search`, which is the production norm.
|
||||||
|
/// The guard protects only the `(change_expansion_search, search)` window; it
|
||||||
|
/// holds no data, hence `RwLock<()>`.
|
||||||
|
expansion_lock: RwLock<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UsearchIndex {
|
impl UsearchIndex {
|
||||||
@ -117,9 +147,13 @@ impl UsearchIndex {
|
|||||||
.map_err(|e| VectorError::Backend(format!("USearch init failed: {e}")))?;
|
.map_err(|e| VectorError::Backend(format!("USearch init failed: {e}")))?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
|
// `new_index` set the C++ `expansion_search` to `config.ef_search`,
|
||||||
|
// so the tracked value starts there.
|
||||||
|
current_expansion: AtomicUsize::new(config.ef_search),
|
||||||
inner,
|
inner,
|
||||||
config,
|
config,
|
||||||
total_slots: AtomicUsize::new(0),
|
total_slots: AtomicUsize::new(0),
|
||||||
|
expansion_lock: RwLock::new(()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -128,6 +162,39 @@ impl UsearchIndex {
|
|||||||
validate_dimensions(self.config.dimensions, vec.len())
|
validate_dimensions(self.config.dimensions, vec.len())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reserve capacity for `total` vectors AND `threads` concurrent writer slots.
|
||||||
|
///
|
||||||
|
/// `USearch`'s plain `reserve` allocates a single writer slot, so concurrent
|
||||||
|
/// `add` from multiple threads corrupts the proximity graph (recall collapses).
|
||||||
|
/// `reserve_capacity_and_threads` allocates one slot per thread, after which
|
||||||
|
/// concurrent `insert` of DISTINCT ids from up to `threads` threads is safe
|
||||||
|
/// (each `add` grabs a free slot internally; `total_slots` uses an atomic
|
||||||
|
/// `fetch_add`). Used by the m12p3 grid-search harness to build the 100k/1M
|
||||||
|
/// indexes in parallel — the difference between a tractable and a ~70-minute
|
||||||
|
/// sequential build — without sacrificing graph quality.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`VectorError::Backend`] if the underlying reservation fails (OOM).
|
||||||
|
pub fn reserve_with_threads(&self, total: usize, threads: usize) -> Result<(), VectorError> {
|
||||||
|
self.inner
|
||||||
|
.reserve_capacity_and_threads(total, threads)
|
||||||
|
.map_err(|e| VectorError::Backend(format!("USearch reserve failed: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Actual resident memory of the HNSW graph + vectors, in bytes, as reported
|
||||||
|
/// by `USearch` itself.
|
||||||
|
///
|
||||||
|
/// Unlike [`EmbeddingSlotRegistry::index_stats`](super::EmbeddingSlotRegistry::index_stats),
|
||||||
|
/// which estimates `count × dim × bytes_per_component` (a lower bound that
|
||||||
|
/// omits the proximity-graph links), this is the true footprint including the
|
||||||
|
/// HNSW graph overhead. Used by the m12p3 grid-search harness to size pods at
|
||||||
|
/// the production shape (F16 vs Int8 vs F32 at 1M/1536-D).
|
||||||
|
#[must_use]
|
||||||
|
pub fn memory_usage(&self) -> usize {
|
||||||
|
self.inner.memory_usage()
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert a `&Path` to a `&str`, returning a `VectorError::Io` on non-UTF-8 paths.
|
/// Convert a `&Path` to a `&str`, returning a `VectorError::Io` on non-UTF-8 paths.
|
||||||
fn path_to_str(path: &Path) -> Result<&str, VectorError> {
|
fn path_to_str(path: &Path) -> Result<&str, VectorError> {
|
||||||
path.to_str().ok_or_else(|| {
|
path.to_str().ok_or_else(|| {
|
||||||
@ -138,22 +205,56 @@ impl UsearchIndex {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Warn once per call when a query requests an `ef_search` that differs from
|
/// Run a `USearch` search `op` with the index-global `expansion_search` pinned
|
||||||
/// the index-level default, which `USearch` cannot honor per-query.
|
/// to `ef` for the whole call, honoring the per-query beam-width override
|
||||||
|
/// race-free (m12p3).
|
||||||
///
|
///
|
||||||
/// Extracted so `search` and `filtered_search` emit an identical diagnostic
|
/// `USearch` 2.24 exposes no per-call expansion argument; the only knob is
|
||||||
/// (single source of truth — the two paths previously duplicated this block
|
/// `change_expansion_search`, which mutates index-global state shared by every
|
||||||
/// verbatim). See the module-level docs for why per-query override is not
|
/// concurrent search. The [`Self::expansion_lock`] makes that safe:
|
||||||
/// supported.
|
///
|
||||||
fn ef_search_unsupported_warn(&self, requested: usize) {
|
/// * **Shared (read) path** — the common case. If the live expansion already
|
||||||
if requested != self.config.ef_search {
|
/// equals `ef`, hold the read guard and run `op`. No writer can change the
|
||||||
tracing::warn!(
|
/// expansion while ANY reader holds the guard, so the C++ value is pinned to
|
||||||
requested,
|
/// `ef` for the entire search, and unlimited same-`ef` searches proceed in
|
||||||
default = self.config.ef_search,
|
/// parallel. Cost: one uncontended read-lock acquire — not serialization.
|
||||||
"per-query ef_search override not supported; using index-level default (may affect recall)"
|
/// * **Exclusive (write) path** — only a query whose `ef` differs from the
|
||||||
);
|
/// live value. Take the write guard, set the expansion, then run `op` while
|
||||||
|
/// still holding it (so a third query cannot change the value mid-search).
|
||||||
|
///
|
||||||
|
/// `ef == 0` is normalized to the construction-time default
|
||||||
|
/// (`config.ef_search`), matching the "0 ⇒ slot default" convention the query
|
||||||
|
/// layer uses ([`crate::query::executor::candidate_gen`]). A poisoned guard is
|
||||||
|
/// recovered with `into_inner`: the lock protects only the transient
|
||||||
|
/// `(set, search)` window — there is no invariant to uphold across a panic —
|
||||||
|
/// so one panicking search must not brick every future search on the slot.
|
||||||
|
fn with_expansion<R>(&self, ef: usize, op: impl FnOnce() -> R) -> R {
|
||||||
|
let ef = if ef == 0 { self.config.ef_search } else { ef };
|
||||||
|
|
||||||
|
// Shared path: the live beam width already matches — run concurrently.
|
||||||
|
{
|
||||||
|
let _shared = self
|
||||||
|
.expansion_lock
|
||||||
|
.read()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
if self.current_expansion.load(Ordering::Relaxed) == ef {
|
||||||
|
return op();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exclusive path: change the beam width, then search under the same guard.
|
||||||
|
let _exclusive = self
|
||||||
|
.expansion_lock
|
||||||
|
.write()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
// Re-check under the exclusive guard: another writer may have already set
|
||||||
|
// `ef` between our shared-path miss and acquiring the write lock.
|
||||||
|
if self.current_expansion.load(Ordering::Relaxed) != ef {
|
||||||
|
self.inner.change_expansion_search(ef);
|
||||||
|
self.current_expansion.store(ef, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
op()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VectorIndex for UsearchIndex {
|
impl VectorIndex for UsearchIndex {
|
||||||
@ -177,22 +278,11 @@ impl VectorIndex for UsearchIndex {
|
|||||||
|
|
||||||
/// Search for the `k` nearest neighbors using HNSW approximate search.
|
/// Search for the `k` nearest neighbors using HNSW approximate search.
|
||||||
///
|
///
|
||||||
/// The `ef_search` parameter is accepted for trait compliance. If it
|
/// The `ef_search` parameter is honored per-query (m12p3): it sets the layer-0
|
||||||
/// differs from the index-level default, a debug log is emitted, but
|
/// beam width for this search, trading latency for recall. `0` uses the
|
||||||
/// the index-level default is used.
|
/// construction-time default. The override is made race-free against
|
||||||
///
|
/// concurrent searches by [`Self::with_expansion`] — searches that share an
|
||||||
/// # Per-query `ef_search` override (deferred)
|
/// `ef_search` run concurrently; only a differing one briefly serializes.
|
||||||
///
|
|
||||||
/// `USearch`'s `change_expansion_search()` mutates global state and is
|
|
||||||
/// not safe for concurrent queries with different `ef_search` values.
|
|
||||||
/// A proper solution would require either:
|
|
||||||
///
|
|
||||||
/// 1. A mutex around search + expansion change (unacceptable on the hot path), or
|
|
||||||
/// 2. `USearch` adding a per-query expansion parameter to their search API.
|
|
||||||
///
|
|
||||||
/// For now, we use the index-level default set at construction time.
|
|
||||||
/// Tracked as deferred work in `docs/specs/07-vector-retrieval.md`
|
|
||||||
/// (per-query beam-width override); not scheduled for an M0–M10 milestone.
|
|
||||||
fn search(
|
fn search(
|
||||||
&self,
|
&self,
|
||||||
query: &[f32],
|
query: &[f32],
|
||||||
@ -200,11 +290,9 @@ impl VectorIndex for UsearchIndex {
|
|||||||
ef_search: usize,
|
ef_search: usize,
|
||||||
) -> Result<Vec<VectorSearchResult>, VectorError> {
|
) -> Result<Vec<VectorSearchResult>, VectorError> {
|
||||||
self.validate_dimensions(query)?;
|
self.validate_dimensions(query)?;
|
||||||
self.ef_search_unsupported_warn(ef_search);
|
|
||||||
|
|
||||||
let matches = self
|
let matches = self
|
||||||
.inner
|
.with_expansion(ef_search, || self.inner.search(query, k))
|
||||||
.search(query, k)
|
|
||||||
.map_err(|e| VectorError::Backend(format!("USearch search failed: {e}")))?;
|
.map_err(|e| VectorError::Backend(format!("USearch search failed: {e}")))?;
|
||||||
|
|
||||||
Ok(matches
|
Ok(matches
|
||||||
@ -221,7 +309,8 @@ impl VectorIndex for UsearchIndex {
|
|||||||
/// through the C++ layer. The predicate is evaluated during graph traversal,
|
/// through the C++ layer. The predicate is evaluated during graph traversal,
|
||||||
/// so non-matching vectors are skipped without computing their distance.
|
/// so non-matching vectors are skipped without computing their distance.
|
||||||
///
|
///
|
||||||
/// See `search()` for the `ef_search` override limitation.
|
/// The `ef_search` parameter is honored per-query exactly as in
|
||||||
|
/// [`search`](Self::search) — see there for the concurrency model.
|
||||||
fn filtered_search(
|
fn filtered_search(
|
||||||
&self,
|
&self,
|
||||||
query: &[f32],
|
query: &[f32],
|
||||||
@ -230,15 +319,15 @@ impl VectorIndex for UsearchIndex {
|
|||||||
filter: &dyn Fn(VectorId) -> bool,
|
filter: &dyn Fn(VectorId) -> bool,
|
||||||
) -> Result<Vec<VectorSearchResult>, VectorError> {
|
) -> Result<Vec<VectorSearchResult>, VectorError> {
|
||||||
self.validate_dimensions(query)?;
|
self.validate_dimensions(query)?;
|
||||||
self.ef_search_unsupported_warn(ef_search);
|
|
||||||
|
|
||||||
// Wrap the &dyn Fn in a concrete closure type that USearch's generic
|
// Wrap the &dyn Fn in a concrete closure type that USearch's generic
|
||||||
// filtered_search can monomorphize over.
|
// filtered_search can monomorphize over.
|
||||||
let predicate = |key: usearch::Key| filter(key);
|
let predicate = |key: usearch::Key| filter(key);
|
||||||
|
|
||||||
let matches = self
|
let matches = self
|
||||||
.inner
|
.with_expansion(ef_search, || {
|
||||||
.filtered_search(query, k, predicate)
|
self.inner.filtered_search(query, k, predicate)
|
||||||
|
})
|
||||||
.map_err(|e| VectorError::Backend(format!("USearch filtered_search failed: {e}")))?;
|
.map_err(|e| VectorError::Backend(format!("USearch filtered_search failed: {e}")))?;
|
||||||
|
|
||||||
Ok(matches
|
Ok(matches
|
||||||
@ -304,6 +393,12 @@ impl VectorIndex for UsearchIndex {
|
|||||||
.load(path_str)
|
.load(path_str)
|
||||||
.map_err(|e| VectorError::Backend(format!("USearch load failed: {e}")))?;
|
.map_err(|e| VectorError::Backend(format!("USearch load failed: {e}")))?;
|
||||||
|
|
||||||
|
// The on-disk index may carry a different `expansion_search` than
|
||||||
|
// `config`. Force the configured default so the tracked
|
||||||
|
// `current_expansion` (set by `new` to `config.ef_search`) is the truth.
|
||||||
|
// Single-threaded at construction — no `expansion_lock` needed.
|
||||||
|
index.inner.change_expansion_search(config.ef_search);
|
||||||
|
|
||||||
// Reconstruct total_slots from the loaded index size.
|
// Reconstruct total_slots from the loaded index size.
|
||||||
// After load, all entries are live (no tombstones in a freshly loaded index).
|
// After load, all entries are live (no tombstones in a freshly loaded index).
|
||||||
index
|
index
|
||||||
@ -321,6 +416,11 @@ impl VectorIndex for UsearchIndex {
|
|||||||
.view(path_str)
|
.view(path_str)
|
||||||
.map_err(|e| VectorError::Backend(format!("USearch view failed: {e}")))?;
|
.map_err(|e| VectorError::Backend(format!("USearch view failed: {e}")))?;
|
||||||
|
|
||||||
|
// Pin the beam width to `config.ef_search` (the C++ config is in-process
|
||||||
|
// memory, independent of the mmap'd graph) so `current_expansion` is
|
||||||
|
// truthful even on a read-only view. See `load`.
|
||||||
|
index.inner.change_expansion_search(config.ef_search);
|
||||||
|
|
||||||
// After view, all entries are live (no tombstones).
|
// After view, all entries are live (no tombstones).
|
||||||
index
|
index
|
||||||
.total_slots
|
.total_slots
|
||||||
@ -552,6 +652,70 @@ mod tests {
|
|||||||
assert!(index.is_empty()); // no side effects
|
assert!(index.is_empty()); // no side effects
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// m12p3: concurrent searches that request DIFFERENT `ef_search` values must
|
||||||
|
/// each return correct results — the epoch guard must serialize only the
|
||||||
|
/// beam-width change, never corrupt a neighbour set. We hammer the index from
|
||||||
|
/// many threads alternating between two `ef` values and assert every result is
|
||||||
|
/// a valid, in-range, descending-distance neighbour list.
|
||||||
|
#[test]
|
||||||
|
fn usearch_concurrent_mixed_ef_search_is_correct() {
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
let dim = 64;
|
||||||
|
let n = 2_000_u64;
|
||||||
|
let config = VectorIndexConfig {
|
||||||
|
dimensions: dim,
|
||||||
|
metric: DistanceMetric::L2,
|
||||||
|
quantization: QuantizationLevel::F16,
|
||||||
|
connectivity: 16,
|
||||||
|
ef_construction: 200,
|
||||||
|
ef_search: 100,
|
||||||
|
};
|
||||||
|
let index = Arc::new(UsearchIndex::new(config).unwrap());
|
||||||
|
index.reserve(n as usize).unwrap();
|
||||||
|
for id in 0..n {
|
||||||
|
#[allow(clippy::cast_precision_loss)]
|
||||||
|
let mut v = vec![0.0_f32; dim];
|
||||||
|
v[(id as usize) % dim] = 1.0;
|
||||||
|
v[(id as usize / dim) % dim] += 0.5;
|
||||||
|
index.insert(id, &v).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let query: Vec<f32> = {
|
||||||
|
let mut v = vec![0.0_f32; dim];
|
||||||
|
v[0] = 1.0;
|
||||||
|
v
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
for t in 0..8_u64 {
|
||||||
|
let index = Arc::clone(&index);
|
||||||
|
let query = query.clone();
|
||||||
|
handles.push(std::thread::spawn(move || {
|
||||||
|
for i in 0..200_u64 {
|
||||||
|
// Alternate beam widths so threads contend on the epoch guard.
|
||||||
|
let ef = if (t + i) % 2 == 0 { 16 } else { 256 };
|
||||||
|
let results = index.search(&query, 10, ef).unwrap();
|
||||||
|
assert!(results.len() <= 10);
|
||||||
|
// Distances are ascending (closest first) and in the L2 range.
|
||||||
|
let mut prev = f32::NEG_INFINITY;
|
||||||
|
for r in &results {
|
||||||
|
assert!(r.id < n, "id {} out of range", r.id);
|
||||||
|
assert!(r.distance >= -1e-3, "negative distance {}", r.distance);
|
||||||
|
assert!(
|
||||||
|
r.distance + 1e-3 >= prev,
|
||||||
|
"results not ascending by distance"
|
||||||
|
);
|
||||||
|
prev = r.distance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for h in handles {
|
||||||
|
h.join().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn usearch_tombstone_ratio() {
|
fn usearch_tombstone_ratio() {
|
||||||
let config = default_config(4);
|
let config = default_config(4);
|
||||||
|
|||||||
174
tidal/tests/m12p1_vector_recall.rs
Normal file
174
tidal/tests/m12p1_vector_recall.rs
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
#![allow(clippy::unwrap_used, clippy::cast_precision_loss)]
|
||||||
|
//! m12p1 — pure k-NN recall probe (`TidalDb::vector_search_items`).
|
||||||
|
//!
|
||||||
|
//! The recall harness (`tidal-stress --verify-recall`) measures ANN recall@k by
|
||||||
|
//! comparing the engine's nearest-neighbor result against a brute-force cosine
|
||||||
|
//! ground truth. This test exercises the engine-side surface that harness hits
|
||||||
|
//! — the raw k-NN probe — and proves it returns the true nearest neighbors
|
||||||
|
//! (recall == 1.0) at a corpus size where the slot index is exact brute-force,
|
||||||
|
//! so any later HNSW recall shortfall is attributable to the index, not the
|
||||||
|
//! plumbing.
|
||||||
|
//!
|
||||||
|
//! # UAT Scenario
|
||||||
|
//!
|
||||||
|
//! ```
|
||||||
|
//! Given: A db with an Item "content" embedding slot and N indexed vectors
|
||||||
|
//! When: db.vector_search_items(q, 10, None)
|
||||||
|
//! Then: Returns the 10 items nearest q by cosine, closest-first
|
||||||
|
//! And: The set equals a brute-force cosine top-10 ground truth (recall 1.0)
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use std::{collections::HashMap, time::Duration};
|
||||||
|
|
||||||
|
use tidaldb::{
|
||||||
|
TidalDb,
|
||||||
|
schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Window},
|
||||||
|
};
|
||||||
|
|
||||||
|
const DIM: usize = 16;
|
||||||
|
// Below `USEARCH_MIN_VECTORS` (10k) the slot is an exact BruteForceIndex, so the
|
||||||
|
// probe's recall against a brute-force ground truth must be exactly 1.0 — this
|
||||||
|
// isolates the plumbing from HNSW approximation, which the live harness measures.
|
||||||
|
const N: u64 = 500;
|
||||||
|
|
||||||
|
/// `SplitMix64` — a tiny deterministic generator so the corpus is reproducible
|
||||||
|
/// without a dev-dependency on a seeded RNG. Same scheme the live harness uses.
|
||||||
|
const fn splitmix64(state: &mut u64) -> u64 {
|
||||||
|
*state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||||
|
let mut z = *state;
|
||||||
|
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||||
|
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||||
|
z ^ (z >> 31)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A deterministic, non-zero vector keyed by entity id (each component in
|
||||||
|
/// `[-0.5, 0.5)`). The engine L2-normalizes on write, so cosine order over these
|
||||||
|
/// raw vectors matches the engine's L2-on-normalized order.
|
||||||
|
fn vector_for(id: u64) -> Vec<f32> {
|
||||||
|
let mut state = id.wrapping_mul(0x2545_F491_4F6C_DD1D).wrapping_add(1);
|
||||||
|
(0..DIM)
|
||||||
|
.map(|_| {
|
||||||
|
let bits = (splitmix64(&mut state) >> 40) as u32; // 24 random bits
|
||||||
|
(bits as f32 / f32::from(1u16 << 12) / 4096.0) - 0.5
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cosine(a: &[f32], b: &[f32]) -> f32 {
|
||||||
|
let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
|
||||||
|
let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||||
|
let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||||
|
if na == 0.0 || nb == 0.0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
dot / (na * nb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Brute-force cosine top-`k` ground truth over the deterministic corpus.
|
||||||
|
fn ground_truth(query: &[f32], k: usize) -> Vec<u64> {
|
||||||
|
let mut scored: Vec<(u64, f32)> = (1..=N)
|
||||||
|
.map(|id| (id, cosine(query, &vector_for(id))))
|
||||||
|
.collect();
|
||||||
|
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
|
||||||
|
scored.into_iter().take(k).map(|(id, _)| id).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_db() -> TidalDb {
|
||||||
|
let mut builder = SchemaBuilder::new();
|
||||||
|
// A schema must declare at least one signal; the probe ignores signals
|
||||||
|
// entirely, but the engine requires one to open.
|
||||||
|
let _ = builder
|
||||||
|
.signal(
|
||||||
|
"view",
|
||||||
|
EntityKind::Item,
|
||||||
|
DecaySpec::Exponential {
|
||||||
|
half_life: Duration::from_secs(7 * 24 * 3600),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.windows(&[Window::TwentyFourHours])
|
||||||
|
.velocity(false)
|
||||||
|
.add();
|
||||||
|
builder.embedding_slot("content", EntityKind::Item, DIM);
|
||||||
|
let schema = builder.build().unwrap();
|
||||||
|
let db = TidalDb::builder()
|
||||||
|
.ephemeral()
|
||||||
|
.with_schema(schema)
|
||||||
|
.open()
|
||||||
|
.unwrap();
|
||||||
|
for id in 1..=N {
|
||||||
|
// An item must exist for its embedding; metadata is irrelevant to k-NN.
|
||||||
|
db.write_item_with_metadata(EntityId::new(id), &HashMap::new())
|
||||||
|
.unwrap();
|
||||||
|
db.write_item_embedding(EntityId::new(id), &vector_for(id))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
db
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vector_search_returns_exact_nearest_neighbors() {
|
||||||
|
let db = build_db();
|
||||||
|
|
||||||
|
// Query with item 42's own vector: it must come back first, ~zero distance.
|
||||||
|
let q = vector_for(42);
|
||||||
|
let results = db.vector_search_items(&q, 10, None).unwrap();
|
||||||
|
assert_eq!(results.len(), 10, "k=10 nearest requested");
|
||||||
|
assert_eq!(
|
||||||
|
results[0].id, 42,
|
||||||
|
"an item's own vector is its nearest neighbor"
|
||||||
|
);
|
||||||
|
// ~0 modulo F16 quantization (the slot's default), which perturbs a stored
|
||||||
|
// unit vector by ~1e-3 — far below any other item's distance.
|
||||||
|
assert!(
|
||||||
|
results[0].distance <= 0.01,
|
||||||
|
"self-distance must be ~0, got {}",
|
||||||
|
results[0].distance
|
||||||
|
);
|
||||||
|
|
||||||
|
// Results are ordered closest-first (ascending L2 distance).
|
||||||
|
for w in results.windows(2) {
|
||||||
|
assert!(
|
||||||
|
w[0].distance <= w[1].distance,
|
||||||
|
"results must be sorted by ascending distance"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recall@10 vs an INDEPENDENT brute-force cosine ground truth. The exhaustive
|
||||||
|
// index is exact, but it scores in F16 (and computes L2-on-normalized where the
|
||||||
|
// oracle computes cosine), so a single item at the k=10 boundary may swap —
|
||||||
|
// hence `>= 9`, not `== 10`. A real recall miss (HNSW approximation) shows up
|
||||||
|
// as a much larger shortfall, which the live harness measures at scale.
|
||||||
|
let truth: std::collections::HashSet<u64> = ground_truth(&q, 10).into_iter().collect();
|
||||||
|
let got: std::collections::HashSet<u64> = results.iter().map(|r| r.id).collect();
|
||||||
|
let hits = got.intersection(&truth).count();
|
||||||
|
assert!(
|
||||||
|
hits >= 9,
|
||||||
|
"exact index must achieve recall@10 ~ 1.0, got {hits}/10"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vector_search_accepts_ef_search_override() {
|
||||||
|
let db = build_db();
|
||||||
|
let q = vector_for(7);
|
||||||
|
// Both the slot default (None) and an explicit override resolve and return
|
||||||
|
// the same exact nearest set on a brute-force slot (ef is ignored there).
|
||||||
|
let a = db.vector_search_items(&q, 5, None).unwrap();
|
||||||
|
let b = db.vector_search_items(&q, 5, Some(64)).unwrap();
|
||||||
|
let ids_a: Vec<u64> = a.iter().map(|r| r.id).collect();
|
||||||
|
let ids_b: Vec<u64> = b.iter().map(|r| r.id).collect();
|
||||||
|
assert_eq!(ids_a, ids_b);
|
||||||
|
assert_eq!(ids_a[0], 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vector_search_rejects_dimension_mismatch() {
|
||||||
|
let db = build_db();
|
||||||
|
let wrong = vec![0.1_f32; DIM + 1];
|
||||||
|
let err = db.vector_search_items(&wrong, 10, None);
|
||||||
|
assert!(
|
||||||
|
err.is_err(),
|
||||||
|
"a query vector of the wrong dimension must error, not silently return"
|
||||||
|
);
|
||||||
|
}
|
||||||
295
tidal/tests/m12p2_ann_retrieve.rs
Normal file
295
tidal/tests/m12p2_ann_retrieve.rs
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
#![allow(
|
||||||
|
clippy::too_many_lines,
|
||||||
|
clippy::unwrap_used,
|
||||||
|
clippy::cast_possible_truncation
|
||||||
|
)]
|
||||||
|
//! m12p2 — ANN candidate generation + cached `SignalRanked` in RETRIEVE.
|
||||||
|
//!
|
||||||
|
//! These prove the G1 unblock end-to-end through the engine: `for_you` sources
|
||||||
|
//! its candidates by nearest-neighbour over the user's preference vector (not an
|
||||||
|
//! arbitrary low-id scan slice), and `trending` sources its candidates from the
|
||||||
|
//! cached per-signal-type top-K (reaching the actually-viewed items at any id).
|
||||||
|
//! The distinguishing trick: seed a corpus far larger than the scan cap and
|
||||||
|
//! place the relevant items at HIGH ids — a scan (capped at the lowest ~240 ids)
|
||||||
|
//! cannot reach them, so their presence in the feed proves the index path ran.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use tidaldb::{
|
||||||
|
TidalDb,
|
||||||
|
query::retrieve::{ProfileRef, RetrieveBuilder},
|
||||||
|
schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window},
|
||||||
|
};
|
||||||
|
|
||||||
|
const DIM: usize = 8;
|
||||||
|
/// Corpus far larger than the executor's scan cap (~240) so high-id relevant
|
||||||
|
/// items are unreachable by a scan — their presence proves the index path.
|
||||||
|
const N: u64 = 2000;
|
||||||
|
|
||||||
|
/// A one-hot embedding on axis `i % DIM`: items sharing an axis are identical
|
||||||
|
/// (distance 0), so a preference vector on axis A makes every axis-A item — at
|
||||||
|
/// any id — a nearest neighbour.
|
||||||
|
fn one_hot(axis: usize) -> Vec<f32> {
|
||||||
|
let mut v = vec![0.0_f32; DIM];
|
||||||
|
v[axis] = 1.0;
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
fn schema_with_embeddings() -> tidaldb::schema::Schema {
|
||||||
|
let mut builder = SchemaBuilder::new();
|
||||||
|
let _ = builder
|
||||||
|
.signal(
|
||||||
|
"view",
|
||||||
|
EntityKind::Item,
|
||||||
|
DecaySpec::Exponential {
|
||||||
|
half_life: std::time::Duration::from_secs(7 * 24 * 3600),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.windows(&[Window::OneHour, Window::TwentyFourHours])
|
||||||
|
.velocity(true)
|
||||||
|
.add();
|
||||||
|
let _ = builder
|
||||||
|
.signal(
|
||||||
|
"like",
|
||||||
|
EntityKind::Item,
|
||||||
|
DecaySpec::Exponential {
|
||||||
|
half_life: std::time::Duration::from_secs(30 * 24 * 3600),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.windows(&[Window::TwentyFourHours])
|
||||||
|
.velocity(false)
|
||||||
|
.positive_engagement(true)
|
||||||
|
.add();
|
||||||
|
builder.embedding_slot("content", EntityKind::Item, DIM);
|
||||||
|
builder.build().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn for_you_ann_surfaces_nearest_to_preference_across_the_whole_corpus() {
|
||||||
|
let db = TidalDb::builder()
|
||||||
|
.ephemeral()
|
||||||
|
.with_schema(schema_with_embeddings())
|
||||||
|
.open()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let ts = Timestamp::now();
|
||||||
|
for id in 1..=N {
|
||||||
|
db.write_item_with_metadata(EntityId::new(id), &HashMap::new())
|
||||||
|
.unwrap();
|
||||||
|
db.write_item_embedding(EntityId::new(id), &one_hot((id % DIM as u64) as usize))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// A HIGH-id axis-5 item, made engaging with views. Its id (1997) is far beyond
|
||||||
|
// the executor's scan pool (limit × 10 = 500 lowest ids), so a scan can NEVER
|
||||||
|
// surface it — only ANN candidate-gen over the axis-5 preference vector reaches
|
||||||
|
// it. (1997 % 8 == 5.)
|
||||||
|
let far = 1997u64;
|
||||||
|
for _ in 0..50 {
|
||||||
|
db.signal("view", EntityId::new(far), 1.0, ts).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// User 7's learned taste is axis 5. Set the preference vector directly (the
|
||||||
|
// signal-driven path that builds it is covered elsewhere; here we isolate the
|
||||||
|
// ANN candidate-gen). Axis-5 items are ids 5, 13, 21, …, 1997.
|
||||||
|
let user = 7u64;
|
||||||
|
assert!(db.preference_vectors().set(user, one_hot(5)));
|
||||||
|
|
||||||
|
let results = db
|
||||||
|
.retrieve(
|
||||||
|
&RetrieveBuilder::new(EntityKind::Item, ProfileRef::new("for_you"))
|
||||||
|
.for_user(user)
|
||||||
|
.limit(50)
|
||||||
|
.build()
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let ids: Vec<u64> = results.items.iter().map(|r| r.entity_id.as_u64()).collect();
|
||||||
|
assert_eq!(ids.len(), 50, "expected a full page");
|
||||||
|
|
||||||
|
let axis5 = ids.iter().filter(|id| *id % DIM as u64 == 5).count();
|
||||||
|
// ANN over the axis-5 preference vector ⇒ the page is dominated by axis-5
|
||||||
|
// items (for_you injects ~10% exploration, so allow for that). A scan would
|
||||||
|
// return ~1/8 axis-5 from the low-id slice — nowhere near this.
|
||||||
|
assert!(
|
||||||
|
axis5 >= 30,
|
||||||
|
"expected the page dominated by axis-5 (ANN); got {axis5}/50 — looks like a scan"
|
||||||
|
);
|
||||||
|
// The engaging high-id item is reachable ONLY through the ANN candidate pool
|
||||||
|
// (it sits beyond the scan cap), so its presence proves the ANN index — not the
|
||||||
|
// universe scan — generated these candidates.
|
||||||
|
assert!(
|
||||||
|
ids.contains(&far),
|
||||||
|
"ANN must reach the engaging high-id nearest neighbour {far} that the scan pool \
|
||||||
|
(ids 1..=500) cannot; got {ids:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn for_you_without_preference_vector_falls_back_to_scan() {
|
||||||
|
// An anonymous-ish read (a user with no preference vector) must still serve —
|
||||||
|
// the ANN strategy degrades to a scan rather than erroring or returning empty.
|
||||||
|
let db = TidalDb::builder()
|
||||||
|
.ephemeral()
|
||||||
|
.with_schema(schema_with_embeddings())
|
||||||
|
.open()
|
||||||
|
.unwrap();
|
||||||
|
for id in 1..=300u64 {
|
||||||
|
db.write_item_with_metadata(EntityId::new(id), &HashMap::new())
|
||||||
|
.unwrap();
|
||||||
|
db.write_item_embedding(EntityId::new(id), &one_hot((id % DIM as u64) as usize))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let results = db
|
||||||
|
.retrieve(
|
||||||
|
&RetrieveBuilder::new(EntityKind::Item, ProfileRef::new("for_you"))
|
||||||
|
.for_user(999) // no preference vector set
|
||||||
|
.limit(20)
|
||||||
|
.build()
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
!results.items.is_empty(),
|
||||||
|
"for_you with no preference vector must fall back to scan, not return empty"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn related_ann_surfaces_seed_neighbours_across_the_whole_corpus() {
|
||||||
|
let db = TidalDb::builder()
|
||||||
|
.ephemeral()
|
||||||
|
.with_schema(schema_with_embeddings())
|
||||||
|
.open()
|
||||||
|
.unwrap();
|
||||||
|
let ts = Timestamp::now();
|
||||||
|
for id in 1..=N {
|
||||||
|
db.write_item_with_metadata(EntityId::new(id), &HashMap::new())
|
||||||
|
.unwrap();
|
||||||
|
db.write_item_embedding(EntityId::new(id), &one_hot((id % DIM as u64) as usize))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
// Seed item 3 is axis 3; an engaging high-id axis-3 item (1995 % 8 == 3) is
|
||||||
|
// reachable only through the seed's ANN neighbour pool, never the scan slice.
|
||||||
|
let far = 1995u64;
|
||||||
|
for _ in 0..50 {
|
||||||
|
db.signal("view", EntityId::new(far), 1.0, ts).unwrap();
|
||||||
|
}
|
||||||
|
let seed = EntityId::new(3);
|
||||||
|
let results = db
|
||||||
|
.retrieve(
|
||||||
|
&RetrieveBuilder::new(EntityKind::Item, ProfileRef::new("related"))
|
||||||
|
.similar_to(seed)
|
||||||
|
.limit(50)
|
||||||
|
.build()
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let ids: Vec<u64> = results.items.iter().map(|r| r.entity_id.as_u64()).collect();
|
||||||
|
assert_eq!(ids.len(), 50, "expected a full page");
|
||||||
|
// The decisive proof of ANN candidate generation: the seed's high-id
|
||||||
|
// neighbour (1995, beyond the scan pool of ids 1..=500) is in the page. Only
|
||||||
|
// the ANN search over the seed embedding can reach it; a scan never could.
|
||||||
|
// (related ranks the ANN-similar pool by engagement/recency — surfacing
|
||||||
|
// SIMILAR items by similarity score is a related-scoring follow-up; here we
|
||||||
|
// assert candidate generation, the m12p2 work item.)
|
||||||
|
assert!(
|
||||||
|
ids.contains(&far),
|
||||||
|
"related ANN must reach the seed's high-id neighbour {far} beyond the scan pool; got {ids:?}"
|
||||||
|
);
|
||||||
|
// Axis-3 (the seed's true neighbours) appear well above the ~1/8 a scan slice
|
||||||
|
// would yield, confirming the pool is the seed's neighbourhood.
|
||||||
|
let axis3 = ids.iter().filter(|id| *id % DIM as u64 == 3).count();
|
||||||
|
assert!(
|
||||||
|
axis3 >= 8,
|
||||||
|
"related's candidate pool should be the seed's axis-3 neighbourhood; got {axis3}/50"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trending_signalranked_reaches_high_id_viewed_items() {
|
||||||
|
let db = TidalDb::builder()
|
||||||
|
.ephemeral()
|
||||||
|
.with_schema(schema_with_embeddings())
|
||||||
|
.open()
|
||||||
|
.unwrap();
|
||||||
|
let ts = Timestamp::now();
|
||||||
|
for id in 1..=N {
|
||||||
|
db.write_item_with_metadata(EntityId::new(id), &HashMap::new())
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
// Heavy views on three HIGH-id items (unreachable by a low-id scan), light
|
||||||
|
// views on a few low ids as noise.
|
||||||
|
let hot = [1500u64, 1700, 1900];
|
||||||
|
for &id in &hot {
|
||||||
|
for _ in 0..100 {
|
||||||
|
db.signal("view", EntityId::new(id), 1.0, ts).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id in 1..=20u64 {
|
||||||
|
db.signal("view", EntityId::new(id), 1.0, ts).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let results = db
|
||||||
|
.retrieve(
|
||||||
|
&RetrieveBuilder::new(EntityKind::Item, ProfileRef::new("trending"))
|
||||||
|
.limit(20)
|
||||||
|
.build()
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let ids: std::collections::HashSet<u64> =
|
||||||
|
results.items.iter().map(|r| r.entity_id.as_u64()).collect();
|
||||||
|
for &id in &hot {
|
||||||
|
assert!(
|
||||||
|
ids.contains(&id),
|
||||||
|
"trending must surface heavily-viewed high-id item {id} via SignalRanked; \
|
||||||
|
a scan (low ~240 ids) could never reach it. got {ids:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trending_cache_reflects_new_writes() {
|
||||||
|
// The SignalRanked top-K cache must not serve stale results across writes on a
|
||||||
|
// small ledger (always-fresh policy): a newly-hot item appears immediately.
|
||||||
|
let db = TidalDb::builder()
|
||||||
|
.ephemeral()
|
||||||
|
.with_schema(schema_with_embeddings())
|
||||||
|
.open()
|
||||||
|
.unwrap();
|
||||||
|
let ts = Timestamp::now();
|
||||||
|
for id in 1..=100u64 {
|
||||||
|
db.write_item_with_metadata(EntityId::new(id), &HashMap::new())
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
for _ in 0..50 {
|
||||||
|
db.signal("view", EntityId::new(10), 1.0, ts).unwrap();
|
||||||
|
}
|
||||||
|
let q = || {
|
||||||
|
db.retrieve(
|
||||||
|
&RetrieveBuilder::new(EntityKind::Item, ProfileRef::new("trending"))
|
||||||
|
.limit(10)
|
||||||
|
.build()
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
let first: std::collections::HashSet<u64> =
|
||||||
|
q().items.iter().map(|r| r.entity_id.as_u64()).collect();
|
||||||
|
assert!(
|
||||||
|
first.contains(&10),
|
||||||
|
"item 10 should be trending after its views"
|
||||||
|
);
|
||||||
|
|
||||||
|
// New burst on a different item AFTER the first query (which warmed the cache).
|
||||||
|
for _ in 0..200 {
|
||||||
|
db.signal("view", EntityId::new(90), 1.0, ts).unwrap();
|
||||||
|
}
|
||||||
|
let second: std::collections::HashSet<u64> =
|
||||||
|
q().items.iter().map(|r| r.entity_id.as_u64()).collect();
|
||||||
|
assert!(
|
||||||
|
second.contains(&90),
|
||||||
|
"the cache must reflect the new burst on item 90 (always-fresh on a small ledger); got {second:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -408,3 +408,99 @@ fn usearch_recall_at_10k() {
|
|||||||
|
|
||||||
eprintln!("10K vectors recall@{k}: {avg_recall:.3}");
|
eprintln!("10K vectors recall@{k}: {avg_recall:.3}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// m12p3: the per-query `ef_search` override must be HONORED, not silently
|
||||||
|
/// dropped to the index default (the pre-m12p3 behaviour, which only logged a
|
||||||
|
/// warning).
|
||||||
|
///
|
||||||
|
/// The index is built with a deliberately starved default beam (`ef_search=8`),
|
||||||
|
/// so if the override were ignored every search would use that starved beam and
|
||||||
|
/// a wide per-query beam would change nothing. We run the same query set twice —
|
||||||
|
/// once at the starved default, once at a wide `ef_search=512` — and assert the
|
||||||
|
/// wide beam recovers strictly more true neighbours. Summed over many queries so
|
||||||
|
/// the verdict does not hinge on a single lucky/unlucky query.
|
||||||
|
#[test]
|
||||||
|
fn usearch_per_query_ef_search_is_honored() {
|
||||||
|
let dim = 128;
|
||||||
|
let n = 10_000u64;
|
||||||
|
let k: usize = 10;
|
||||||
|
let num_queries: usize = 30;
|
||||||
|
|
||||||
|
// Starved construction default: if the override is ignored, *every* search
|
||||||
|
// runs at this beam and the wide pass below cannot possibly do better.
|
||||||
|
let config = VectorIndexConfig {
|
||||||
|
dimensions: dim,
|
||||||
|
metric: DistanceMetric::L2,
|
||||||
|
quantization: QuantizationLevel::F16,
|
||||||
|
connectivity: 16,
|
||||||
|
ef_construction: 200,
|
||||||
|
ef_search: 8,
|
||||||
|
};
|
||||||
|
let usearch = UsearchIndex::new(config).unwrap();
|
||||||
|
let brute = BruteForceIndex::new(f32_config(dim));
|
||||||
|
usearch.reserve(n as usize).unwrap();
|
||||||
|
|
||||||
|
let mut rng = rand::rng();
|
||||||
|
for id in 0..n {
|
||||||
|
let v = random_unit_vector(dim, &mut rng);
|
||||||
|
usearch.insert(id, &v).unwrap();
|
||||||
|
brute.insert(id, &v).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a fixed query set so the narrow and wide passes are compared on the
|
||||||
|
// SAME queries (the only thing that differs is the requested ef_search).
|
||||||
|
let queries: Vec<Vec<f32>> = (0..num_queries)
|
||||||
|
.map(|_| random_unit_vector(dim, &mut rng))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut narrow_hits = 0usize;
|
||||||
|
let mut wide_hits = 0usize;
|
||||||
|
for q in &queries {
|
||||||
|
let truth: Vec<u64> = brute
|
||||||
|
.search(q, k, 0)
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|r| r.id)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let narrow: Vec<u64> = usearch
|
||||||
|
.search(q, k, 8) // starved beam (== construction default)
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|r| r.id)
|
||||||
|
.collect();
|
||||||
|
let wide: Vec<u64> = usearch
|
||||||
|
.search(q, k, 512) // wide per-query override
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|r| r.id)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
#[allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_sign_loss,
|
||||||
|
clippy::cast_precision_loss
|
||||||
|
)]
|
||||||
|
let scale = |frac: f64| -> usize { (frac * k as f64).round() as usize };
|
||||||
|
narrow_hits += scale(recall_at_k(&truth, &narrow));
|
||||||
|
wide_hits += scale(recall_at_k(&truth, &wide));
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
wide_hits > narrow_hits,
|
||||||
|
"per-query ef_search appears ignored: ef=512 recovered {wide_hits} hits, \
|
||||||
|
ef=8 recovered {narrow_hits} (a wider beam must recover strictly more)"
|
||||||
|
);
|
||||||
|
// The wide beam should also reach near-exact recall — sanity that 512 is
|
||||||
|
// actually exploring, not just marginally above the starved default.
|
||||||
|
#[allow(clippy::cast_precision_loss)]
|
||||||
|
let wide_recall = wide_hits as f64 / (num_queries as f64 * k as f64);
|
||||||
|
assert!(
|
||||||
|
wide_recall > 0.95,
|
||||||
|
"ef=512 recall@{k} = {wide_recall:.3}, expected > 0.95"
|
||||||
|
);
|
||||||
|
eprintln!(
|
||||||
|
"per-query ef_search honored: ef=8 hits={narrow_hits}, ef=512 hits={wide_hits} \
|
||||||
|
(wide recall@{k}={wide_recall:.3})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user