---
layout: default
title: SQL API
nav_order: 5
---

# SQL API Reference

## Compaction

### `sorted_heap_compact(regclass)`

Rewrites the table in globally sorted PK order and rebuilds the zone map.
Acquires `AccessExclusiveLock`.

```sql
SELECT sorted_heap_compact('events'::regclass);
```

### `sorted_heap_compact_online(regclass)`

Non-blocking compaction using trigger-based change capture. Concurrent
reads and writes continue during the operation.

```sql
CALL sorted_heap_compact_online('events'::regclass);
```

### `sorted_heap_merge(regclass)`

Incremental merge: detects the already-sorted prefix and only re-sorts the
unsorted tail. 50--90% faster than full compact when data is partially sorted.
Acquires `AccessExclusiveLock`.

```sql
SELECT sorted_heap_merge('events'::regclass);
```

### `sorted_heap_merge_online(regclass)`

Non-blocking variant of merge with the same three-phase approach as
`sorted_heap_compact_online`.

```sql
CALL sorted_heap_merge_online('events'::regclass);
```

### `sorted_heap_bulk_load_ordered(regclass, text, text, boolean default false, name[] default NULL)`

Explicit trusted-operator bulk ingestion helper. It inserts rows from
`source_sql` into a concrete `sorted_heap` or `clustered_heap` table after
ordering the source by `order_by`.

```sql
SELECT sorted_heap_bulk_load_ordered(
    'events'::regclass,
    'SELECT id, payload FROM staging_events',
    'id',
    analyze_after := true,
    key_columns := ARRAY['id']::name[]);
```

This is an ingestion-locality helper, not a merge/compaction input. `source_sql`
and `order_by` are dynamic SQL fragments and should be supplied only by trusted
operators or migration code. The source query must return the target table
columns in target order.

The helper records an observational append-run witness. Inspect it with:

```sql
SELECT *
FROM sorted_heap_append_run_status('events'::regclass);

SELECT *
FROM sorted_heap_append_run_plan('events'::regclass);
```

`is_current = false` means the relation filenode changed or the witness was
otherwise marked invalid. Current releases do not consume these witnesses for
merge/compaction. The witness includes the inserted heap block range, row count,
rows per touched heap block, and first/last source rows under the requested
ordering. If `key_columns` is supplied, the witness also includes normalized
`bigint[]` first/last keys for one or two integer key columns.

Use `sorted_heap_append_run_invalidate(...)` to mark witnesses invalid and
`sorted_heap_append_run_cleanup(...)` to delete invalid, relfilenode-stale, or
relation-orphaned rows.

### Partition-scoped maintenance

For declarative partitioned tables, use the explicit parent helpers instead of
calling the concrete-table functions on the parent. They recurse to concrete
leaves and operate one leaf at a time.

```sql
SELECT *
FROM sorted_heap_partition_status('events_parent'::regclass);

SELECT *
FROM sorted_heap_partition_index_status('events_parent'::regclass);

SELECT *
FROM sorted_heap_compact_partitions('events_parent'::regclass);

SELECT *
FROM sorted_heap_merge_partitions('events_parent'::regclass);

SELECT *
FROM sorted_heap_rebuild_zonemap_partitions('events_parent'::regclass);
```

Before running a maintenance operation on a large partition tree, use the
read-only plan helper:

```sql
SELECT *
FROM sorted_heap_partition_maintenance_plan(
    'events_parent'::regclass,
    operation := 'compact');
```

The plan returns one row per concrete leaf with:

- `status = 'would_run'` for supported sorted_heap leaves with a primary key;
- `status = 'blocked'` for unsupported leaves or sorted_heap leaves without a
  primary key;
- `lock_mode`, currently `AccessExclusiveLock` for the concrete operation;
- `tablespace_oid`, `tablespace_name`, and `tablespace_location`, so operators
  can join the rewrite estimate to their tablespace or filesystem monitoring;
- `relation_size_bytes` and `estimated_temp_bytes`. For `compact` and `merge`,
  the temp estimate is the current leaf size; for `rebuild_zonemap`, heap
  rewrite temp is `0`.

By default, maintenance helpers fail before doing work if any leaf is not a
`sorted_heap` table or if a sorted_heap leaf has no primary key. Pass `false`
as the second argument to explicitly skip unsupported leaves:

```sql
SELECT *
FROM sorted_heap_compact_partitions('events_parent'::regclass, false);
```

Locking and disk-space model:

- `sorted_heap_compact_partitions(...)` calls `sorted_heap_compact(...)` on each
  leaf, so each processed leaf takes `AccessExclusiveLock` while it is
  rewritten.
- `sorted_heap_merge_partitions(...)` calls `sorted_heap_merge(...)` on each
  leaf and has the same lock class.
- The rewrite needs temporary disk headroom for the leaf being processed, not
  for the whole logical parent at once.
- PostgreSQL does not expose a portable SQL-level free-space metric; use the
  reported tablespace fields with OS or platform monitoring when checking
  available bytes.
- These helpers are not atomic across all leaves. If a later leaf fails,
  already processed leaves remain processed.
- See [Huge-Table Compaction Operating Model](spec-huge-table-compaction) for
  the detailed rewrite/free-space contract.

`sorted_heap_partition_index_status(parent)` returns one row per leaf index
for a partitioned parent or concrete sorted_heap table. It reports the index AM,
valid/ready/live flags, primary/unique flags, and convenience booleans for
`sorted_hnsw` and btree indexes. Leaves without indexes are still represented
with `NULL` index fields, so the output can be used as a health checklist.

Manual lock-behavior smoke:

```bash
make test-partition-lock
```

### Partition-aware vector search

Use `sorted_hnsw_partition_search(...)` when vector search should run across a
partitioned parent or an explicit subset of leaves:

```sql
SELECT leaf_relid, distance, row_data
FROM sorted_hnsw_partition_search(
    'documents_parent'::regclass,
    'embedding',
    '[0.1,0.2,0.3,...]',
    top_k := 10,
    local_k := 32,
    leaf_relids := ARRAY['documents_2026_05'::regclass]);
```

Contract:

- each selected leaf runs local `ORDER BY embedding <=> query LIMIT local_k`;
- the helper unions local candidate pools and globally reranks by exact
  distance;
- `local_k` must be at least `top_k`, otherwise a dense leaf could contribute
  more true global winners than the local pool preserves;
- `local_k` must be no larger than `sorted_hnsw.ef_search`, so each local scan
  remains inside the planner-integrated `sorted_hnsw` contract;
- each selected sorted_heap leaf must have a valid, ready, non-expression,
  non-partial leaf-local `sorted_hnsw` index on `vector_column`; the helper
  fails closed instead of silently falling back to exact sort;
- every explicit `leaf_relids` entry must be a concrete leaf under `parent`;
  wrong-parent relations are rejected instead of being ignored;
- `leaf_relids` is optional. When omitted, all supported sorted_heap leaves
  under the parent participate.

The query argument is text and is cast to the actual vector type of each leaf
column (`svec` or `hsvec`). The result is intentionally generic:
`row_data jsonb` carries the source row because partitioned parents can have
arbitrary table shapes.

Use `sorted_hnsw_partition_search_status(...)` when the caller needs explicit
underfill metadata for the same routed search contract:

```sql
SELECT *
FROM sorted_hnsw_partition_search_status(
    'documents_parent'::regclass,
    'embedding',
    '[0.1,0.2,0.3,...]',
    top_k := 10,
    local_k := 32,
    leaf_relids := ARRAY['documents_2026_05'::regclass]);
```

Returned fields:

- `requested_top_k`: requested final result count
- `effective_local_k`: per-leaf candidate budget after applying the default
- `selected_leaves`: supported sorted_heap leaves participating in the call
- `returned_rows`: rows returned by the routed search
- `underfilled`: true when `returned_rows < requested_top_k`
- `fallback`: `none`, `underfilled_no_fallback`, or `exact_filtered` when
  `exact_fallback := true` was requested and the ANN candidate pool underfilled
  before the exact selected-leaf pass

By default, the status helper is diagnostic and does not change the row-returning
search API. If callers explicitly pass `exact_fallback := true` to
`sorted_hnsw_partition_search(...)` or
`sorted_hnsw_partition_search_status(...)`, an underfilled ANN candidate pool is
replaced with an exact rerank over the same selected leaves. This fallback is
bounded by shard selection, not by arbitrary base-table `WHERE` clauses.

---

## Zone map

### `sorted_heap_zonemap_stats(regclass)`

