# PostgreSQL-Native Lexical and Fuzzy Retrieval

pgContext serves keyword retrieval through PostgreSQL itself. You register a
**lexical source** — an ordered set of weighted text or JSON fields, or a stored
`tsvector` column — and pgContext renders every query from that registration.
PostgreSQL owns parsing, dictionaries, matching, ranking, collation, MVCC, ACLs,
and RLS. pgContext owns validated metadata, bounded orchestration, an exact
fallback, provenance, and diagnostics.

Optional trigram retrieval works the same way through a registered **fuzzy
source** backed by `pg_trgm`.

## Registering a lexical source

```sql
SELECT pgcontext.create_collection('articles', 'public.articles');
SELECT pgcontext.backfill_points('articles', 10000);

SELECT pgcontext.register_lexical_source(
    collection          => 'articles',
    source_name         => 'article',
    text_columns        => ARRAY['title', 'body'],
    text_configuration  => 'pg_catalog.english',
    field_weights       => ARRAY['A', 'D'],
    ranker              => 'ts_rank_cd',
    normalization       => 0
);
```

| Argument | Meaning |
| --- | --- |
| `text_columns` | Source-table columns in document order (1..=16). |
| `text_configuration` | Schema-qualified text-search configuration. Defaults to `pg_catalog.english`. |
| `field_weights` | Per-column PostgreSQL weight `A`/`B`/`C`/`D`. Defaults to `D`. |
| `json_paths` | Per-column dotted path into a `json`/`jsonb` column, e.g. `tags.topic`. Up to 16 components. |
| `ranker` | `ts_rank` or `ts_rank_cd`. |
| `normalization` | PostgreSQL rank normalization bitmask, `0..=63`. |
| `rank_weights` | `{D, C, B, A}` `real[]`, each within `0.0..=1.0`. Defaults to `{0.1, 0.2, 0.4, 1.0}`. |

Source names are validated identifiers: `[a-z_][a-z0-9_]{0,62}`.

### Stored, generated, or trigger-maintained documents

When the source table already carries a `tsvector` column, register it directly:

```sql
SELECT pgcontext.register_lexical_document_source(
    'articles', 'stored', 'document', 'pg_catalog.english'
);
```

pgContext never writes that column. Keep it current with a generated column or a
trigger; PostgreSQL's own MVCC then keeps the document consistent with the row.
Stored-vector sources carry no raw text, so `pgcontext.lexical_headline` is not
available for them.

### Per-row `tsquery` bindings

A `tsquery` column can be registered and referenced from the typed query:

```sql
SELECT pgcontext.register_lexical_tsquery('articles', 'article', 'saved', 'saved_query');
```

## Querying

`pgcontext.query_lexical` builds a validated query-plan leaf that composes with
every other Q1 branch through `pgcontext.execute_query`.

```sql
SELECT point_id, source_key, score
  FROM pgcontext.execute_query(
      'articles',
      pgcontext.query_lexical(
          'article',
          jsonb_build_object('form', 'plain', 'text', 'postgres storage'),
          NULL,
          10
      )
  );
```

### Query forms

| `form` | Fields | PostgreSQL constructor |
| --- | --- | --- |
| `plain` | `text` | `plainto_tsquery` |
| `structured` | `text` | `to_tsquery` (caller supplies `tsquery` syntax) |
| `phrase` | `text` | `phraseto_tsquery` |
| `web_search` | `text` | `websearch_to_tsquery` (raw-input safe) |
| `prefix` | `term` | `to_tsquery('term:*')` |
| `distance` | `left`, `right`, `distance` | `tsquery_phrase` |
| `boolean` | `operator` (`and`/`or`/`not`), `clauses` | `&&`, `||`, `!!` |
| `weight_restricted` | `weights`, `query` | `ts_filter` over the registered document |
| `registered_tsquery` | `name` | the registered per-row `tsquery` column |

