Ticket 02 — Qdrant serving-path parity: report¶
Date: 2026-08-12/13. All numbers from a live Qdrant v1.19.0 (Docker, local Mac), real S3 exports, real S5 gold queries. No fake-backed numbers anywhere in this report.
Verdict¶
Deployed with Qdrant, same recall — measured on the full query sets. With the export loaded
at distance: dot and the shipped backend serving at search_params: {hnsw_ef: 512}, the
production GraphRescueRetrieval composition returns the brute-force bench reference's recall
to within −0.0004 absolute on every metric over the full 12,576-query wiki2multihop set
(R@10 −0.000278, docR@5 −0.000398, docR@10 −0.000278 — well inside ANN noise; the bare dense
lane's ANN recall@10 is 0.9995), and exactly (all deltas 0.0) on multihop_rag.
The deployment¶
deploy/qdrant/docker-compose.yml— single-node Qdrant, pinnedv1.19.0, persistent volume, ports 6333 (HTTP) / 6334 (gRPC).- Loaded through the shipped sink CLI (payload columns verbatim, KG metadata included):
.venv/bin/latence-sink-qdrant load benchmark/sota-corpus/s3-exports/wiki2multihop \
--collection wiki2multihop-dot --basename corpus --distance dot --url http://localhost:6333
- Parity harness:
benchmark/serving/parity.py(resumable, fixed-seed sampling = the S5SAMPLE_SEED, per-query JSONL + committed.summary.jsonaggregates underbenchmark/serving/results/).
Index build stats¶
| collection | points | dim | distance | HNSW m | ef_construct | segments | on-disk | load wall |
|---|---|---|---|---|---|---|---|---|
| wiki2multihop-dot | 57,812 | 768 | Dot | 16 (default) | 100 (default) | 5 | 337 MB | 65 s |
| multihop_rag-dot | 3,264 | 768 | Dot | 16 (default) | 100 (default) | 5 | 46 MB | 4.5 s |
Load reports (shipped sink, batch 256, HTTP): upserted == rows read, 0 repeats, reconcile
sweep converged to 0 stale points on every load. Idempotency verified live by the sink's own
smoke (re-load ⇒ 0 upserted / N unchanged, retraction ⇒ verify_absent == []).
Contract fixes made (shipped code)¶
The round trip broke in two places and mis-served in a third; all three are fixed in shipped code with offline tests whose fakes reject what the real store rejects, validated against the live store in this run:
- Identity did not survive the trip (
latence_retrieval.qdrant.QdrantBackend). Qdrant rejects arbitrary string point ids, so the sink derives a UUIDv5 from the content-addressedrecord_idand stores therecord_idin the payload — but the backend returned the UUID asCandidate.id. Every downstream consumer (RRF dedupe against the BM25 lane, gold scoring,GraphRescueRetrieval's append-and-dedupe, hyperedge→chunk resolution) keys on the corpus id, so the served lane's candidates could never merge with anything. Fix:id_keyconfig (default"record_id"— the two shipped halves agree with no configuration, mirroringtext_key), graceful-absent for foreign collections, loud on malformed values, consumed key dropped from metadata. - No ANN accuracy knobs (
QdrantBackend).query_pointswas issued with the store's default search params, so the recall/latency trade could not be tuned or even measured. Fix:search_paramsconfig (hnsw_ef,exact), validated offline at construction, threaded asmodels.SearchParams. The offline fake's explicitquery_pointssignature carries the new kwarg, so a kwarg the real API lacks still fails offline. - Score convention: Qdrant
cosinere-normalises stored vectors (documented onQdrantSink'sdistancekey — measured, not guessed). The export's embedder L2-normalises, but the GPU embed leaves norms ~±0.4% off unit (measured range 0.9982–1.0037 on multihop_rag). Qdrant'scosinedivides by the stored norm at ingest; the bench reference ranks by rawmatrix @ query. The two orderings genuinely differ on near-ties — and the difference is not ANN error:exact: trueon a cosine collection reproduces the same mismatch (ANN-recall@10 0.9522 at defaults and exact, byte-identical).distance: dotstores vectors verbatim and serves the raw inner product — the score-convention-faithful load for pre-normalised exports, and the one this validation uses. (Recall impact of the cosine convention was itself negligible on the sample: R@10 delta −0.002, docR@10 +0.007 — it reorders near-ties, it does not lose gold.)
Recall parity (brute-force reference vs served Qdrant lane)¶
Both sides run the production GraphRescueRetrieval (graph_mode auto, selector none =
the ranked union; the selectors are deterministic functions of the union, so identical unions ⇒
identical selections). The only variable is the dense engine: exact brute force (bench
DenseBackend + the same KG-metadata candidate contract) vs QdrantBackend over the loaded
collection. ann-recall@k is the bare dense lane against the exact top-k (uncontaminated by
fusion).
Tuning sweep (wiki2multihop, 57,812 points, fixed-seed 200-query sample, dot collection)¶
| search_params | ann@10 | ann@100 | R@10 Δ | docR@5 Δ | docR@10 Δ | top-10 identical | served med/p95 ms |
|---|---|---|---|---|---|---|---|
| default | 0.9880 | 0.9738 | −0.0125 | −0.0113 | −0.0125 | 76.0% | 72.9 / 116.0 |
| hnsw_ef 256 | 0.9985 | 0.9960 | −0.0025 | — | −0.0025 | 93.0% | 76.3 / 189.4 |
| hnsw_ef 512 | 0.9995 | 0.9994 | 0.0 | 0.0 | 0.0 | 98.0% | 60.1 / 168.8 |
| hnsw_ef 1024 | 1.0 | 0.9999 | 0.0 | 0.0 | 0.0 | 99.0% | 60.3 / 159.1 |
| exact | 1.0 | 1.0 | 0.0 | 0.0 | 0.0 | 99.5% | 56.4 / 167.6 |
Operating point: hnsw_ef = 512 (zero recall delta at ANN latency; exact recorded as the
rescoring bound). Residual non-identical top-10s at exact are float-noise ties (brute float32
matmul vs the store's own float32 accumulation order), not missing gold — recall deltas are 0.0.
multihop_rag (3,264 points, 46-query fixed-seed smoke, dot collection)¶
Exact parity at every setting tried (default ef, 256, exact): ann@10 = ann@100 = 1.0, all recall deltas 0.0, top-10 identical 46/46.
Full-set numbers (the gate numbers) — hnsw_ef 512, dot collections¶
wiki2multihop: all 12,576 usable queries (12,576/12,576 resolved). multihop_rag: all 2,255 usable queries (of 2,556; the unusable ones are the S5 gold-resolution exclusions, identical for both sides).
| dataset | metric | brute-force reference | served (Qdrant) | delta |
|---|---|---|---|---|
| wiki2multihop (12,576 q) | R@10 | 0.708129 | 0.707851 | −0.000278 |
| wiki2multihop | docR@5 | 0.670165 | 0.669768 | −0.000398 |
| wiki2multihop | docR@10 | 0.718333 | 0.718054 | −0.000278 |
| multihop_rag (2,255 q) | R@10 | 0.375756 | 0.375756 | 0.0 |
| multihop_rag | docR@5 | 0.699076 | 0.699076 | 0.0 |
| multihop_rag | docR@10 | 0.854176 | 0.854176 | 0.0 |
Agreement observability (full sets): wiki2multihop — bare-dense ANN recall@10 0.999491 / @100 0.999427, full-pipeline top-10 lists byte-identical on 98.16% of queries, gate decisions identical on 100% (gate hit rate 0.9998 — 2Wiki questions are almost all relation-shaped, so the graph lane runs nearly everywhere and the payload's KG columns are exercised on nearly every query). multihop_rag — ANN recall@10 = @100 = 1.0, top-10 identical 99.96%, gate agreement 100%.
Serving latency (per query, end-to-end through the production composition)¶
Local single-node store over HTTP, one query at a time (no batching, no concurrency), Mac
host. dense lane is the bare first-stage QdrantBackend.search (top-100); the pipeline rows
are the whole GraphRescueRetrieval.retrieve (dense + BM25 + gate + expander + traversal +
hypergraph + union).
| dataset | measurement | median ms | p95 ms | mean ms |
|---|---|---|---|---|
| wiki2multihop (12,576 q) | dense lane (Qdrant, top-100) | 28.1 | 57.2 | 32.0 |
| wiki2multihop | pipeline, brute-force reference | 67.2 | 185.1 | 90.6 |
| wiki2multihop | pipeline, served (Qdrant) | 82.6 | 213.4 | 104.9 |
| multihop_rag (2,255 q) | dense lane (Qdrant, top-100) | 23.5 | 47.9 | 26.9 |
| multihop_rag | pipeline, brute-force reference | 22.2 | 39.5 | 26.5 |
| multihop_rag | pipeline, served (Qdrant) | 42.2 | 83.1 | 50.4 |
On wiki2multihop the served pipeline costs +15 ms median over the in-process brute scan (an HTTP round trip per query at top-100 with full payloads); the brute reference only looks cheap because a 57,812 × 768 matrix is resident in the process — the served path is the one that scales past a single host's RAM, and its whole-pipeline p95 stays near 0.2 s.
Fake-vs-real validation¶
packages/latence-sink-qdrantlive smoke (LATENCE_QDRANT_URL): 3/3 green against the real v1.19.0 store — load/converge/retraction-absent, and the sparse-vector leg the fake cannot stand in for (named sparse index served, term weights in the vector not the payload).packages/latence-retrievallive backend smoke: green against the loaded 57,812-point collection.- Payload contract round trip (live): top hit's
Candidate.id == record_id,text == content, and all four KG columns (kg_node_ids,neighbor_node_ids,kg_node_community,kg_node_centrality) byte-equal to the export row;record_idnot duplicated into metadata. search_paramsdemonstrably reach the live store: the ef sweep moves ann-recall@10 from 0.9880 → 1.0 with everything else fixed.- Offline suites (the merge gate):
PYTHONHASHSEED=0 pytest packages/latence-retrieval/tests packages/latence-sink-qdrant/tests→ 819 passed, 16 skipped (skips = live-gated + optional-dep tests); withLATENCE_QDRANT_URLset the live-gated tests pass against the real store.ruff checkclean on both packages + the harness;mypy --strict --explicit-package-basesclean on every touched/new file (qdrant.py,sink.py,benchmark/serving/*).
Licenses (verified 2026-08-12)¶
| dependency | version | license | verification |
|---|---|---|---|
| qdrant-client | 1.19.0 | Apache-2.0 | PyPI metadata License-Expression: Apache-2.0 |
| qdrant (server) | v1.19.0 | Apache-2.0 | github.com/qdrant/qdrant LICENSE |
| grpcio | 1.83.0 | Apache-2.0 | PyPI metadata |
| protobuf | 7.35.1 | BSD-3-Clause | PyPI classifier |
| portalocker | 3.2.0 | BSD-3-Clause | PyPI metadata |
| h2 / hpack / hyperframe | 4.4.1 / 4.2.0 / 6.1.0 | MIT | PyPI metadata |
No model weights involved (a vector store is data infrastructure; ADR-0012 weights-vs-code split not applicable to the store itself).
Residuals¶
- BM25 stays local by design (fast + exact); hybrid-in-Qdrant not needed — parity reached on the dense lane alone.
- The graph lanes (traversal + hypergraph) run identically on both sides; their latency is ticket 01's surface, not this one's.