Skip to content

perf(search): AGE edge-endpoint indexes for hybrid/age-fused graph-walk latency (Cat 7b) - #206

Merged
jphein merged 1 commit into
mainfrom
perf/7b-hybrid-latency
May 31, 2026
Merged

perf(search): AGE edge-endpoint indexes for hybrid/age-fused graph-walk latency (Cat 7b)#206
jphein merged 1 commit into
mainfrom
perf/7b-hybrid-latency

Conversation

@jphein

@jphein jphein commented May 31, 2026

Copy link
Copy Markdown
Collaborator

Cat 7b — hybrid retrieval is slow

Reported: vector p50 626ms / union 429ms / hybrid 2064ms (p95 5.6s) — hybrid ~3-5× the others.

Root cause (profiled read-only on prod familiar:8085, 2026-05-30)

The entire hybrid-vs-union delta is AGE graph-walk Cypher — not rerank (~14ms), not candidate-pool size (n_input=10 both), not the vector/BM25 fusion. union (vector ∪ BM25) is ~13× faster than hybrid at the same retrieval; the only difference is hybrid's graph candidate-merger.

Each per-entity edge lookup the hybrid merger (mempalace _graph_expand_from_*) and this daemon's /search/age-fused _age_lookup issue does a full Parallel Seq Scan of the edge backing table — MENTIONS (6.69M rows) or RELATION (1.92M rows) — because AGE only btree-indexes a label table's own id, never the start_id/end_id graphid columns the edge walks join on. EXPLAIN ANALYZE showed the full MENTIONS seq scan even when the Entity filter matched a single row (~5.8s cold for a hot entity, × N query entities). The hybrid RELATION walk is worse — the anonymous -[r:RELATION]->() target makes AGE materialize all 1.58M vertices and nested-loop, spilling to the container's 64MB /dev/shm.

Fix

POST /backfill-age/indexes — operator-triggered route that installs the four missing edge-endpoint indexes with CREATE INDEX CONCURRENTLY IF NOT EXISTS (no table lock against live reads, idempotent). Auth-gated, postgres-only (503 on chroma). Mirrors the existing /backfill-age operator pattern — no silent startup DDL on prod.

  • idx_mentions_end_id, idx_mentions_start_id
  • idx_relation_start_id, idx_relation_end_id

The Entity side is already covered by the existing idx_entity_name GIN index.