`and` and `or` require at least two clauses, `not` requires exactly one, and a
Boolean node accepts at most 64 clauses. Query text is bounded at 4096 bytes,
the tree at 256 nodes and 16 levels, and phrase distance at 16384 lexemes.
`prefix` terms accept only alphanumeric characters and `_`, so no `tsquery`
operator can reach the parser from untrusted input.

`weight_restricted` restricts the **document** with `ts_filter`, so it is only
valid at the root of a lexical query.

> **Weight restriction forgoes the index.** A restricted match is *not* a subset
> of the unrestricted match — removing a lexeme can make a negated clause become
> true — so the candidate probe must evaluate the restricted expression. That
> expression does not match an index keyed on the unrestricted document, so a
> weight-restricted query is served by a bounded sequential scan even when an
> index is attached. It stays bounded by the candidate allowance and the query
> timeout, but do not expect index-speed latency from this form on a large
> corpus.

Every form is a `HigherIsBetter` leaf and accepts the same filter JSON as the
other Q1 branches.

## Indexes

pgContext creates and owns lexical indexes so the index expression always
matches the canonical document expression the query paths render:

```sql
SELECT pgcontext.create_lexical_index('articles', 'article');           -- GIN
SELECT pgcontext.create_lexical_index('articles', 'article', 'gist');   -- GiST
SELECT pgcontext.detach_lexical_index('articles', 'article');
```

`pgcontext.attach_lexical_index` binds an index you created yourself. Attachment
requires a valid, live, non-partial GIN or GiST index on the registered source
relation, with exactly one key and no included columns. A stored-document source
must use that `tsvector` column directly with PostgreSQL's `tsvector_ops`. A
raw-field source must use the exact canonical document expression: pgContext
checks the referenced columns and asks PostgreSQL's planner to prove that the
registered predicate can use the index. An index over the same table, or even
the same columns with a different configuration or weighting expression, is
rejected. The full `pg_get_indexdef` text is recorded and rechecked on every
query, so an index that is later redefined fails closed rather than silently
changing semantics.

**Serving contract.** With no attached index, the exact path evaluates the
complete invoker-visible corpus or reports budget exhaustion — it never
truncates and calls the result complete. With an attached index, pgContext
starts from the registered `@@` predicate, probes at most the remaining
candidate allowance plus one, marks the page incomplete when that boundary is
crossed, and then rereads the current source rows under MVCC and RLS to reapply
the predicate and recompute the exact native rank. Index candidates are never
authoritative.

### Candidate budget

`pgcontext.lexical_candidate_budget` (default `1000`, maximum `10000`) bounds
how many candidates one registered lexical or fuzzy source may admit from an
attached index before the page is marked incomplete. A query whose match set
exceeds the allowance fails closed rather than returning a silently truncated
answer:

```sql
SET pgcontext.lexical_candidate_budget = 5000;
```

Raise it for broad queries over large corpora. It only ever raises the
allowance: the effective budget is `max(setting, the query's own limit)`, so
setting it below a query's `limit` has no effect. To cap per-query work, lower
the query's `limit` or the collection's `query_timeout_ms`.

## Fuzzy (trigram) sources

Fuzzy retrieval is optional and requires `pg_trgm`. pgContext resolves the
extension through its own catalog entry, so a relocated `pg_trgm` schema works
and `search_path` is never trusted. `pg_trgm` is **not** a pgContext install
requirement.

```sql
CREATE EXTENSION IF NOT EXISTS pg_trgm;
SELECT pgcontext.register_fuzzy_source('articles', 'body_trgm', 'body');
SELECT pgcontext.create_fuzzy_index('articles', 'body_trgm');           -- gin_trgm_ops

SELECT point_id, source_key, score
  FROM pgcontext.execute_query(
      'articles',
      pgcontext.query_fuzzy('body_trgm', 'postgrs', 'similarity', 0.35, NULL, 10)
  );
```