Returns a text summary of the zone map: format version, number of entries,
validity flags, and overflow page chain.

```sql
SELECT sorted_heap_zonemap_stats('events'::regclass);
```

Example output:

```
v6 nentries=1946 flags=valid,sorted overflow_pages=7
```

### `sorted_heap_zonemap_may_match_int8(regclass, bigint, bigint)`

Returns `false` only when valid zone-map metadata proves that the first-key
`int8` range cannot match any tracked heap page. It returns `true` when the
range may match, and also fail-opens to `true` when metadata is stale or the
table/key shape is unsupported.

This is a metadata-only empty-result probe. It does not return rows and does
not bypass heap visibility or executor quals.

```sql
SELECT sorted_heap_zonemap_may_match_int8('events'::regclass, 100, 200);
```

### `sorted_heap_rebuild_zonemap(regclass)`

Forces a full zone map rebuild by scanning all tuples. Useful after
bulk operations that invalidate the zone map.

```sql
SELECT sorted_heap_rebuild_zonemap('events'::regclass);
```

---

## Monitoring

### `sorted_heap_scan_stats()`

Returns scan statistics as a record: total scans, blocks scanned, blocks
pruned, and stats source (shared memory or per-backend).

```sql
SELECT * FROM sorted_heap_scan_stats();
```

```
 total_scans | blocks_scanned | blocks_pruned | source
-------------+----------------+---------------+---------
         142 |           284  |        276012 | shmem
```

These counters are not keyed by relation. For partitioned deployments, use
`sorted_heap_partition_status(...)` and
`sorted_heap_partition_index_status(...)` for per-leaf state; do not interpret
`sorted_heap_scan_stats()` as parent-level runtime telemetry. The future
runtime-observability contract is tracked in
[Parent Runtime Observability](spec-parent-runtime-observability).

### `sorted_heap_scan_stats_by_relation()`

Returns scan statistics keyed by concrete relation:

```sql
SELECT *
FROM sorted_heap_scan_stats_by_relation();
```

Returned fields:

- `relid`: concrete relation that executed `SortedHeapScan`
- `relname`: current relation name, or `NULL` if the relation was dropped after
  the counter was recorded
- `total_scans`: number of `SortedHeapScan` executions for this relation
- `blocks_scanned`: heap blocks visited by those scans
- `blocks_pruned`: heap blocks skipped by zone-map pruning
- `source`: `shared` when `pg_sorted_heap` is loaded through
  `shared_preload_libraries`, otherwise `local`

This is the first relation-aware runtime surface. Without preload it is
backend-local and useful for same-backend diagnostics. With
`shared_preload_libraries = 'pg_sorted_heap'`, it is cluster-wide shared
telemetry. In both modes it is reset by `sorted_heap_reset_stats()`.
The shared relation table tracks up to 4,096 concrete relations per reset
window; aggregate `sorted_heap_scan_stats()` counters continue to count all
scans even if that relation table is exhausted.

### `sorted_heap_partition_scan_stats(parent)`

Returns scan statistics for sorted_heap leaves under a partitioned parent or
concrete sorted_heap table:

```sql
SELECT *
FROM sorted_heap_partition_scan_stats('events_parent'::regclass);
```

The helper joins `sorted_heap_partition_status(parent)` to
`sorted_heap_scan_stats_by_relation()`. It returns one row per sorted_heap leaf,
with zero counters for leaves that have not executed `SortedHeapScan` in the
current stats window. `source` follows `sorted_heap_scan_stats_by_relation()`:
`shared` with preload, otherwise `local`.

### `sorted_heap_restore_plan(parent default NULL)`

Returns a post-restore maintenance checklist for sorted_heap relations:

```sql
SELECT *
FROM sorted_heap_restore_plan();

SELECT *
FROM sorted_heap_restore_plan('events_parent'::regclass);
```

When called without an argument, it scans all concrete sorted_heap relations in
the database. With a concrete sorted_heap table or partitioned parent argument,
it reports only that table or its sorted_heap leaves.

Returned fields include:

- `zone_map_valid`, `zone_map_sorted`, and `sorted_prefix_pages`
- `sorted_hnsw_indexes`
- `needs_compact`: true when compact/merge is needed to restore zone-map
  pruning
- `post_restore_hnsw_rebuild_recommended`: true when sorted_hnsw indexes exist;
  after `pg_restore`, rebuild them because heap TIDs changed
- `recommended_action`: `none`, `compact_or_merge`,
  `rebuild_sorted_hnsw_after_pg_restore`, or
  `compact_or_merge_and_rebuild_sorted_hnsw`

### `sorted_heap_reset_stats()`

Resets the scan statistics counters.

```sql
SELECT sorted_heap_reset_stats();
```

---

## Trigger

### `sorted_heap_compact_trigger()`

A trigger function used internally by the online compact/merge procedures
to capture changes during non-blocking operations. Not intended for
direct use.

---

## Scan pruning

### IN / ANY support

SortedHeapScan prunes blocks for `IN` and `= ANY(array)` queries on the
leading primary key column. Each block's zone map entry is checked against
the sorted list of target values using O(log K) binary search — blocks that
contain no matching values are skipped entirely.

Both literal arrays and parameterized arrays (generic prepared statements)
are supported, including LATERAL/NestLoop runtime parameters.

```sql
-- Literal IN-list — pruned at plan time
SELECT * FROM events WHERE id IN (100, 200, 300);

-- Literal ANY — same pruning
SELECT * FROM events WHERE id = ANY(ARRAY[100, 200, 300]);

-- Generic prepared statement — pruned at execution time
PREPARE q(int[]) AS SELECT * FROM events WHERE id = ANY($1);
SET plan_cache_mode = force_generic_plan;
EXECUTE q(ARRAY[100, 200, 300]);

-- LATERAL join with runtime array — pruned per outer row
SELECT o.i, s.cnt
FROM (SELECT g AS i, make_arr(g) AS arr FROM generate_series(1,10) g) o
CROSS JOIN LATERAL (
    SELECT count(*) AS cnt FROM events WHERE id = ANY(o.arr)
) s;
```

The scan computes a bounding box (min/max of the array values) to limit the
block range, then applies per-block IN-value filtering within that range.

For LATERAL/NestLoop joins, runtime array parameters (`PARAM_EXEC`) are
resolved at first rescan when outer values become available.

---

## Configuration (GUCs)

### `sorted_heap.enable_scan_pruning`

| Property | Value |
|----------|-------|
| Type | boolean |
| Default | `on` |
| Context | user (SET) |

Enables or disables zone map scan pruning for sorted_heap tables. When
disabled, queries fall back to sequential scan.

```sql
SET sorted_heap.enable_scan_pruning = off;
```

### `sorted_heap.vacuum_rebuild_zonemap`

| Property | Value |
|----------|-------|
| Type | boolean |
| Default | `on` |
| Context | user (SET) |

When enabled, VACUUM automatically rebuilds an invalid zone map, re-enabling
scan pruning without a manual compact step.

```sql
SET sorted_heap.vacuum_rebuild_zonemap = off;
```

### `sorted_heap.lazy_update`

| Property | Value |
|----------|-------|
| Type | boolean |
| Default | `off` |
| Context | user (SET, SET LOCAL, ALTER SYSTEM) |

When enabled, the first UPDATE on a covered page invalidates the zone map on
disk. All subsequent UPDATEs skip zone map maintenance entirely. The planner
falls back to Index Scan. INSERT always uses eager maintenance regardless.
Compact or merge restores zone map pruning.

```sql
-- Per session
SET sorted_heap.lazy_update = on;

-- Per transaction
SET LOCAL sorted_heap.lazy_update = on;

-- Globally
ALTER SYSTEM SET sorted_heap.lazy_update = on;
SELECT pg_reload_conf();
```

### `sorted_heap.hnsw_cache_l0`

| Property | Value |
|----------|-------|
| Type | boolean |
| Default | `off` |
| Context | user (SET) |

Enables session-local cache for HNSW sidecar tables. On first
`svec_hnsw_scan` call, L0 is loaded via sequential scan (~95ms build,
~100 MB for 100K nodes). Upper levels (L1--L4) cached separately (~6 MB).
Cache is evicted on DDL (relcache invalidation).

```sql
SET sorted_heap.hnsw_cache_l0 = on;
```

### `sorted_heap.hnsw_ef_patience`

| Property | Value |
|----------|-------|
| Type | integer |
| Default | `0` (disabled) |
| Context | user (SET) |

