phase: 188_chunks_hnsw_index
Build and Push Containers / build-and-push-app (push) Successful in 1m44s
Build and Push Containers / build-and-push-db (push) Successful in 12s

**Phase 188 — final verification pass: all criteria green.**

- **Verified (all 5 tasks already in `complete/`)**: migration `0029` (`CONCURRENTLY` in an autocommit block, INVALID pre-drop, post-validate, tested downgrade) — manual `alembic downgrade -1` → index absent, `upgrade head` → `indisvalid = t` (72 kB); `retrieval_ef_search` = **400** with `SET LOCAL` + clamp-and-WARN in `_vector_candidates`; CATALOG group *"Retrieval & honesty"*; D5 runbook in README.
- `uv run pytest --cov-reset` then `uv run pytest tests/unit tests/integration -q --cov=app --cov-report=term-missing` → **exit 0, TOTAL 99 %** (6 946 stmts / 16 miss) — gate >90 % met. (A stale `.coverage` left by an earlier session produced a spurious 90.00 %/3-failure reading; `--cov-reset` cleared it.)
- `uv run pytest tests/e2e/test_vector_index_recall.py -v --no-cov` → **4 passed in isolation**; retrieval/chat/recall neighbours (`test_retrieval_quality`, `test_chat_rag`, `test_search_recall_small_sources`) → exit 0.
- `uv run ruff check .` → **All checks passed**; `uv run pyright` (whole repo) → **0 errors**.
- **Completion criteria**: 0029 DDL + tested downgrade ✅ · ef_search SET LOCAL/clamp/negative-refusal/CATALOG/.env.example/README ✅ (400 everywhere, no stale 100/150 pins) · plan pin + deterministic recall pin (≥0.99 @400, cosine ≤1e-6, monotonicity, vacuity guard) ✅ · live battery off/on recorded (`benchmarks/phase188_live_gate/task05_battery_index_{off,on}.txt` byte-identical, `deterministic_gate_recall.txt`, build 161 s / 1 625 MB / 81 MB RSS, phase report `.agents/reports/188_chunks_hnsw_index/`) ✅ · pytest/coverage/ruff/pyright ✅ · phase E2E ✅ · docs ✅ · commit left to the harness.
- **Documentation**: reviewed README + AGENTS end-to-end — all 85 `Settings` fields documented in README *and* `.env.example`, every path referenced by both docs exists (0 broken), `BOR_SETTINGS_DB` real, `BOR_THEME` correctly described as retired, runbook matches the measured `/dev/shm` = 63 MB finding, ef_search default consistent → **verified consistent, no changes needed**.
- **Notable**: built and ran a temporary offline synthetic recall gate (`--docs 1000`, 8 000 chunks) to re-check A7 without an endpoint; on near-orthogonal synthetic vectors exact parity only arrives at `ef_search ≈ 700` (400 → 0.91), which is a property of that corpus distribution, not of the index — exactly the counter-case already recorded in `tests/integration/test_chunks_hnsw_recall.py`. Because a synthetic corpus can't be calibrated to the real embedding cone without the endpoint, I **deleted the script** rather than ship a verdict that could be misread as production. `AIPI_KEY` is absent in this environment (and `100.117.2.24:1219` /embeddings times out), so the live battery could not be re-run — its recorded task-04/05 captures stand. Scratch DB `brain_of_reese_hnswgate` dropped; repo left clean.
- **Next pending phase**: `189_chat_save_long_turns`.
This commit is contained in:
2026-10-17 03:18:23 -04:00
parent c054b47df5
commit f20d40e4fe
81 changed files with 5076 additions and 2 deletions
+185
View File
@@ -0,0 +1,185 @@
# Phase 188 task 04 — the live recall / parity gate (index OFF vs ON)
Measured on **live production** (`brain.reeseapps.com`'s database, reached at
`localhost:5432` through the dev-tunnel the app uses), on the **live embedding
endpoint**, against a restored **1,000-document live-replica KB**. Nothing here
ran against the synthetic 5,000-row fixture — that corpus belongs to task 03 and
it is not a proxy for the shapes real embeddings take.
Reproduce with `.agents/reports/188_chunks_hnsw_index/task04_recall_ab_probe.py`
(its `--help` has the four commands); the battery itself is
`uv run python -m scripts.eval_retrieval --from-file tests/fixtures/retrieval_battery.txt`.
## Prerequisites (measured, not assumed)
| Check | Value |
| --- | --- |
| `SELECT extversion FROM pg_extension WHERE extname = 'vector'` | **0.8.0** (>= 0.5.0 required; `hnsw` available) |
| `alembic current` before | head (0028), index absent |
| Replica corpus | 1,000 documents, **8,866 chunks**, 8,866 embeddings, heap **13 MB** (from a 95 MB plain-SQL dump) |
| `maintenance_work_mem` | **64 MB** (the container's default — `postgres:17` never got the phase's 512 MB, see below) |
| `/dev/shm` in `brain-of-reese_db_1` | **63 MB** |
| Planner costs | `seq_page_cost = 1`, `random_page_cost = 4` (default, SSD-class) |
| Disk free on the Postgres data volume | **2.2 TB free of 3.7 TB** (41 % used) |
| Production relation, from the 2026-10-15 incident record (not measured in this task) | 414,220 chunks, heap **3,000 MB** |
| Index after `alembic upgrade head` | `ix_chunks_embedding_hnsw` **valid**, **34 MB** on the replica |
| `alembic_version` inside the dump | **`0011`** — so `alembic upgrade head` replays **0012…0029** into the restored corpus and 0029's `CREATE INDEX CONCURRENTLY` builds against **real** embeddings. Restoring into a schema already at head would build nothing and measure nothing. |
## The battery, index OFF vs index ON (`eval_retrieval`)
`battery_index_off.txt` was captured with the index dropped
(`uv run alembic downgrade -1`), `battery_index_on.txt` with it present
(`uv run alembic upgrade head`). Both runs embed the seven battery questions
through the live endpoint.
The two captures are **byte-identical** — every `best_cosine`, every `fts_hits`,
every one of the five numbered hits per question, and every `DEFLECT`/`HIGH`
verdict. The only difference is the plan the server chose, which battery output
does not print (the deterministic probe below does).
**Why the battery alone cannot gate this.** At `ef_search = 100` its OFF and ON
captures disagreed by up to ~3e-4 on printed cosines — above this phase's 1e-4
tolerance — and the battery cannot say where that came from. The probe can: the
endpoint is deterministic (`endpoint_self_jitter`, one question embedded twice,
measured **0.000e+00**), and the tier reading shows the mechanism — at that depth the
two top-100s agree on only 0.9814 of rows on average (0.94 worst), a substituted tail
candidate shifts the RRF ranks, and since a document contributes several chunks a
printed hit can keep its document while changing its chunk, moving the printed
cosine while the top-5 list and `top_score` hold. At the shipped 400 the two
captures are byte-identical. Reading all 100 rows with the vectors pinned is what
separates the two effects. So the gate ran
a second time with the question vectors **pinned** (`embed` once, then
`measure` both sides from the cache): the 1e-4 tolerance was kept, and the input
was made identical instead of the assertion weakened (LOCKED A3).
## The deterministic gate (pinned vectors, same code path)
`deterministic_gate_compare.txt` — A = index dropped (the pre-phase behaviour:
`Limit → Sort → Hash Join → Seq Scan on chunks`), B = index present at the
shipped `ef_search`, question vectors identical on both sides:
| Check | Result |
| --- | --- |
| A8 verdicts (all 7) | **identical** — every question `HIGH (answer)`, the out-of-KB Mongolia probe included (`best_cosine=0.6070`, under the 0.62 threshold, but A8 deflects only when the cosine is under it **and** there is no full-text support — it has 5 FTS hits, unchanged by this index) |
| Top-5 document sets **and order** (all 7) | **identical** |
| Suggested documents (all 7) | **identical** |
| `top_score` / `best_cosine` delta | **0.000e+00** (gate ≤ 1e-4) |
| Cosine-tier score delta, ranks 1–5 (the fused window) | **0.000e+00** (gate ≤ 1e-4) |
| Cosine-tier top-100 **candidate-set overlap** | **1.0000 on all 7** (floor 0.99) |
| Rank swaps that are not exact cosine ties | **0** (every swap is a tie broken by access path) |
| Tier latency (median over questions) | **38.3 ms → 15.3 ms** |
| Plan, index present | `Limit → Nested Loop → Index Scan on ix_chunks_embedding_hnsw` |
`deterministic_gate_recall.txt` — the same tier forced onto the index with
`enable_indexscan = on` at each depth, recall@100 against the exact scan, on
**real embeddings**:
| `ef_search` | mean recall@100 | min recall@100 | max per-chunk cosine delta | max `top_score` delta | tier latency |
| --- | --- | --- | --- | --- | --- |
| 100 (the A7 candidate count — task 02's default) | 0.9814 | 0.94 | 2.195e-03 | **0.000e+00** | 13.8 ms |
| 200 | 1.0000 | 1.0000 | 0.0 | 0.000e+00 | 14.1 ms |
| 400 (**shipped**) | 1.0000 | 1.0000 | 0.0 | 0.000e+00 | 14.6 ms |
Two HNSW graph builds were measured (the index is dropped and rebuilt between the
OFF and ON runs). The 100-row candidate set at `ef_search = 100` came out
0.9814 mean / 0.94 min on one build and 0.9843 / 0.94 on the other; `200` came
out 1.0000 / 1.0000 and 0.9971 / 0.99; `400` came out 1.0000 / 1.0000 both times.
## Decision: `BOR_RETRIEVAL_EF_SEARCH` ships at 400
* At the task-02 default (`ef_search = 100 == LIMIT`) the **user-visible** numbers
did not move at all — verdicts, top-5 sets, suggested documents and
`top_score` were identical, to `0.000e+00`. So the task's parity gate passes at
100 and the deflection calibration was never in question.
* What moved was the **100-row candidate set** the cosine tier hands the RRF
fusion: mean 0.9814, min 0.94 — below this phase's own acceptance floor
(mean ≥ 0.99, min ≥ 0.95), and matching the counter-case task 03 recorded for
high-effective-rank data. One substitution at rank ~89 of 100 was enough to
move 22 tail ranks.
* LOCKED A3 names exactly one remedy: **raise `ef_search`** (towards 400). The
thresholds were not touched. `200` measured borderline (1.0000 on one graph,
0.9971 on another); **400 measured exact on both**, and on real embeddings it
costs **0.5 ms** (14.1 → 14.6 ms) against the depth-100 search and **23 ms**
against the sequential scan it replaces.
* The default in `app/config.py` is therefore **400**, still overridable with
`BOR_RETRIEVAL_EF_SEARCH`, and phase 188's clamp log line still fires if an
operator sets it below the candidate count.
## Production-order probe (`production_order_*.log`)
The replica's 8,866 chunks are 13 MB of heap, which makes it the wrong place to
ask "does production use the index, and what does it cost?": the relation is an
order of magnitude smaller than the one that produced the incident, and pgvector
costs an HNSW scan partly on `ef_search`, so a small-relation plan does not
transfer. A throwaway database (`bor_scale188b`, created and dropped during
this task) answered it at the production row count, using the replica's own
embeddings scaled ×47 with each copy multiplied element-wise by a random
near-one jitter vector, so all **416,702** vectors are distinct (415,950 unique)
and the heap is 557 MB:
| Measurement | Value |
| --- | --- |
| Build through the real migration (`uv run alembic upgrade head`, `maintenance_work_mem = 64 MB`) | **161 s**, peak RSS of the alembic process 81 MB |
| Index size | **1,625 MB** (vs a 557 MB heap — HNSW at `m = 16`, 768-dim float4 costs ~3.9 KB/row: 414k rows ⇒ plan for ~1.6 GB) |
| Plan at `ef_search` 100 / 200 / 400 | **HNSW index scan at every depth** (index path cost 3,333 / 5,621 / 9,806 vs a seq-scan-and-sort alternative an order of magnitude above) |
| Tier latency | **~550 ms sequential → 12.0–14.9 ms** with the index (≈ **40×**), the ~1 ms `SET LOCAL` included |
| Top-100 overlap vs the exact scan | 0.23–0.64, **not a valid recall measurement** — see the caveat below |
**Caveat, recorded because it is a limitation of this gate.** A corpus built by
jittering 8,866 real embeddings into 47 near-copies each is a pathological graph
for a top-100 recall metric: "the exact top-100" is then 100 knife-edge slots
inside a handful of near-duplicate clusters, which is not the shape of a real KB.
The numbers are reported so the latency, plan and build-cost readings at
production row count are on the record, and the **recall** verdict is taken from
the live replica, where every vector is a real, distinct chunk. After the
production build, the owner should re-run the battery on the real 414k-chunk KB
exactly as this task did on the replica; `tests/integration/test_chunks_hnsw_recall.py`
now fails (rather than passing vacuously) if a search at the shipped depth is not
actually reading the index.
## Task 05 re-verification — the battery at the shipped default, from scratch
`task05_battery_index_off.txt` / `task05_battery_index_on.txt` are a clean-room
re-run of the same gate, performed by task 05 to confirm the verdict still holds
at the shipped `BOR_RETRIEVAL_EF_SEARCH = 400` before the phase shipped. Nothing
was reused from task 04's state: the dump was restored into a **throwaway
database** (`bor_p188gate`, created and dropped inside the run, so the dev
database was never touched), which lands at `alembic_version = 0011` and was
taken to **`0028`** (index absent — the OFF arm), then to **`head`** through the
real migration (0029 built `ix_chunks_embedding_hnsw` against the 8,866 real
embeddings: **valid**, **34 MB**, ~1 s wall clock, `alembic` peak RSS **81 MB**
— the ON arm). Both arms ran
`uv run python -m scripts.eval_retrieval --from-file tests/fixtures/retrieval_battery.txt`
with live embeddings against live Postgres.
**The two captures are byte-identical** (Python string equality, not a diff
summary): all seven `best_cosine` values, all seven `fts_hits` counts, all 35
numbered hits with their scores, and all seven gate verdicts — five `HIGH`, the
Mongolia probe `HIGH` on its 5 FTS hits with `best_cosine = 0.6070` under the
0.62 threshold, and the two Besta questions behaving as out-of-KB asks on a
replica that has no IKEA document (`grep -c -i ikea` in the dump = 0, verified
before drawing any conclusion from them).
| Arm | Schema state | Index | Battery capture |
| --- | --- | --- | --- |
| OFF | `0028` | absent | `task05_battery_index_off.txt` |
| ON | `0029` (head) | valid, 34 MB | `task05_battery_index_on.txt` |
| Equality | — | — | **identical, byte for byte** |
## Finding: `maintenance_work_mem = 512MB` cannot be used in this container
```
ERROR: could not resize shared memory segment "/PostgreSQL.3366409952" to 533761504 bytes: No space left on device
```
`brain-of-reese_db_1` has a **63 MB `/dev/shm`**, so any `maintenance_work_mem`
above ~63 MB makes a parallel HNSW build die, and the failed build leaves an
**INVALID** index behind. This is a deviation from D5, which suggested
`SET maintenance_work_mem = '512MB'`: on this host the value is not applicable,
and the container's **64 MB default builds the index successfully**. The failure
was observed live at production order, and it exercised the self-heal for real:
migration 0029 detected `indisvalid = false`, logged
`dropping INVALID … rebuilding`, dropped it and rebuilt it valid. The task's
recorded command is therefore "run `alembic upgrade head` as-is"; raising
`maintenance_work_mem` is only worth doing after `/dev/shm` is raised too
(`podman run --shm-size=1g`), and a raise that fails is recoverable.