Validation (synthetic 3.17M-edge AGE graph, apache/age PG16_1.6.0, mirroring prod's index gap)

Decisive plan flip — the seq scan disappears:

BEFORE:  Parallel Seq Scan on "MENTIONS"        cost=13855  (full table)
AFTER:   Bitmap Index Scan on idx_mentions_end_id  cost=831  (matched rows only)

Cold-cache wall clock (container restarted between runs): 320ms → 206ms (1.6×) at half-prod scale. The seq scan grows linearly with the table; the index scan grows with the result set — so the delta widens at prod's 2× scale and under the cold/contended conditions that produced the observed p95 5.6s.

Route DDL verified valid + idempotent against a fresh AGE graph (all four created, re-run is a clean no-op).

Recall impact

None — these are pure read-path access-method indexes. The graph-walk returns the same drawer IDs; only the plan changes. (Observed: the graph source surfaced 0 useful candidates on most of the 12 probe queries anyway — the cost was pure waste.)

Negative results (recorded so they aren't re-tried)

  • Parallelizing the per-entity loop is slower (750ms vs 544ms serial) — the seq scans already saturate workers + I/O, so concurrency adds contention. Don't parallelize without the index in place.
  • Direct-SQL Cypher rewrite compiles to the same seq-scan plan — only the index changes the plan.

Follow-up (mempalace, separate repo — not in this PR)

  1. Index creation belongs in mempalace.backfill_age (_ensure_edge_endpoint_indexes() alongside _ensure_drawer_unique_index()). This route is the bridge until that lands.
  2. The anonymous -[r:RELATION]->() target in searcher._graph_expand_from_* should bind/drop the target so AGE stops materializing all vertices.
  3. mempalace-db /dev/shm is the Docker default 64MB — raise --shm-size (infra, separate).

Tests / lint

  • tests/test_backfill_age_indexes.py — 7 tests (create-all, idempotent-skip, partial, per-index error isolation, all-errors-500, chroma-503, DDL-completeness guard). All green via the daemon venv TestClient.
  • Full suite: 610 passed, 1 skipped. The 2 test_kg_predicate_norm.py failures are pre-existing on main (verified with this branch stashed) and untouched by this change.
  • No ruff configured for this repo; py_compile + ast.parse clean.

Full profile: docs/perf/2026-05-30-hybrid-graph-walk-latency.md. Offline SQL: scripts/age_graph_indexes.sql.

🤖 Generated with Claude Code

…h-walk latency

Cat 7b: /search/hybrid p50 was ~3-5x /search and /search/keyword (prod
familiar 2026-05-30: vector 626ms, union 429ms, hybrid 2064ms, p95 5.6s).
The entire delta is AGE graph-walk Cypher, not rerank, pool size, or the
vector/BM25 fusion. Each per-entity edge lookup the hybrid candidate-merger
and /search/age-fused's _age_lookup issue does a full Parallel Seq Scan of
the edge backing table (MENTIONS 6.69M rows, RELATION 1.92M rows) because
AGE only btree-indexes a label table's own id, never the start_id/end_id
graphid columns the walks join on. ~5.8s cold for a hot entity, x N entities.

Add POST /backfill-age/indexes: an operator-triggered route that installs the
four missing edge-endpoint indexes (idx_mentions_end_id, idx_mentions_start_id,
idx_relation_start_id, idx_relation_end_id) with CREATE INDEX CONCURRENTLY IF
NOT EXISTS — no table lock against live reads, idempotent, auth-gated,
postgres-only (503 on chroma). Mirrors the existing /backfill-age operator
pattern; no silent startup DDL.

Validated on a synthetic 3.17M-edge AGE graph (apache/age PG16_1.6.0): the
MENTIONS seq scan flips to a Bitmap Index Scan (planner cost 13855 -> 831;
cold wall-clock 320ms -> 206ms at half-prod scale, widening at prod's 2x).
Negative results recorded in the docs note: parallelizing the per-entity loop
is *slower* (contention on the seq scans), and a direct-SQL Cypher rewrite
compiles to the same seq-scan plan — only the index changes the plan.

Companion scripts/age_graph_indexes.sql for offline application; full profile
in docs/perf/2026-05-30-hybrid-graph-walk-latency.md. These indexes belong in
mempalace.backfill_age long-term (upstream follow-up); this route is the bridge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 31, 2026 00:53
@gemini-code-assist

Copy link
Copy Markdown

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request addresses significant latency issues in hybrid and age-fused graph retrieval by introducing missing edge-endpoint indexes. The current implementation of AGE graph-walks was performing full sequential scans on large edge tables for every entity lookup, leading to substantial performance degradation. By providing a safe, operator-triggered mechanism to install these indexes concurrently, the changes significantly reduce query latency and improve scalability under load.

Highlights

  • Performance Optimization: Identified and resolved a major performance bottleneck in AGE graph-walk queries where missing indexes on edge tables (MENTIONS and RELATION) caused full parallel sequential scans.
  • New API Endpoint: Added a new operator-triggered route POST /backfill-age/indexes that installs four necessary B-tree indexes using CREATE INDEX CONCURRENTLY to ensure zero downtime for live reads.
  • Validation and Safety: Implemented comprehensive tests for idempotency, error isolation, and backend-specific gating, and verified the fix on a synthetic AGE graph where the plan flipped from a full table scan to a efficient bitmap index scan.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request introduces a new operator-triggered endpoint POST /backfill-age/indexes to concurrently install missing edge-endpoint indexes on the AGE graph, resolving a significant latency bottleneck in hybrid and age-fused graph-walk queries. It also includes comprehensive performance documentation, an offline SQL script, and robust unit tests. The review feedback highlights two important improvements: first, checking indisvalid = true on pg_index to ensure that invalid indexes from previously failed concurrent builds are not incorrectly treated as already present; second, restructuring the connection lifecycle handling to prevent potential connection leaks if setting autocommit fails.

Comment thread backfill_routes.py
Comment on lines +172 to +181
def _existing_age_indexes(conn) -> set[str]:
"""Names of the edge-endpoint indexes already present on mempalace_kg.*."""
wanted = tuple(name for name, _ in _AGE_INDEX_DDL)
with conn.cursor() as cur:
cur.execute(
"SELECT indexname FROM pg_indexes "
"WHERE schemaname = 'mempalace_kg' AND indexname = ANY(%s)",
(list(wanted),),
)
return {r[0] for r in cur.fetchall()}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

In PostgreSQL, if a CREATE INDEX CONCURRENTLY statement fails, it leaves behind an INVALID index in the catalog. The pg_indexes view includes invalid indexes, meaning that if a previous index creation failed, _existing_age_indexes will find the invalid index, report it as already_present, and skip recreating it. This results in a silent performance degradation because the query planner cannot use invalid indexes.

To prevent this, query pg_index directly and check indisvalid = true to ensure only valid indexes are considered present. If an invalid index exists, the route will attempt to recreate it and fail with a clear error (e.g., relation already exists), alerting the operator to drop the invalid index.

def _existing_age_indexes(conn) -> set[str]:
    """Names of the valid edge-endpoint indexes already present on mempalace_kg.*."""
    wanted = tuple(name for name, _ in _AGE_INDEX_DDL)
    with conn.cursor() as cur:
        cur.execute(
            "SELECT c.relname "
            "FROM pg_index i "
            "JOIN pg_class c ON c.oid = i.indexrelid "
            "JOIN pg_namespace n ON n.oid = c.relnamespace "
            "WHERE n.nspname = 'mempalace_kg' "
            "  AND c.relname = ANY(%s) "
            "  AND i.indisvalid = true",
            (list(wanted),),
        )
        return {r[0] for r in cur.fetchall()}

Comment thread backfill_routes.py
Comment on lines +225 to +254
try:
conn = psycopg2.connect(dsn, connect_timeout=5)
except psycopg2.OperationalError as e:
_record_db_error(e)
raise
# CONCURRENTLY forbids an open transaction; autocommit each DDL.
conn.autocommit = True
try:
with conn.cursor() as cur:
cur.execute("LOAD 'age'")
cur.execute('SET search_path = ag_catalog, "$user", public')
present = _existing_age_indexes(conn)
for name, ddl in _AGE_INDEX_DDL:
if name in present:
already.append(name)
continue
try:
with conn.cursor() as cur:
cur.execute(ddl)
created.append(name)
except Exception as e: # one bad index shouldn't sink the rest
errors[name] = f"{type(e).__name__}: {e}"
logging.getLogger("palace-daemon").warning(
"backfill-age/indexes: %s failed: %s", name, e
)
finally:
try:
conn.close()
except Exception:
pass

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

If psycopg2.connect succeeds but setting conn.autocommit = True raises an exception, the connection is leaked because the second try block is never entered, bypassing the finally block.

Initializing conn = None and wrapping the entire connection setup and execution in a single try...finally block ensures that the connection is always closed cleanly if it was successfully opened.

        conn = None
        try:
            try:
                conn = psycopg2.connect(dsn, connect_timeout=5)
            except psycopg2.OperationalError as e:
                _record_db_error(e)
                raise
            # CONCURRENTLY forbids an open transaction; autocommit each DDL.
            conn.autocommit = True
            with conn.cursor() as cur:
                cur.execute("LOAD 'age'")
                cur.execute('SET search_path = ag_catalog, "$user", public')
            present = _existing_age_indexes(conn)
            for name, ddl in _AGE_INDEX_DDL:
                if name in present:
                    already.append(name)
                    continue
                try:
                    with conn.cursor() as cur:
                        cur.execute(ddl)
                    created.append(name)
                except Exception as e:  # one bad index shouldn't sink the rest
                    errors[name] = f"{type(e).__name__}: {e}"
                    logging.getLogger("palace-daemon").warning(
                        "backfill-age/indexes: %s failed: %s", name, e
                    )
        finally:
            if conn is not None:
                try:
                    conn.close()
                except Exception:
                    pass

Copilot AI 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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@jphein
jphein merged commit c8df975 into main May 31, 2026
1 check failed
@jphein
jphein deleted the perf/7b-hybrid-latency branch May 31, 2026 00:56
jphein added a commit that referenced this pull request May 31, 2026
Two correctness improvements from the PR #206 review:

1. Presence probe now checks pg_index.indisvalid instead of the pg_indexes
   view. A CREATE INDEX CONCURRENTLY that fails partway leaves an INVALID
   index in the catalog — pg_indexes lists it but the planner can't use it.
   Counting it as already_present would silently skip the rebuild and leave
   the latency bug unfixed. Filtering on indisvalid means an invalid index
   reads as absent, so the route re-attempts it (and the rebuild's clear
   "already exists" error alerts the operator to DROP the invalid one).

2. Connection-leak hardening: init conn=None and wrap the whole connect +
   autocommit + execute sequence in a single try/finally. Previously if
   connect() succeeded but `conn.autocommit = True` raised, the finally
   (in a separate nested try) was never entered and the connection leaked.

New tests: probe queries pg_index/indisvalid (not the view); connection is
closed even when setting autocommit raises. indisvalid probe SQL verified
valid against a real apache/age PG16 graph. 9/9 index-route tests green.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

2 participants