Patience-based early termination for L0 beam search. Applies to both
`svec_hnsw_scan()` and `sorted_hnsw` ordered index scans. When set to N > 0,
the search stops after N consecutive expansions that don't improve the result
set. `ef_search` becomes the maximum budget.

```sql
SET sorted_heap.hnsw_ef_patience = 20;
```

### `sorted_hnsw.ef_search`

| Property | Value |
|----------|-------|
| Type | integer |
| Default | `96` |
| Context | user (SET) |

Beam width for planner-integrated `sorted_hnsw` ordered index scans.
Higher values increase candidate exploration and usually improve recall at the
cost of latency.

```sql
SET sorted_hnsw.ef_search = 128;
```

### `sorted_hnsw.sq8`

| Property | Value |
|----------|-------|
| Type | boolean |
| Default | `on` |
| Context | user (SET) |

Controls SQ8 quantization in `sorted_hnsw` L0 storage and scan distance
evaluation. Leave this enabled for the current release path unless you are
doing low-level experiments.

```sql
SET sorted_hnsw.sq8 = on;
```

### `sorted_hnsw.build_sq8`

| Property | Value |
|----------|-------|
| Type | boolean |
| Default | `off` |
| Context | user (SET) |

Builds `sorted_hnsw` from SQ8-compressed build vectors instead of keeping a
full float32 build slab resident. This lowers build-time memory materially for
large indexes at the cost of an extra heap scan and possible graph-quality
loss on some corpora.

The narrow verified point so far:

- local `1M x 64D` multidepth build (`m=16`, `ef_construction=64`)
- `build_indexes`: `48.606 s -> 46.541 s`
- depth-5 unified GraphRAG stayed `87.5% / 100.0%`

The memory saving is by construction:

- float32 build slab: `4 * N * D` bytes
- SQ8 build slab: `1 * N * D` bytes

So, for example:

- `10M x 64D`: about `2.56 GiB -> 0.64 GiB`
- `10M x 384D`: about `15.36 GiB -> 3.84 GiB`

```sql
SET sorted_hnsw.build_sq8 = on;
```

### `sorted_hnsw.shared_cache`

| Property | Value |
|----------|-------|
| Type | boolean |
| Default | `on` |
| Context | user (SET) |

Enables the preloaded shared decoded scan cache for ordered `sorted_hnsw`
index scans. Effective only when `pg_sorted_heap` is loaded via
`shared_preload_libraries`; otherwise scans fall back to backend-local cache
builds. When active, fresh backends can attach to a shared immutable snapshot
keyed by `{relid, relfilenode, cache_gen}` instead of rebuilding the decoded
graph privately.

```sql
SET sorted_hnsw.shared_cache = on;
```

### `sorted_hnsw_scan_stats()`

Backend-local scan diagnostics for planner-integrated `sorted_hnsw` ordered
index scans. Use this while tuning or benchmarking to distinguish normal ANN
latency from the fail-closed exact heap fallback used when ANN/top-up rerank
underfills the configured `sorted_hnsw.ef_search` budget.

```sql
SELECT sorted_hnsw_reset_stats();

SELECT id
FROM items
ORDER BY embedding <=> '[0.1,0.2,0.3,...]'::svec
LIMIT 10;

SELECT sorted_hnsw_scan_stats();
```

The text output includes cumulative `calls`, `l0_searches`,
`topup_searches`, `exact_fallbacks`, and last-scan fields such as `ef`,
`nodes`, `l0_candidates`, `initial_results`, `topup_results`,
`fallback_results`, `final_results`, and `exact_fallback`.

### `sorted_heap.ann_timing`

| Property | Value |
|----------|-------|
| Type | boolean |
| Default | `off` |
| Context | user (SET) |

Enables per-query timing breakdown for `svec_ann_scan`, `svec_graph_scan`,
and `svec_hnsw_scan`. Output is emitted at `DEBUG1` log level.

```sql
SET sorted_heap.ann_timing = on;
SET client_min_messages = debug1;
```

---

## Vector search

See the [Vector Search guide](vector-search) for a full tutorial.

### `sorted_hnsw` Index AM

The stable ANN path in this release is the planner-integrated HNSW access
method. It supports both `svec` and `hsvec`.

```sql
CREATE TABLE items (
    id        bigserial PRIMARY KEY,
    embedding svec(384),
    body      text
);

CREATE INDEX items_embedding_idx
ON items USING sorted_hnsw (embedding)
WITH (m = 16, ef_construction = 200);

SET sorted_hnsw.ef_search = 32;

SELECT id, body
FROM items
ORDER BY embedding <=> '[0.1,0.2,0.3,...]'::svec
LIMIT 10;
```

Compact-storage variant:

```sql
CREATE TABLE items_compact (
    id        bigserial PRIMARY KEY,
    embedding hsvec(384),
    body      text
);

CREATE INDEX items_compact_embedding_idx
ON items_compact USING sorted_hnsw (embedding hsvec_cosine_ops)
WITH (m = 16, ef_construction = 200);
```

Current contract:

- planner-integrated ordered scan for base-relation
  `ORDER BY embedding <=> query LIMIT k`
- not chosen when there is no `LIMIT`, when `LIMIT > sorted_hnsw.ef_search`,
  or when extra base-table quals would make the path under-return candidates
- exact rerank happens inside the index scan
- `sorted_hnsw.shared_cache = on` is most useful when
  `shared_preload_libraries = 'pg_sorted_heap'`

### Training permissions

All training functions (`svec_ann_train`, `svec_pq_train`, `svec_pq_train_residual`,
`svec_ivf_train`) create internal metadata tables in the extension schema on first
call. The calling role must have `CREATE` privilege on the extension schema, or be
the extension owner / superuser.

### `svec_ann_train(source_query, nlist, m, n_iter, max_samples)`

Trains both IVF centroids and raw PQ codebook from a SQL query returning
`svec` vectors. Returns `(ivf_cb_id, pq_cb_id)`.

```sql
SELECT * FROM svec_ann_train(
    'SELECT embedding FROM my_table',
    nlist := 64, m := 192);
```

### `svec_pq_train_residual(source_query, m, ivf_cb_id, n_iter, max_samples)`

Trains a residual PQ codebook on `(vector − nearest IVF centroid)` residuals.
Higher recall than raw PQ at no additional storage cost.

```sql
SELECT svec_pq_train_residual(
    'SELECT embedding FROM my_table',
    m := 192, ivf_cb_id := 1);
```

### `svec_ann_scan(tbl, query, nprobe, lim, rerank_topk, cb_id, ivf_cb_id, pq_column)`

C-level IVF-PQ scan — fastest path. Performs IVF probe, PQ ADC, top-K selection,
and optional exact cosine reranking in a single C function call.

```sql
-- PQ-only (fastest)
SELECT * FROM svec_ann_scan('my_table', query_vec,
    nprobe := 3, lim := 10, cb_id := 2, ivf_cb_id := 1);

-- With exact reranking
SELECT * FROM svec_ann_scan('my_table', query_vec,
    nprobe := 10, lim := 10, rerank_topk := 200,
    cb_id := 2, ivf_cb_id := 1);
```

| Parameter | Default | Description |
|---|---|---|
| tbl | — | Table name (regclass) |
| query | — | Query vector (svec) |
| nprobe | 10 | Number of IVF partitions to probe |
| lim | 10 | Number of results to return |
| rerank_topk | 0 | If > 0, rerank this many PQ candidates with exact cosine |
| cb_id | 1 | PQ codebook ID |
| ivf_cb_id | 0 | IVF codebook ID (> 0 enables residual PQ mode) |
| pq_column | 'pq_code' | Name of the PQ code column |

### `svec_ann_search(tbl, query, nprobe, lim, rerank_topk, cb_id)`

SQL-level IVF-PQ search. Same interface as `svec_ann_scan` but implemented in
PL/pgSQL. Useful for debugging and when `svec_ann_scan` is not available.

### `svec_ivf_assign(vec, cb_id)`

Returns the nearest IVF centroid ID for a vector. Used in generated columns
to assign rows to partitions.

### `svec_ivf_probe(vec, nprobe, cb_id)`

Returns an array of the `nprobe` nearest IVF centroid IDs. Used in WHERE
clauses to filter candidates.

### `svec_pq_encode(vec, cb_id)` / `svec_pq_encode_residual(vec, centroid_id, pq_cb_id, ivf_cb_id)`

Encode a vector as an M-byte PQ code. The residual variant encodes
`(vec − centroid)` for use with residual PQ codebooks.

---

## GraphRAG

The stable `0.13` GraphRAG surface is intentionally narrow: fact-shaped
retrieval over a `sorted_heap` table clustered by
`(entity_id, relation_id, target_id)`, or by an equivalent registered alias
mapping.

Stable API:

- `sorted_heap_graph_rag(...)`
- `sorted_heap_graph_register(...)`
- `sorted_heap_graph_config(...)`
- `sorted_heap_graph_unregister(...)`
- `sorted_heap_graph_rag_stats()`
- `sorted_heap_graph_rag_reset_stats()`

Beta API:

- `sorted_heap_expand_ids(...)`
- `sorted_heap_expand_rerank(...)`
- `sorted_heap_expand_twohop_rerank(...)`
- `sorted_heap_expand_twohop_path_rerank(...)`
- `sorted_heap_expand_multihop_rerank(...)`
- `sorted_heap_expand_multihop_path_rerank(...)`
- `sorted_heap_graph_rag_scan(...)`
- `sorted_heap_graph_rag_twohop_scan(...)`
- `sorted_heap_graph_rag_twohop_path_scan(...)`
- `sorted_heap_graph_rag_multihop_scan(...)`
- `sorted_heap_graph_rag_multihop_path_scan(...)`

The stable contract covers fact-shaped retrieval with explicit per-hop relation
sequences. Broader code-corpus snippet/symbol/lexical retrieval recipes remain
benchmark-side reference logic, not SQL-stable product surface.

Recommended schema shape:

```sql
CREATE TABLE facts (
    entity_id   int4,
    relation_id int2,
    target_id   int4,
    embedding   svec(384),
    payload     text,
    PRIMARY KEY (entity_id, relation_id, target_id)
) USING sorted_heap;

CREATE INDEX facts_embedding_idx
ON facts USING sorted_hnsw (embedding)
WITH (m = 24, ef_construction = 200);
```

Canonical fact columns work out of the box. If a fact table uses different
names, register the mapping once and then keep using the same GraphRAG API.

### `sorted_heap_graph_register(rel, entity_column, relation_column, target_column, embedding_column, payload_column)`

Registers a non-canonical fact-table schema for GraphRAG helpers and wrappers.
All mapped columns must still be type-compatible with the fact-graph contract:
`int4 / int2 / int4 / svec / text`.

```sql
SELECT sorted_heap_graph_register(
    'facts_alias'::regclass,
    entity_column := 'src_id',
    relation_column := 'edge_type',
    target_column := 'dst_id',
    embedding_column := 'vec',
    payload_column := 'body'
);
```

### `sorted_heap_graph_config(rel)`

Returns the current GraphRAG mapping for a relation, including whether it was
explicitly registered or is still using the canonical default names.

### `sorted_heap_graph_unregister(rel)`

Removes a previously registered mapping and restores the canonical-name
defaults for that relation.

### `sorted_heap_graph_rag_stats()`

Returns backend-local statistics for the last GraphRAG helper or wrapper call.
This is intended for stage-level observability while tuning seed breadth,
expansion width, and rerank cost.

Returned fields:

- `calls`: number of top-level GraphRAG calls seen by the current backend
- `api`: concrete GraphRAG execution path for the last call
- `seed_count`: ANN or explicit seed count
- `expanded_rows`: rows collected during graph expansion
- `reranked_rows`: rows considered by the exact rerank stage
- `returned_rows`: rows emitted to the caller
- `ann_ms`: ANN seed stage time in milliseconds
- `expand_ms`: graph expansion time in milliseconds
- `rerank_ms`: final rerank / emit time in milliseconds
- `total_ms`: aggregate of the recorded stages

`api` reports the top-level C execution path, not necessarily the SQL wrapper
name. For example, a unified `sorted_heap_graph_rag(...)` call with
`relation_path := ARRAY[1, 2], score_mode := 'path'` will report
`sorted_heap_graph_rag_twohop_path_scan`.

These stats are backend-local and aggregate the last top-level GraphRAG call.
For segmented/routed calls, the aggregate row is the sum of the recorded shard
rows from `sorted_heap_graph_route_last_stats()`.

```sql
SELECT * FROM sorted_heap_graph_rag_stats();
```

### `sorted_heap_graph_rag_reset_stats()`

Resets the backend-local GraphRAG stats counters.

```sql
SELECT sorted_heap_graph_rag_reset_stats();
```

### `sorted_heap_graph_route_last_stats()`

Returns backend-local per-shard stats for the last
`sorted_heap_graph_rag_segmented(...)` or routed GraphRAG call.

Returned fields:

- `call_id`: backend-local route-trace id
- `api`: route wrapper that started the trace
- `source_rel`: concrete shard relation
- `seed_count`, `expanded_rows`, `reranked_rows`, `returned_rows`: shard-local
  stage counters
- `ann_ms`, `expand_ms`, `rerank_ms`, `total_ms`: shard-local timing counters

The row set is diagnostic last-call telemetry, not persistent accounting. It is
cleared by `sorted_heap_graph_rag_reset_stats()`. The current implementation
keeps at most 256 shard rows for the last call; aggregate
`sorted_heap_graph_rag_stats()` totals still include all executed shards.

```sql
SELECT *
FROM sorted_heap_graph_route_last_stats();
```

### `sorted_heap_graph_rag(rel, query, relation_path, ann_k, top_k, score_mode, limit_rows)`

Preferred fact-shaped GraphRAG entry point.

- `relation_path := ARRAY[1]`
  - one-hop expansion
  - ANN seed on `entity_id`
  - exact rerank on the endpoint facts
  - `score_mode := 'endpoint'` and `score_mode := 'path'` are intentionally
    equivalent here because there is only one hop
- `relation_path := ARRAY[1, 2], score_mode := 'endpoint'`
  - two-hop expansion
  - rerank by the final-hop endpoint only
- `relation_path := ARRAY[1, 2], score_mode := 'path'`
  - two-hop expansion
  - path-aware rerank using hop-1 and hop-2 evidence together
- `relation_path := ARRAY[1, 2, 3, ...]`
  - multi-hop expansion
  - each array element is the relation filter for that hop
  - `score_mode := 'endpoint'` ranks only the final hop
  - `score_mode := 'path'` accumulates distance across the whole path

Current constraints:

- `relation_path` must be a non-empty one-dimensional `int4[]`
- supported `score_mode` values are `endpoint` and `path`
- `limit_rows := 0` means unlimited expansion/rerank work
- positive `limit_rows` values cap rows collected or scanned inside the
  current GraphRAG helper stages; this is a work bound, not a final result
  count override
- canonical fact columns (`entity_id`, `relation_id`, `target_id`,
  `embedding`, `payload`) need no extra setup
- non-canonical schemas must be registered first with
  `sorted_heap_graph_register(...)`
- current regression and benchmark coverage verifies the generic path-aware
  contract through synthetic depth `5`

```sql
SET sorted_hnsw.ef_search = 128;

SELECT *
FROM sorted_heap_graph_rag(
    'facts'::regclass,
    '[0.1,0.2,0.3,...]'::svec,
    relation_path := ARRAY[1, 2],
    ann_k := 64,
    top_k := 10,
    score_mode := 'path'
);
```

### `sorted_heap_graph_rag_segmented(rels, query, relation_path, ann_k, top_k, score_mode, limit_rows)`

Beta segmented GraphRAG wrapper.

- `rels` is the candidate shard list to search
- each shard is queried via `sorted_heap_graph_rag(...)`
- shard-local rows are merged globally by `(distance, entity_id, relation_id,
  target_id)`
- the call records backend-local shard rows visible through
  `sorted_heap_graph_route_last_stats()`
- `limit_rows` keeps the same per-shard work-cap semantics as
  `sorted_heap_graph_rag(...)`; it does not replace `top_k` as the final merge
  limit
- this does **not** solve routing for you; the caller still chooses which
  shard subset to query

This is the first SQL-level segmented reference path for large-scale
fact-shaped GraphRAG. It is useful when routing/pruning already exists in the
application or in metadata tables, and you want to move shard fanout/merge out
of benchmark code and into SQL.

Partitioned-parent contract:

- `sorted_heap_graph_rag(...)` is a concrete-relation API. Do not use a
  declarative partition parent as an implicit global graph.
- For partitioned or tenant-sharded facts, register each concrete leaf/shard
  with `sorted_heap_graph_register(...)` and call
  `sorted_heap_graph_route(...)` or `sorted_heap_graph_rag_segmented(...)`
  over the selected leaves.
- Fanout is explicit. `sorted_heap_graph_route_plan(...)` shows the concrete
  shard list before execution.
