Skip to content

Postgres search reads whole tables per query on large projects (admission count, FTS predicate placement, count) #1791

Closed

Description

@phernandez

Production search on large projects reads whole tables per query. On a 1 CU Neon compute those reads mostly miss the cache, so a hybrid search running alone has a p50 of 12.8 s, and under MCP fan-out searches time out at 30 s. Full evidence (EXPLAIN plans, Logfire spans, costs): https://github.com/basicmachines-co/basic-memory-cloud/issues/2610#issuecomment-6283405950

Measured on a tenant with 480k vector chunks and 95k FTS chunks, on core 91d83d6 (includes #1746). Four other large tenants show the same shape.

Defects, in priority order

  • Admission count (pgvector_index.PgVectorIndex.search, if admission is not None): SELECT count(*) FROM (... JOIN ... LIMIT :exact_cap) plans as a parallel hash join over both full tables. LIMIT cannot short-circuit the hash build. Measured on a 480k-chunk tenant: 8.2 s cold, 4.2 s warm, 318k pages. Fix ideas: decide from per-project manifest counts (or counts cached per scope and type), or count from search_vector_chunks alone with an index-only scan on (project_id, source_type, embedding_status). Acceptance: under 50 ms and under 1k pages at 480k chunks.
  • FTS predicate placement (compile_fts_filter / document_fts_vector_sql): the document-vector predicate and its per-operand EXISTS subqueries run on every row in scope, before the candidate join, because of the planner's 1-row estimate. Fix ideas: a MATERIALIZED candidate CTE joined by primary key, with operand presence computed in one grouped pass over search_index_fts_chunks restricted to candidates (bool_or(tsv @@ op_i) per search_index_id). Acceptance: pages touched scale with candidate count, not scope size.
  • Chunk candidate leg uses a seq scan. The GIN index on search_index_fts_chunks.textsearchable_index_col loses to a seq scan because TOAST is not costed. Each scan detoasts about 375 MB. Make the GIN path explicit.
  • Count: PostgresFts.count repeats the full FTS cost. Replace it with a limit+1 has-more probe, or a capped count.
  • Manifest join: the HNSW candidate path fetches fat search_vector_chunks heap rows only to check status columns. Add a covering index or move the status columns.

Test

Include a reproduction harness: 300k+ chunk fixture, cold cache (restart, or pg_prewarm off), and report EXPLAIN (ANALYZE, BUFFERS) page counts, not only timings. Warm-cache benchmarks hide all of this. The #1746 numbers were warm.

Cloud is adding a per-tenant search concurrency guard separately. Raising compute is a stopgap only: until the admission count is fixed, its 2.5 GB sweep flushes most of a larger cache.

Activity

  1. added
    cost-review-2026-10October 2026 COGS review: cost leaks, heavy tenants, and production error triage
    on Oct 20, 2026
  2. claude commented on Oct 20, 2026

    @claude
    Contributor

    Claude finished @phernandez's task in 17s —— View job


    Triage

    • Type: Enhancement (performance). The search results are correct. The cost per query is the problem.
    • Component: Database/search (Postgres FTS and pgvector). The label is cloud because the impact is on large hosted tenants.
    • Priority: High. A hybrid search takes about 12.8 s alone on a large tenant and times out at 30 s under MCP fan-out. Four other large tenants show the same shape.
    • Complexity: Complex. The work touches the pgvector admission path, FTS query compilation, the count path, and indexes (which means an Alembic migration). It also needs a cold-cache benchmark harness.

    Notes

    • No duplicates found (gh search issues).
    • The issue's priority order looks right. The admission count is the largest single cost, at about 8.2 s cold and 318k pages. It is also the cheapest to fix, either from manifest counts or from a count over search_vector_chunks alone.
    • Every acceptance criterion is measurable, so the harness the issue asks for (300k+ chunks, cold cache, EXPLAIN (ANALYZE, BUFFERS) page counts) should be written first. It would let each fix be checked against the targets.
    • Per the repo's consistency model, the FTS and admission paths read derived state. Fixes there should stay lock-free. That means no FOR UPDATE and no wider transactions.
    • Coverage must stay at 100%, and Postgres-specific tests need the testcontainers run.
    • The issue could be split into one PR per defect, in the listed order, so each improvement can be measured on its own.

    This was triage only. I made no code changes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    cloudBasic Memory Cloudcost-review-2026-10October 2026 COGS review: cost leaks, heavy tenants, and production error triageenhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions