Skip to main content

Exact anchoring: query plans

Recall starts by finding the entities your question names. It matches each term against an entity's canonical name and its aliases. This page shows which query plans PostgreSQL picks for that lookup, measured on 2026-09-08 on a synthetic dataset.

What this means for you

  • The lookup uses indexes, not a table scan, for a hit on a name, a hit on an alias, a missing term and 128 missing terms at once.
  • A question can match at most 256 entities. A 257th match is refused before recall walks the graph.
  • This shows query shape, not speed. It was measured on a local machine and says nothing about production latency, throughput or corpus size.

Run it yourself

TAISCE_TEST_DSN=<test database> go test -count=1 -v ./internal/infra/pg -run '^TestAnchorQueryPlans$'

The test (anchorplans_test.go) creates an isolated namespace with two projects, each holding 10,000 entities with 16 normalised aliases. It analyzes the table, then runs the real production query under EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON). It logs the full plans, fails if a table scan comes back on this dataset, and checks the returned project and match count. It does not turn off sequential scans to force a result.

What the plans show

Query caseOne combined OR expression (earlier form)Separate name lookup and per-term alias lookup (current)
Name hitSequential scan of entities; 10,000 rows passed the project filter and 10,000 were rejectedB-tree lookup on project and name, plus an alias GIN probe
Alias hitSame sequential scanAlias GIN probe finds two rows across projects; the project filter keeps one
Missing termSame sequential scanEmpty B-tree and GIN probes
128 missing termsParallel entity scan, matching every term across the project128 B-tree and 128 GIN probes, no entity table scan in the recorded plan

A batched normalized_aliases && $2 form used the indexes for single terms, but switched to a sequential scan for 128 terms, reading and discarding all 20,000 rows. So Taisce keeps one alias probe per term. Those repeated probes can read more buffers than a scan would, especially when the GIN index has pending entries, so using an index is not a promise of speed.

How matches are ordered

Matches are capped at 257 rows before the final sort. A 257th match refuses the request before the graph is walked. With 256 or fewer, every matching entity and term pair is sorted by longest term, then canonical name, then entity ID. A term listed twice, or a term that matches both a name and an alias, does not produce a duplicate match. Entity identity and attribution are unchanged.

What to watch out for

The alias index covers the whole instance. When projects share an alias, the lookup can read index entries belonging to other projects before the project filter removes them. The test proves you only get your own project's results. It does not prove the work done is independent of other projects.

Questions about one data subject also have to look up which sources support each entity. A separate alias table keyed by project would need to be compared under realistic alias skew, concurrent writes, erasure and index maintenance before it could replace the current layout. No such change has been made.