- The merge step is global over the routed shard-local result sets:
  `global_top_k = top_k(sort(union(shard_local_top_k)))`. It is not local
  top-k concatenation.
- `fanout_limit` narrows the selected shard list before GraphRAG execution. If
  correctness requires every possibly relevant shard, keep `fanout_limit := 0`
  or use a profile that includes all required shards.
- ANN seed retrieval, `ann_k`, and `limit_rows` remain per-shard work bounds;
  they do not become a transparent cross-partition planner path.

```sql
SELECT source_rel, entity_id, relation_id, target_id, payload, distance
FROM sorted_heap_graph_rag_segmented(
    ARRAY['facts_2025q1'::regclass, 'facts_2025q2'::regclass],
    '[0.1,0.2,0.3,...]'::svec,
    relation_path := ARRAY[1, 2],
    ann_k := 64,
    top_k := 10,
    score_mode := 'path'
);
```

### `sorted_heap_graph_segment_meta_register(rel, segment_group, relation_family, segment_labels)`

Registers shared per-shard routing metadata.

- `rel` is one concrete shard relation
- `segment_group` is optional shared shard labeling such as `hot` or `sealed`
- `relation_family` is optional shared shard labeling such as `claims`,
  `citations`, or `right`
- `segment_labels` is optional shared multi-valued shard labeling such as
  `ARRAY['sealed','archive']`
- this is mainly for reducing repeated registry data when several route rows
  point at the same shard
- if a routed row also stores `segment_group` or `relation_family`, the
  row-local value wins over the shared metadata

### `sorted_heap_graph_segment_meta_config(rel)`

Lists current shared per-shard metadata rows.

### `sorted_heap_graph_segment_meta_unregister(rel)`

Deletes one shared per-shard metadata row, or all rows when `rel` is `NULL`.

### `sorted_heap_graph_segment_register(route_name, rel, route_min, route_max, segment_group, relation_family)`

Registers a shard in the beta segment-routing registry.

- `route_name` groups shards into one logical routed graph
- `route_min` / `route_max` define an inclusive `int8` range
- `segment_group` is an optional shard label such as `hot`, `sealed`, or a
  lifecycle tier
- `relation_family` is an optional second routing label such as
  `claims`, `citations`, or `right`
- either label may be left `NULL` and resolved later from
  `sorted_heap_graph_segment_meta_registry`
- overlapping ranges are allowed

### `sorted_heap_graph_segment_config(route_name, segment_groups, relation_family, segment_labels)`

Lists the current registered shard ranges for one route group, ordered by
group, range, and relation.

- `segment_groups := NULL` means "all groups"
- non-`NULL` `text[]` filters to matching shard labels only
- `relation_family := NULL` means "all families"
- non-`NULL` `relation_family` narrows rows to one family value
- `segment_labels := NULL` means "all label sets"
- non-`NULL` `segment_labels` requires the effective shared shard labels to
  contain all supplied labels
- effective labels come from the route row first, then from
  `sorted_heap_graph_segment_meta_registry` for the same shard when the
  route-local label is `NULL`
- when `segment_groups` is non-`NULL`, its array order also becomes the
  preferred group order in the result set

### `sorted_heap_graph_segment_catalog(route_name, segment_groups, relation_family, segment_labels)`

Lists range-routed shard rows with both raw and effective metadata.

Returned fields include:

- route-local values:
  - `route_segment_group`
  - `route_relation_family`
- shared shard metadata:
  - `shared_segment_group`
  - `shared_relation_family`
  - `shared_segment_labels`
- effective resolved values:
  - `effective_segment_group`
  - `effective_relation_family`
  - `effective_segment_labels`
- source markers:
  - `segment_group_source`
  - `relation_family_source`
  - `segment_labels_source`

Source markers are one of:

- `route`
- `shared`
- `unset`

This is an introspection helper only. It does not affect shard routing or
GraphRAG scoring.

### `sorted_heap_graph_segment_resolve(route_name, route_value, fanout_limit, segment_groups, relation_family, segment_labels)`

Resolves candidate shards for a route value.

- matches rows where `route_value BETWEEN route_min AND route_max`
- optionally filters to `segment_group = ANY(segment_groups)`
- optionally filters to `relation_family = <supplied family>`
- optionally filters to shards whose shared `segment_labels` contain all
  supplied labels
- effective labels come from the route row first, then from
  `sorted_heap_graph_segment_meta_registry` for the same shard when the
  route-local label is `NULL`
- when `segment_groups` is non-`NULL`, its array order is preferred before the
  usual narrower-range ordering
- orders narrower ranges first
- `fanout_limit := 0` means "all matching shards"

### `sorted_heap_graph_segment_unregister(route_name, rel)`

Deletes one shard from a route group, or all shards for that route group when
`rel` is `NULL`.

### `sorted_heap_graph_route_policy_register(route_name, policy_name, segment_groups)`

Registers a named shard-group preference policy.

- `route_name` scopes the policy to one routed graph
- `policy_name` is an application-facing label such as `prefer_hot`
- `segment_groups` is a non-empty `text[]`
- array order is preserved and becomes the preference order used by the
  policy-backed wrappers

### `sorted_heap_graph_route_policy_config(route_name, policy_name)`

Lists registered shard-group policies for one route group.

### `sorted_heap_graph_route_policy_groups(route_name, policy_name)`

Returns the stored `segment_groups text[]` for one named policy.

- raises an error if the policy does not exist

### `sorted_heap_graph_route_policy_unregister(route_name, policy_name)`

Deletes one named policy, or all policies under that route group when
`policy_name` is `NULL`.

### `sorted_heap_graph_route_profile_register(route_name, profile_name, policy_name, segment_groups, relation_family, fanout_limit, segment_labels)`

Registers a named routed profile that bundles the current beta routing knobs.

- `route_name` scopes the profile to one routed graph
- `profile_name` is an application-facing name such as `sealed_claims`
- `policy_name` may be `NULL`; when non-`NULL` it must name a policy already
  registered under the same route
- `segment_groups` may be `NULL`; when non-`NULL` it must be a non-empty
  one-dimensional `text[]`
- `policy_name` and `segment_groups` cannot both be non-`NULL`
- `relation_family` may be `NULL`
- `fanout_limit` must be `>= 0`
- `segment_labels` may be `NULL`; when non-`NULL` it must be a non-empty
  one-dimensional `text[]`

### `sorted_heap_graph_route_profile_config(route_name, profile_name)`

Lists registered routed profiles for one route group.

### `sorted_heap_graph_route_profile_resolve(route_name, profile_name)`

Resolves one routed profile into:

- `policy_name`
- `segment_groups`
- `relation_family`
- `fanout_limit`
- `segment_labels`

This is mainly useful for inspection/testing. Query execution should normally
go through the profile-backed wrappers below.

### `sorted_heap_graph_route_profile_unregister(route_name, profile_name)`

Deletes one routed profile, or all profiles under that route group when
`profile_name` is `NULL`.

### `sorted_heap_graph_route_default_register(route_name, profile_name)`

Registers the default routed profile for one route.

- `route_name` scopes the default to one routed graph
- `profile_name` must already exist in
  `sorted_heap_graph_route_profile_registry` under the same route
- later registrations replace the previous default for that route

### `sorted_heap_graph_route_default_config(route_name)`

Lists current default-profile bindings.

### `sorted_heap_graph_route_default_resolve(route_name)`

Returns the `profile_name` currently marked as default for one route.

This is mainly useful for inspection/testing. Query execution should normally
go through the default-backed wrappers below.

### `sorted_heap_graph_route_profile_catalog(route_name, profile_name)`

Lists routed profiles with both raw and effective shard-group metadata.

Returned fields include:

- profile-local values:
  - `policy_name`
  - `inline_segment_groups`
- policy-backed values:
  - `policy_segment_groups`
- effective resolved values:
  - `effective_segment_groups`
  - `relation_family`
  - `fanout_limit`
  - `segment_labels`
- source/default markers:
  - `segment_groups_source`
  - `is_default`

`segment_groups_source` is one of:

- `inline`
- `policy`
- `unset`

This is an introspection helper only. It does not affect shard routing or
GraphRAG scoring.

### `sorted_heap_graph_route_catalog(route_name)`

Lists one summary row per route across the current segmented/routed control
plane.

Returned fields include:

- control-plane counts:
  - `range_shard_count`
  - `exact_binding_count`
  - `policy_count`
  - `profile_count`
