Skip to content

perf(core): rank vector search rows in the database and count search bytes - #2074

Merged
phernandez merged 4 commits into
mainfrom
search-traffic-1746
Nov 7, 2026
Merged

phernandez merged 4 commits into
mainfrom
search-traffic-1746

Conversation

@phernandez

Copy link
Copy Markdown
Member

Why

Basic Memory Cloud bills Neon transfer to customers as of 2026-11-07, and search is most of that transfer (#1746). Cloud already runs #1755 (late text fetch) and #2062, yet production still moves about 18 MB per search.

A new byte counter shows that most of what a vector or hybrid search still reads is chunk keys, not note text:

  • Excerpts. To pick each returned row's best passages, search read every chunk key of every returned note.
  • Vector window. Filtered searches (every default MCP search) widened the window until enough rows appeared, re-reading thousands of chunk rows. On a large project that loop runs up to 50,000 chunks.

What

  • Row search in the index. pgvector and sqlite-vec get search_rows. The database ranks chunks, keeps each search row's best chunks_per_row, and returns at most limit rows. The collapse SQL is shared by both engines (collapse_to_rows_sql).
  • One read per window. A built-in index reads its window once: no widening loop and no manifest hydration statement, because the index already joins the ready manifest.
  • Smaller scans.
    • An approximate scan (pgvector HNSW, sqlite-vec) reads 10 chunks per requested row.
    • pgvector's exact ranking of 50,000 or fewer admitted chunks reads them all, as before.
    • On SQLite the scan is capped at 4,096 chunks (the issue's "one bounded overfetch").
  • Excerpts. One count query per page, then a row search with chunks_per_row=5 over the returned rows.
  • Milvus keeps the chunk path and its widening loop.
  • Ranking. Rows still rank by their best chunk's similarity, and fusion is unchanged. The vector leg now hands fusion up to candidate_limit rows instead of candidate_limit chunks.

Measuring it

  • tests/statement_traffic.py records rows and bytes returned per statement.
  • test-int/test_search_traffic.py runs searches through the HTTP route over 40 KB notes (production's average is 41 KB). Its regular test asserts a byte budget and at most two nearest-neighbour queries per search.
  • test_benchmark_search_traffic (-m benchmark) prints per-statement bytes. BASIC_MEMORY_SEARCH_TRAFFIC_NOTES sets its size.

Bytes per search, page of 10, main (7f7a219) → this branch. Each note is cut into about 129 chunks.

backend, notes fts vector hybrid hybrid, no filters
SQLite, 300 48 → 48 KB 379 → 115 KB 398 → 135 KB 398 → 135 KB
Postgres, 100 47 → 47 KB 367 → 104 KB 381 → 118 KB 381 → 118 KB

Rows per vector search fell from about 3,200 to about 350–500. On Postgres I used 100 notes because seeding takes about 9 s per note; the 300-note Postgres baseline timed out after 190.

What a 300-note search still reads:

bytes what note
43 KB the page's content (4,000 chars per row) public response contract
34 KB excerpts 5 chunks × 10 rows
20 KB title, path and metadata for the 100-row window could be deferred to the returned page; follow-up
12–18 KB the window itself

The fixture cannot show the production-scale gain. There, the widening loop read up to 50,000 chunk rows per round across several rounds; it now reads one window of rows.

Latency. Indicative only: every timed run shared the machine with the test suite.

  • Hybrid on Postgres was 414–668 ms on this branch, against 429–1,264 ms on main.
  • Vector-only on Postgres was 665–1,517 ms on this branch, against 419–994 ms on main.

One plan change to watch: the HNSW path (more than 50,000 admitted chunks, or no filters) now reads up to 10 chunks per requested row, 1,000 at the default 100 rows, where main read 100 chunks per round. I could not exercise it at that size locally.

Tests

  • New: test_search_rows_collapses_chunks_to_their_nearest_rows (real sqlite-vec), pgvector unit tests for the exact and HNSW plans, a reader trace test for row matches, and the traffic test on both backends.
  • Updated: tests that pinned the old chunk window (test_a_built_in_index_reads_its_row_window_once replaces the long-notes widening test).
  • Verification:
    • just fast-check clean.
    • just fast-test: 6,881 passed.
    • tests/repository plus the traffic test pass on SQLite; the traffic test passes on Postgres.
    • All four changed modules: every line this change adds is covered.

Not in this PR

  • Deferring candidate row fields (title, metadata) to the returned page.
  • Trimming content for vector-matched rows (a response change).
  • RRF or other ranking changes.
  • Milvus.

Refs #1746

🤖 Generated with Claude Code

https://claude.ai/code/session_01CMYVL4TcbWdXhdvAzxfqjo

…bytes

Built-in vector indexes (pgvector, sqlite-vec) now rank chunks, collapse
them to their nearest search rows, and return at most `limit` rows in one
query. A filtered window is read once: no widening loop, no manifest
hydration statement. Excerpts count each returned row's chunks, then read
only its best five chunk keys instead of every chunk key of the note.

tests/statement_traffic.py records rows and bytes per statement.
test-int/test_search_traffic.py runs searches through the HTTP route over
40 KB notes, asserts a byte budget and at most two nearest-neighbour
queries per search, and has a benchmark that reports per-statement bytes.

Refs #1746

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CMYVL4TcbWdXhdvAzxfqjo
Signed-off-by: phernandez <paul@basicmachines.co>
@phernandez

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-07T20:05:17.371965Z 44174d7 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 31f709f61d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1332 to 1334
not self.vector.external
or not query.has_filters
or window.admitted >= row_target

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve row-target filling for truncated built-in scans

For filtered SQLite searches, returning rows does not mean the underlying chunk scan is exhausted: search_rows() collapses only the nearest min(row_limit * 10, 4096) chunks. With the default semantic_vector_k=100, five matching notes containing 300 chunks each, and chunks clustered by note similarity, a five-result request scans 1,000 chunks and returns only four notes. This unconditional exit then hides the fifth matching note, whereas the previous filtered loop widened to 1,600 chunks and returned all five. Large PostgreSQL admissions using HNSW have the same limitation. Preserve a way to fill row_target when the underlying chunk scan was truncated, rather than treating every short built-in row window as the complete answer.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8fc72f1. search_rows now returns VectorRowSearch(matches, truncated). The collapse query counts its candidates, and truncated means the scan read every chunk it was allowed. While a truncated scan has fewer than row_target rows, the reader doubles chunk_scan up to the index's max_chunk_scan (4,096 for sqlite-vec, 50,000 for pgvector HNSW). An exact pgvector ranking is never truncated. Each round still returns at most row_limit rows. Regression tests: test_a_truncated_row_scan_widens_until_the_page_fills (your five notes of 300 chunks: scans 1,000, then 2,000 chunks, returns all five) and test_a_row_scan_stops_at_the_index_limit_or_when_it_saw_every_chunk; the real sqlite-vec test checks the flag.

A built-in row search collapses only the nearest `chunk_scan` chunks. When
matching notes hold hundreds of chunks each, that scan can reach fewer rows
than the page needs while more admitted rows sit past it, and the reader
treated the short window as the whole answer.

`search_rows` now returns `VectorRowSearch(matches, truncated)`: the scan
counts its candidates, and `truncated` means it read as many chunks as it
was allowed. While a truncated scan returns fewer than `row_target` rows,
the reader doubles the chunk scan up to the index's `max_chunk_scan`
(4,096 on sqlite-vec, 50,000 on pgvector HNSW). An exact ranking is never
truncated. Each round still returns at most `row_limit` rows, not chunk keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CMYVL4TcbWdXhdvAzxfqjo
Signed-off-by: phernandez <paul@basicmachines.co>
@phernandez

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: 8fc72f1cd0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

The per-search `search.vector_rows` log event is gone. Rounds, chunk scan,
rows, row target and truncation are attributes on the one
`search.vector_query` span every vector search already writes, since
Logfire bills per record. The span is now opened explicitly so the row
scan can set them; the external-index overfetch loop moved into
`_run_external_query` unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CMYVL4TcbWdXhdvAzxfqjo
Signed-off-by: phernandez <paul@basicmachines.co>
@phernandez

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 49f9f850b2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +623 to +626
while (
search.truncated
and len(search.matches) < row_target
and chunk_scan < index.max_chunk_scan

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the hybrid rerank prefix fixed across row-scan sizes

For unfiltered hybrid searches with reranking and long multi-chunk notes, stopping at row_target can change the reranked prefix between pages. _prefix_rows() still uses _rerank_candidate_limit() as its position bound, but positions now count rows rather than chunks, so a deeper scan can add vector evidence inside that supposedly fixed prefix. With vector_k=8, two rerank candidates, and 40 clustered chunks per note, a two-result first page scans 80 chunks, while the next page scans 280. Reproducing this through the hybrid pipeline produced rerank pools [100, 1] and [3, 100], returning pages [100, 1] and [1, 2]: note 1 repeats and note 3 is skipped. Preserve fixed hybrid-prefix membership when switching from chunk windows to row windows.

Useful? React with 👍 / 👎.

`search.vector_window` and `search.materialize` were log records written
on every search. Each is now a span around its unit of work with the
outcome as attributes, so search writes no per-search log records. The
window carries its read count so the span can report rounds. Drops the
telemetry-shape test added with the row scan span.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CMYVL4TcbWdXhdvAzxfqjo
Signed-off-by: phernandez <paul@basicmachines.co>
@phernandez

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Breezy!

Reviewed commit: 44174d70b1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@phernandez
phernandez merged commit 41df3d6 into main Nov 7, 2026
51 checks passed
@phernandez
phernandez deleted the search-traffic-1746 branch November 7, 2026 20:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant