Postgres does not charge for the vectors it reads
P25 found frames/guided on a sequential scan and guessed the planner was mis-estimating how selective the policy filter is. It is not. pg_stats carries element statistics for frames.policies, and the planner's row estimate for guided is 24,982 against an actual 24,738. The estimate is right and the plan is still wrong.
What the planner gets wrong is the price. A vector(1152) is 4,616 bytes, so every vector lives in TOAST: frame_vectors' own heap is 20 MB and the vectors beside it are 1,322 MB. A sequential scan is costed on the heap, and detoasting the vectors to compute distances is costed at nothing. So the planner prices guided's scan at 11,174 while it reads 211,655 buffers, prices the index walk that reads 23,468 at 19,891, and takes the scan.
uniform is on the right side of that comparison because both prices move with how many rows the filter passes, not because anything about its own uncharged cost is different. An uncharged cost cannot move an estimate. The scan's charged part grows with the rows that pass, since the sort and the distance evaluation run over all of them, and the walk's falls, since fewer index rows are needed per output row. Read off plain EXPLAIN with one plan disabled at a time: uniform's scan is priced at 19,194 against its walk at 11,290, and guided's scan at 11,174 against its walk at 19,891.
Nobody had looked at the other two policies. scenecut and deictic were on the same plan. Three of the five calls the app can make were doing a full scan.
The fix is a fourth SET clause on frame_search, enable_seqscan = off, scoped to the function so it changes no other query on the connection. Measured on the live database, one query asked ten times, against the same endpoint but not the same cache: the before set ran on backend pid 7150 after an hour awake, the after set on pid 1198 ninety seconds after a verified resume.
| policy | frames | before | after |
|---|
frames/uniform | 239,124 | 40-47 ms | 47-52 ms |
frames/guided | 24,738 | 497-874 ms | 61-67 ms |
frames/scenecut | 22,257 | 273-286 ms | 67-73 ms |
frames/deictic | 3,881 | 91-212 ms | 143-173 ms |
frames (no policy) | 253,831 | 45-50 ms | 44-48 ms |
deictic is the one policy the clause does not move onto the index, and that is the right answer. Its HNSW path is priced at 72,350 against 20,910 for the frames_pkey plan it takes instead, because 1.5 percent of rows passing means the walk would visit most of the graph to fill 200 rows. It moves within the spread of its own readings, 143-173 ms against 91-212 ms.
Cold, on a compute verified to have actually suspended and resumed, frames/guided:
| reading | before | after |
|---|
| cold | 8,974 ms | 4,209 ms |
| warm 1 | 8,587 ms | 59 ms |
| warm 2 | 2,209 ms | 66 ms |
| warm 3 to 15 | 506-663 ms | 60-72 ms |
Both runs cleared the postmaster gate, so both cold readings are cold on evidence rather than on an idle timer. The three-reading tail the scan needed before it settled is gone: the query after the cold one is already at the warm floor.
That is one query asked sixteen times, and it is the shape that flatters an index walk most, because every reading after the first asks for a region of the graph the first one pulled in. The scan read the same 211,655 buffers whatever the vector was, so a session paid for them once. The walk reads about 23,000 blocks scattered through a 1,978 MB index that this compute's cache cannot hold, and which ones depends on the vector, so every query nobody has asked before pays for its own region as random reads from Neon's pageserver at roughly 0.2 ms a block. Six different queries on a compute that had just resumed, each asked once and then repeated:
| query | first touch | repeat |
|---|
| q010 | 4,320 ms | 65 ms |
| q020 | 6,137 ms | 79 ms |
| q030 | 5,091 ms | 94 ms |
| q040 | 4,584 ms | 270 ms |
| q050 | 5,550 ms | 72 ms |
| q060 | 4,341 ms | 97 ms |
No decay across the six, and the live route showed the same at 6,229, 4,340 and 3,339 ms. Before this change, the same six would have cost about 8,974 ms, then 8,587, then 2,209, then 506-663 ms each, because the second query needed the pages the first had already read. So per guided search on a compute that resumed at the start of the session:
| query in the session | before | after |
|---|
| 1 | 8,974 ms | 4,209 ms |
| 2 | 8,587 ms | about 5,000 ms |
| 3 | 2,209 ms | about 5,000 ms |
| 4 onward, each | 506-663 ms | about 5,000 ms |
| one already asked this session | 506-663 ms | 65-270 ms |
One search after a resume is twice as fast as it was. Six different searches in a session cost about 30 seconds of database time against about 21 before. The crossover is at three or four queries, and with autosuspend at 300 seconds the resumed compute is the ordinary state, not an edge case. What P30 has really done is trade a cost that was shared across a session for one paid per query, and nothing inside the function can bring the per-query cost down: the walk already reads the minimum it can read.
Closing that gap is a hosting decision and it is Ed's. A cron that keeps the compute from suspending would put every reader on the 65-270 ms row and would bill for every hour it keeps an idle compute awake, which is the same question docs/decisions/001 answered no to for the text tower. A higher minimum CU than 0.25 holds more of the graph between queries and bills for the floor whenever the compute is awake, and does nothing across a suspend. Prewarming the index with pg_prewarm moves the read from the reader to the resume, but it is 1,978 MB into a cache smaller than that, so it would help the first few searches and not the rest. The decision doc has what each would cost. Nobody has counted how many searches a real session on this site contains, which is the measurement that would settle it.
Then the question the eval could not be left standing without an answer to. A sequential scan returns exact nearest neighbours and an index walk returns approximate ones, so if guided was on the scan and uniform was on the index, evals/results.md was comparing two retrieval methods and calling the difference a difference between sampling policies. It was not. The per-cell knn timings already in results.json say both rows were served by the scan: guided at 311-564 ms on all 99 cells, uniform at 1,209-2,413 ms on 98 of 100, against an index plan that costs 45 ms. Both were exact.
Running all 100 ground-truth queries against both plans offline settles the rest. Every cell of the guided table comes out identical either way, splits and by-style rows included. That is a statement about what these metrics read, not about the two retrievals agreeing: every metric reads only where the ground-truth film ranks and what t came back with it. Elsewhere they differ, at 0.944 mean frame-level recall, with 9 of 100 queries returning a different top-ten film order. None of those differences moved a ground-truth film across a rank boundary on this query set. results.md now says at the table which retrieval produced it, and that the reading is an inference from timing.
The re-run is still worth doing, once this is deployed, because the published numbers were produced by a retrieval the site will no longer perform and because frames/uniform's row was taken under the plan P29 fixed. It is make eval (cd index && uv run ../evals/run.py), the three ship paths over the committed 100 rows, and it costs about 13 minutes of wall time. The zone's rate limit sets that, not the work: 300 requests paced 2.6 s apart. The tower pays 200 embeds and one cold start of about 50 s if the container has gone to sleep. The database pays about 20 seconds of query time in total at the new warm costs, and stays awake for the 13 minutes. It overwrites evals/results.json and evals/results.md, this note at the table included.
Read back from the traces on the build that shipped this entry (cce1eb6), search.knn span duration through the live route, six queries per policy that nobody had asked before, on a database that had been idle:
| policy | p50 | p95 |
|---|
| guided | 4,840 ms | 6,804 ms |
| uniform | 1,641 ms | 1,870 ms |
| deictic | 1,510 ms | 1,888 ms |
| scenecut | 598 ms | 1,392 ms |
Across all four, p50 1,641 ms and p95 6,256 ms, against p50 1,506 ms and p95 8,209 ms on the previous build, whose spans carry no policy and whose mix of policies is not known. Every one of these is a first-touch reading, which is what the novel-query table above predicts, and uniform pays its own smaller version of the same per-query cost on the live path.
A guided search nobody has run before costs 4 to 6 seconds on the live site after the database has been idle, and 65 to 270 milliseconds once it has been run. Before this it was 9 seconds for the first search of a session and half a second for every one after. The recall table stands as written, and it now says which retrieval wrote it.