- effective default-profile state:
  - `default_profile_name`
  - `default_effective_segment_groups`
  - `default_segment_groups_source`
  - `default_relation_family`
  - `default_fanout_limit`
  - `default_segment_labels`

This is the top-level operator summary:

- range shards are counted from `sorted_heap_graph_segment_registry`
- exact bindings are counted from `sorted_heap_graph_exact_registry`
- policies and profiles are counted from their corresponding registries
- default-profile fields come from the effective profile/default catalog layer

This is an introspection helper only. It does not affect shard routing or
GraphRAG scoring.

### `sorted_heap_graph_route_default_unregister(route_name)`

Deletes one default-profile binding, or all bindings when `route_name` is
`NULL`.

### `sorted_heap_graph_rag_routed(route_name, route_value, query, relation_path, ann_k, top_k, score_mode, limit_rows, fanout_limit, segment_groups, relation_family, segment_labels)`

Beta routed GraphRAG wrapper.

- resolves candidate shards from `sorted_heap_graph_segment_registry`
- optionally narrows those shards by `segment_group`
- optionally narrows those shards by `relation_family`
- optionally narrows those shards by shared `segment_labels`
- when `segment_groups` is non-`NULL`, its array order is the shard preference
  order before `fanout_limit` is applied
- delegates to `sorted_heap_graph_rag_segmented(...)`
- preserves the same GraphRAG scoring contract after routing

This is the first metadata-driven routing surface for segmented GraphRAG. It
does not try to infer a route from the vector query itself; the caller supplies
the route value.

```sql
SELECT sorted_heap_graph_segment_register('tenant_facts', 'facts_hot'::regclass, 1, 1000, 'hot', 'claims');
SELECT sorted_heap_graph_segment_register('tenant_facts', 'facts_cold'::regclass, 1, 1000, 'sealed', 'claims');

SELECT source_rel, entity_id, relation_id, target_id, payload, distance
FROM sorted_heap_graph_rag_routed(
    'tenant_facts',
    812,
    '[0.1,0.2,0.3,...]'::svec,
    relation_path := ARRAY[1, 2],
    ann_k := 64,
    top_k := 10,
    score_mode := 'path',
    segment_groups := ARRAY['hot'],
    relation_family := 'claims'
);
```

### `sorted_heap_graph_rag_routed_policy(route_name, route_value, policy_name, query, relation_path, ann_k, top_k, score_mode, limit_rows, fanout_limit, relation_family, segment_labels)`

Beta routed GraphRAG wrapper with registry-backed shard-group policy lookup.

- resolves `segment_groups` from `sorted_heap_graph_route_policy_registry`
- delegates to `sorted_heap_graph_rag_routed(...)`
- can still add a per-query `relation_family` filter on top of the stored
  shard-group order
- can also add a per-query `segment_labels` filter on top of the stored
  shard-group order
- keeps the same routing and GraphRAG scoring semantics

```sql
SELECT sorted_heap_graph_route_policy_register(
    'tenant_facts',
    'prefer_hot',
    ARRAY['hot', 'sealed']
);

SELECT source_rel, entity_id, relation_id, target_id, payload, distance
FROM sorted_heap_graph_rag_routed_policy(
    'tenant_facts',
    812,
    'prefer_hot',
    '[0.1,0.2,0.3,...]'::svec,
    relation_path := ARRAY[1, 2],
    ann_k := 64,
    top_k := 10,
    score_mode := 'path',
    fanout_limit := 1,
    relation_family := 'claims'
);
```

### `sorted_heap_graph_rag_routed_profile(route_name, route_value, profile_name, query, relation_path, ann_k, top_k, score_mode, limit_rows)`

Beta routed GraphRAG wrapper with registry-backed profile lookup.

- resolves `policy_name`, `relation_family`, `fanout_limit`, and optional
  `segment_labels` from
  `sorted_heap_graph_route_profile_registry`
- delegates to `sorted_heap_graph_rag_routed(...)` with inline
  `segment_groups` when the profile stores them directly
- otherwise delegates to `sorted_heap_graph_rag_routed_policy(...)` when the
  profile names a policy
- otherwise delegates to `sorted_heap_graph_rag_routed(...)`
- keeps the same routing and GraphRAG scoring semantics

```sql
SELECT sorted_heap_graph_route_profile_register(
    'tenant_facts',
    'sealed_claims',
    NULL,
    ARRAY['sealed', 'hot'],
    'claims',
    1
);

SELECT source_rel, entity_id, relation_id, target_id, payload, distance
FROM sorted_heap_graph_rag_routed_profile(
    'tenant_facts',
    812,
    'sealed_claims',
    '[0.1,0.2,0.3,...]'::svec,
    relation_path := ARRAY[1, 2],
    ann_k := 64,
    top_k := 10,
    score_mode := 'path'
);
```

### `sorted_heap_graph_rag_routed_default(route_name, route_value, query, relation_path, ann_k, top_k, score_mode, limit_rows)`

Beta routed GraphRAG wrapper with registry-backed default-profile lookup.

- resolves `profile_name` from `sorted_heap_graph_route_default_registry`
- delegates to `sorted_heap_graph_rag_routed_profile(...)`
- keeps the same routing and GraphRAG scoring semantics

```sql
SELECT sorted_heap_graph_route_default_register(
    'tenant_facts',
    'sealed_claims'
);

SELECT source_rel, entity_id, relation_id, target_id, payload, distance
FROM sorted_heap_graph_rag_routed_default(
    'tenant_facts',
    812,
    '[0.1,0.2,0.3,...]'::svec,
    relation_path := ARRAY[1, 2],
    ann_k := 64,
    top_k := 10,
    score_mode := 'path'
);
```

### `sorted_heap_graph_route(route_name, query, relation_path, route_key, route_value, profile_name, policy_name, ann_k, top_k, score_mode, limit_rows, fanout_limit, segment_groups, relation_family, segment_labels)`

Unified routed GraphRAG entry point over the existing beta exact/range
wrappers.

Resolution order:

1. exactly one of `route_key` or `route_value`
2. at most one of `profile_name` or `policy_name`
3. explicit `profile_name`
4. explicit `policy_name`
5. explicit call-site routing overrides
   - `fanout_limit`
   - `segment_groups`
   - `relation_family`
   - `segment_labels`
6. route default profile, if one exists
7. base exact/range routed wrapper

Important constraints:

- `profile_name` cannot be combined with call-site routing overrides
- `policy_name` cannot be combined with `segment_groups`
- defaults never override explicit call-site routing knobs
- this wrapper reuses the existing routed GraphRAG paths; it does not define
  a new GraphRAG scoring model
- this is the recommended parent/shard fanout API for GraphRAG; declarative
  partition parents should be resolved to concrete leaves first rather than
  passed as implicit global GraphRAG relations

```sql
SELECT source_rel, entity_id, relation_id, target_id, payload, distance
FROM sorted_heap_graph_route(
    'tenant_facts',
    '[0.1,0.2,0.3,...]'::svec,
    relation_path := ARRAY[1, 2],
    route_key := 'kb_alpha',
    ann_k := 64,
    top_k := 10,
    score_mode := 'path'
);
```

### `sorted_heap_graph_route_plan(route_name, route_key, route_value, profile_name, policy_name, fanout_limit, segment_groups, relation_family, segment_labels)`

Explain helper for `sorted_heap_graph_route(...)`.

Returns:

- `route_kind`
- `resolution_path`
- `used_profile_name`
- `used_policy_name`
- `used_default`
- `effective_fanout_limit`
- `effective_segment_groups`
- `effective_relation_family`
- `effective_segment_labels`
- `candidate_shards`

This function uses the same routing precedence as `sorted_heap_graph_route(...)`
but does not execute GraphRAG.

```sql
SELECT *
FROM sorted_heap_graph_route_plan(
    'tenant_facts',
    route_key := 'kb_alpha'
);
```

### `sorted_heap_graph_exact_register(route_name, route_key, rel, priority, segment_group, relation_family)`

Registers an exact-key shard mapping in the beta exact-routing registry.

- `route_name` groups shards into one logical routed graph
- `route_key` is an exact text key such as a tenant id or knowledge-base id
- multiple shards may share the same key
- `priority` orders those shards when one key fans out to several shards
- `segment_group` is an optional shard label such as `hot` or `sealed`
- `relation_family` is an optional second routing label such as
  `claims`, `citations`, or `right`
- either label may be left `NULL` and resolved later from
  `sorted_heap_graph_segment_meta_registry`

### `sorted_heap_graph_exact_config(route_name, route_key, segment_groups, relation_family, segment_labels)`