The registered source column must have PostgreSQL type `text`. An attached
fuzzy index must key that exact column directly, contain no included columns,
and use the `gin_trgm_ops` or `gist_trgm_ops` operator class owned by the
resolved `pg_trgm` extension. An index over another text column on the same
table is rejected.

Modes are `similarity`, `word_similarity`, and `strict_word_similarity`.
Thresholds are finite and within `0.0 < threshold <= 1.0`. An indexed probe sets
the matching `pg_trgm` threshold GUC and restores the previous value through a
scope guard when the probe returns or fails; because the setting is written
transaction-locally, a PostgreSQL error that unwinds past the guard is discarded
by PostgreSQL's own transaction rollback. Either way a query never leaks a
threshold into the surrounding transaction. Each mode restores its own
documented default (`0.3`, `0.6`, `0.5`) when the session had no explicit value.
The final score is always recomputed with the explicit similarity function and
typed threshold, never from the GUC.

## Highlighting

```sql
SELECT point_id, headline
  FROM pgcontext.lexical_headline(
      'articles', 'article', ARRAY[1, 2, 3]::bigint[],
      jsonb_build_object('form', 'plain', 'text', 'postgres')
  );
```

The call admits at most 1000 point IDs, 4096 option bytes, and 8 MiB of source
document bytes before PostgreSQL builds any markup. The 2 MiB output cap is a
hard limit on the returned result, enforced while reading the response —
PostgreSQL offers no way to bound `ts_headline` output before generating it, so
keep `MaxFragments`/`MaxWords` modest for large batches. Exceeding any bound
raises `program_limit_exceeded`.

> **Sanitize the output.** `ts_headline` returns PostgreSQL's own markup — by
> default `<b>` and `</b>` around matches, and the surrounding source text is
> reproduced verbatim. pgContext does not escape it. Escape or sanitize the
> result for your output context (HTML, terminal, JSON-in-attribute, …) before
> rendering it.

## Inspecting registrations

```sql
SELECT * FROM pgcontext.lexical_sources('articles');
SELECT * FROM pgcontext.fuzzy_sources('articles');
SELECT stage, detail, strategy FROM pgcontext.explain('articles', 'article');
```

Registration metadata lives in private catalog tables and is exposed only
through membership-filtered security-barrier views, so a non-member observes
nothing. Registration, index attachment, and drops require collection ownership;
every path additionally requires source-relation `SELECT`.

Membership and source-table privilege are both evaluated against the session
role (`SESSION_USER`), consistently with the rest of pgContext's catalog views.
`SET ROLE` therefore does not narrow which registrations are *listed*, though
the source rows a query can read are still governed by PostgreSQL's own ACLs and
RLS at execution time.

## Dump, restore, and drift

Catalog rows store both stable names and resolved OIDs. After a dump/restore or
a source-table rewrite, re-derive the OIDs:

```sql
SELECT pgcontext.refresh_lexical_catalog('articles');
```

Rows whose stable names no longer resolve to a compatible object are left
untouched and fail closed at query time. Column number, type, collation,
text-search configuration, relation, index expression or key, and operator-class
drift are each detected before Q1 execution starts.

## Limits

| Bound | Value |
| --- | --- |
| Registered fields per lexical document | 16 |
| JSON path components per field | 16 |
| Query text bytes | 4096 |
| Lexical query nodes | 256 |
| Lexical query depth | 16 |
| Boolean clauses per node | 64 |
| Phrase distance | 16384 |
| Headline points / option bytes / source bytes / output bytes | 1000 / 4096 / 8 MiB / 2 MiB |

## Related

- [Hybrid retrieval](hybrid_retrieval.md) — fusing lexical with dense branches.
- [Retrieval methods](retrieval_methods.md) — where lexical sits among the
  available methods.
- [SQL API contract](api_reference.md) — contract-guaranteed signatures.