Lists the current exact-key shard mappings ordered by
`(route_name, route_key, priority desc, segment_group, relation_family, rel)`.

- when `segment_groups` is non-`NULL`, its array order becomes the preferred
  group order before per-shard priority
- `relation_family := NULL` means "all families"
- non-`NULL` `relation_family` narrows rows to one family value
- `segment_labels := NULL` means "all label sets"
- non-`NULL` `segment_labels` requires the effective shared shard labels to
  contain all supplied labels
- effective labels come from the exact-route row first, then from
  `sorted_heap_graph_segment_meta_registry` for the same shard when the
  route-local label is `NULL`

### `sorted_heap_graph_exact_catalog(route_name, route_key, segment_groups, relation_family, segment_labels)`

Lists exact-key routed shard rows with both raw and effective metadata.

Returned fields include:

- route-local values:
  - `route_segment_group`
  - `route_relation_family`
- shared shard metadata:
  - `shared_segment_group`
  - `shared_relation_family`
  - `shared_segment_labels`
- effective resolved values:
  - `effective_segment_group`
  - `effective_relation_family`
  - `effective_segment_labels`
- source markers:
  - `segment_group_source`
  - `relation_family_source`
  - `segment_labels_source`

Source markers are one of:

- `route`
- `shared`
- `unset`

This is an introspection helper only. It does not affect shard routing or
GraphRAG scoring.

### `sorted_heap_graph_exact_resolve(route_name, route_key, fanout_limit, segment_groups, relation_family, segment_labels)`

Resolves candidate shards for an exact route key.

- matches rows where `route_key = <supplied key>`
- optionally filters to `segment_group = ANY(segment_groups)`
- optionally filters to `relation_family = <supplied family>`
- optionally filters to shards whose shared `segment_labels` contain all
  supplied labels
- effective labels come from the exact-route row first, then from
  `sorted_heap_graph_segment_meta_registry` for the same shard when the
  route-local label is `NULL`
- when `segment_groups` is non-`NULL`, its array order is preferred before the
  usual `priority DESC` ordering
- orders by `priority DESC, rel`
- `fanout_limit := 0` means "all matching shards"

### `sorted_heap_graph_exact_unregister(route_name, route_key, rel)`

Deletes exact-key shard mappings.

- `route_key := NULL` deletes every key under that route group
- `rel := NULL` deletes all matching shard rows for the selected route/key

### `sorted_heap_graph_rag_routed_exact(route_name, route_key, query, relation_path, ann_k, top_k, score_mode, limit_rows, fanout_limit, segment_groups, relation_family, segment_labels)`

Beta exact-key routed GraphRAG wrapper.

- resolves candidate shards from `sorted_heap_graph_exact_registry`
- optionally narrows those shards by `segment_group`
- optionally narrows those shards by `relation_family`
- optionally narrows those shards by shared `segment_labels`
- when `segment_groups` is non-`NULL`, its array order is the shard preference
  order before `fanout_limit` is applied
- delegates to `sorted_heap_graph_rag_segmented(...)`
- keeps the same GraphRAG scoring contract after routing

This is the stronger fit for tenant-id / knowledge-base-id routing than the
range-based wrapper.

```sql
SELECT sorted_heap_graph_exact_register('tenant_facts', 'kb_alpha', 'facts_hot'::regclass, 100, 'hot', 'claims');
SELECT sorted_heap_graph_exact_register('tenant_facts', 'kb_alpha', 'facts_cold'::regclass, 50, 'sealed', 'claims');

SELECT source_rel, entity_id, relation_id, target_id, payload, distance
FROM sorted_heap_graph_rag_routed_exact(
    'tenant_facts',
    'kb_alpha',
    '[0.1,0.2,0.3,...]'::svec,
    relation_path := ARRAY[1, 2],
    ann_k := 64,
    top_k := 10,
    score_mode := 'path',
    segment_groups := ARRAY['hot'],
    relation_family := 'claims'
);
```

### `sorted_heap_graph_rag_routed_exact_policy(route_name, route_key, policy_name, query, relation_path, ann_k, top_k, score_mode, limit_rows, fanout_limit, relation_family, segment_labels)`

Beta exact-key routed GraphRAG wrapper with registry-backed shard-group policy
lookup.

- resolves `segment_groups` from `sorted_heap_graph_route_policy_registry`
- delegates to `sorted_heap_graph_rag_routed_exact(...)`
- can still add a per-query `relation_family` filter on top of the stored
  shard-group order
- can also add a per-query `segment_labels` filter on top of the stored
  shard-group order
- keeps the same routing and GraphRAG scoring semantics

```sql
SELECT sorted_heap_graph_route_policy_register(
    'tenant_facts',
    'prefer_hot',
    ARRAY['hot', 'sealed']
);

SELECT source_rel, entity_id, relation_id, target_id, payload, distance
FROM sorted_heap_graph_rag_routed_exact_policy(
    'tenant_facts',
    'kb_alpha',
    'prefer_hot',
    '[0.1,0.2,0.3,...]'::svec,
    relation_path := ARRAY[1, 2],
    ann_k := 64,
    top_k := 10,
    score_mode := 'path',
    fanout_limit := 1,
    relation_family := 'claims'
);
```

### `sorted_heap_graph_rag_routed_exact_profile(route_name, route_key, profile_name, query, relation_path, ann_k, top_k, score_mode, limit_rows)`

Beta exact-key routed GraphRAG wrapper with registry-backed profile lookup.

- resolves `policy_name`, `relation_family`, `fanout_limit`, and optional
  `segment_labels` from
  `sorted_heap_graph_route_profile_registry`
- delegates to `sorted_heap_graph_rag_routed_exact(...)` with inline
  `segment_groups` when the profile stores them directly
- otherwise delegates to `sorted_heap_graph_rag_routed_exact_policy(...)`
  when the profile names a policy
- otherwise delegates to `sorted_heap_graph_rag_routed_exact(...)`
- keeps the same routing and GraphRAG scoring semantics

```sql
SELECT sorted_heap_graph_route_profile_register(
    'tenant_facts',
    'sealed_claims',
    NULL,
    ARRAY['sealed', 'hot'],
    'claims',
    1
);

SELECT source_rel, entity_id, relation_id, target_id, payload, distance
FROM sorted_heap_graph_rag_routed_exact_profile(
    'tenant_facts',
    'kb_alpha',
    'sealed_claims',
    '[0.1,0.2,0.3,...]'::svec,
    relation_path := ARRAY[1, 2],
    ann_k := 64,
    top_k := 10,
    score_mode := 'path'
);
```

### `sorted_heap_graph_rag_routed_exact_default(route_name, route_key, query, relation_path, ann_k, top_k, score_mode, limit_rows)`

Beta exact-key routed GraphRAG wrapper with registry-backed default-profile
lookup.

- resolves `profile_name` from `sorted_heap_graph_route_default_registry`
- delegates to `sorted_heap_graph_rag_routed_exact_profile(...)`
- keeps the same routing and GraphRAG scoring semantics

```sql
SELECT sorted_heap_graph_route_default_register(
    'tenant_facts',
    'sealed_claims'
);

SELECT source_rel, entity_id, relation_id, target_id, payload, distance
FROM sorted_heap_graph_rag_routed_exact_default(
    'tenant_facts',
    'kb_alpha',
    '[0.1,0.2,0.3,...]'::svec,
    relation_path := ARRAY[1, 2],
    ann_k := 64,
    top_k := 10,
    score_mode := 'path'
);
```

### Lower-level GraphRAG building blocks (beta)

### `sorted_heap_expand_ids(rel, seed_ids, relation_filter, limit_rows)`

Expands known entity seeds into fact rows without reranking.

- `limit_rows := 0` means unlimited
- positive `limit_rows` stops expansion after that many matching rows have
  been collected

```sql
SELECT *
FROM sorted_heap_expand_ids(
    'facts'::regclass,
    ARRAY[101, 202],
    relation_filter := 1
);
```

### `sorted_heap_expand_rerank(rel, seed_ids, query, top_k, relation_filter, limit_rows)`

One-hop expansion followed by exact rerank on the expanded candidates.

- `limit_rows` caps the expansion and rerank scan work before top-k selection
- `top_k` still controls the final output size

```sql
SELECT *
FROM sorted_heap_expand_rerank(
    'facts'::regclass,
    ARRAY[101, 202],
    '[0.1,0.2,0.3,...]'::svec,
    top_k := 10,
    relation_filter := 1
);
```

### `sorted_heap_expand_twohop_rerank(rel, seed_ids, query, top_k, hop1_relation_filter, hop2_relation_filter, limit_rows)`

Two-hop expansion with rerank on the final candidate set.

- `limit_rows` caps work in the hop-expansion and final rerank scan stages

### `sorted_heap_expand_twohop_path_rerank(rel, seed_ids, query, top_k, hop1_relation_filter, hop2_relation_filter, limit_rows)`

Two-hop expansion with path-aware rerank using hop-1 and hop-2 evidence
together. This is the stronger current contract for fact-shaped multihop
retrieval.

- `limit_rows` caps work in the hop-expansion and final path-rerank scan
  stages

```sql
SELECT *
FROM sorted_heap_expand_twohop_path_rerank(
    'facts'::regclass,
    ARRAY[101, 202],
    '[0.1,0.2,0.3,...]'::svec,
    top_k := 10,
    hop1_relation_filter := 1,
    hop2_relation_filter := 2
);
```

### `sorted_heap_graph_rag_scan(rel, query, ann_k, top_k, relation_filter, limit_rows)`

Lower-level one-hop wrapper retained for backward compatibility and
target-seeded graph shapes. This wrapper seeds one-hop expansion from
ANN-selected `target_id` values, so it is not the preferred fact-graph
contract.

- `limit_rows` keeps the same work-cap semantics as the lower-level helpers

### `sorted_heap_graph_rag_twohop_scan(rel, query, ann_k, top_k, hop1_relation_filter, hop2_relation_filter, limit_rows)`

Lower-level endpoint-scored two-hop wrapper. `sorted_heap_graph_rag(...)`
with `relation_path := ARRAY[hop1, hop2], score_mode := 'endpoint'`
is the preferred higher-level syntax.

- `limit_rows` keeps the same work-cap semantics as the underlying helper path

### `sorted_heap_graph_rag_twohop_path_scan(rel, query, ann_k, top_k, hop1_relation_filter, hop2_relation_filter, limit_rows)`

Lower-level path-aware two-hop wrapper. `sorted_heap_graph_rag(...)` with
`relation_path := ARRAY[hop1, hop2], score_mode := 'path'` is the preferred
higher-level syntax.

- `limit_rows` keeps the same work-cap semantics as the underlying helper path

### `sorted_heap_expand_multihop_rerank(rel, seed_ids, query, top_k, relation_path, limit_rows)`

Lower-level endpoint-scored multi-hop helper. `relation_path` is the explicit
per-hop relation sequence.

- `limit_rows` caps work at each hop expansion plus the final rerank scan

### `sorted_heap_expand_multihop_path_rerank(rel, seed_ids, query, top_k, relation_path, limit_rows)`

Lower-level path-aware multi-hop helper. This accumulates distance across the
full explicit `relation_path`.

- `limit_rows` caps work at each hop expansion plus the final path-rerank scan

### `sorted_heap_graph_rag_multihop_scan(rel, query, ann_k, top_k, relation_path, limit_rows)`

Lower-level ANN-seeded multi-hop wrapper for endpoint-scored retrieval.

- `limit_rows` keeps the same work-cap semantics as the underlying helper path

### `sorted_heap_graph_rag_multihop_path_scan(rel, query, ann_k, top_k, relation_path, limit_rows)`

Lower-level ANN-seeded multi-hop wrapper for path-aware retrieval.

- `limit_rows` keeps the same work-cap semantics as the underlying helper path

```sql
SET sorted_hnsw.ef_search = 128;

SELECT *
FROM sorted_heap_graph_rag(
    'facts'::regclass,
    '[0.1,0.2,0.3,...]'::svec,
    relation_path := ARRAY[1, 2],
    ann_k := 64,
    top_k := 10,
    score_mode := 'path'
);
```

---

## Routed GraphRAG: operator recipe

This section describes the recommended app-facing flow for multi-shard
GraphRAG with routing. Use `sorted_heap_graph_route(...)` for queries and
`sorted_heap_graph_route_plan(...)` for inspection. The lower-level
`_routed`, `_routed_exact`, `_routed_policy`, `_routed_profile`, and
`_routed_default` wrappers remain lower-level beta building blocks.

### What to call in an app

| Task | Function |
|------|----------|
| **Query** (app code) | `sorted_heap_graph_route(...)` |
| **Inspect routing** (operator/debug) | `sorted_heap_graph_route_plan(...)` |
| Setup: register shard metadata | `sorted_heap_graph_register(...)` per shard |
| Setup: register exact routes | `sorted_heap_graph_exact_register(...)` |
| Setup: register range routes | `sorted_heap_graph_segment_register(...)` |
| Setup: optional profile/default | `sorted_heap_graph_route_profile_register(...)` + `sorted_heap_graph_route_default_register(...)` |

### Exact-key routing (tenant / knowledge-base)

**Setup (once per deployment):**

```sql
-- 1. Create shard tables with HNSW indexes
CREATE TABLE facts_shard_a (...) USING sorted_heap;
CREATE TABLE facts_shard_b (...) USING sorted_heap;
-- load data, compact, create sorted_hnsw indexes, ANALYZE

-- 2. Register graph schema on each shard
SELECT sorted_heap_graph_register('facts_shard_a'::regclass,
  entity_column := 'entity_id', relation_column := 'relation_id',
  target_column := 'target_id', embedding_column := 'embedding',
  payload_column := 'payload');
SELECT sorted_heap_graph_register('facts_shard_b'::regclass, ...);

-- 3. Map tenant keys to shards
SELECT sorted_heap_graph_exact_register('tenants', 'acme',
  'facts_shard_a'::regclass, 100);
SELECT sorted_heap_graph_exact_register('tenants', 'globex',
  'facts_shard_b'::regclass, 100);
```

**Query (app code):**

```sql
SELECT entity_id, relation_id, target_id, payload, distance
FROM sorted_heap_graph_route(
  'tenants',                        -- route name
  query_embedding,                  -- svec
  ARRAY[1, 2],                      -- relation path (2-hop)
  route_key := 'acme',              -- tenant key
  ann_k := 64,
  top_k := 10,
  score_mode := 'path'
);
```

**Inspect (operator):**

```sql
SELECT * FROM sorted_heap_graph_route_plan(
  'tenants', route_key := 'acme');
-- route_kind | resolution_path | candidate_shards
-- exact      | base            | {facts_shard_a}
```

### Range routing (time-window / ID-range)

**Setup:**

```sql
-- Map ID ranges to shards
SELECT sorted_heap_graph_segment_register('id_range',
  'facts_shard_a'::regclass, 1, 100000);
SELECT sorted_heap_graph_segment_register('id_range',
  'facts_shard_b'::regclass, 100001, 200000);
```

**Query:**

```sql
SELECT entity_id, relation_id, target_id, payload, distance
FROM sorted_heap_graph_route(
  'id_range',
  query_embedding,
  ARRAY[1, 2],
  route_value := 42000,             -- routes to shard_a
  ann_k := 64,
  top_k := 10
);
```

### Adding a default profile

Profiles bundle routing knobs so app code doesn't need to pass them:

```sql
-- Register a profile that filters by segment group and relation family
SELECT sorted_heap_graph_route_profile_register(
  'tenants', 'production',
  NULL,                             -- no policy
  ARRAY['active'],                  -- segment_groups filter
  'core',                           -- relation_family
  0,                                -- fanout_limit
  ARRAY['verified']                 -- segment_labels
);

-- Set as the default for this route
SELECT sorted_heap_graph_route_default_register('tenants', 'production');
```

Now `sorted_heap_graph_route('tenants', ..., route_key := 'acme')` will
automatically use the 'production' profile (step 6 in the resolution
order) unless the caller explicitly provides a different profile, policy,
or routing overrides.

### Lower-level building blocks (not recommended for app code)

The following functions are internal dispatch targets used by
`sorted_heap_graph_route(...)`. They remain available for advanced use
cases but are not the recommended app entry point:

- `sorted_heap_graph_rag_routed(...)` / `sorted_heap_graph_rag_routed_exact(...)`
- `sorted_heap_graph_rag_routed_policy(...)` / `sorted_heap_graph_rag_routed_exact_policy(...)`
- `sorted_heap_graph_rag_routed_profile(...)` / `sorted_heap_graph_rag_routed_exact_profile(...)`
- `sorted_heap_graph_rag_routed_default(...)` / `sorted_heap_graph_rag_routed_exact_default(...)`
- `sorted_heap_graph_rag_segmented(...)`
