Summary
The ANN rewrite fires only when the query spells the table with a fully qualified
three-part reference (catalog.schema.table). A two-part (schema.table) or bare
(table) reference silently misses the index lookup and falls back to a full scan —
correct results, no error, no warning, no log line.
The registry key is built from the table reference exactly as written in the query,
while registration keys the index by its resolved name. Only one of the two sides
normalizes, so the keys agree only by accident of how the user typed the FROM clause.
Reproduction
Verified against production (api.hotdata.dev, workspace roh-workspace) on a managed
database table with a status=ready cosine index over a 1536-dim List(Float32) column.
Same index, same query, same session — only the FROM clause differs:
FROM clause |
Plan |
public.clean_docs |
DataSourceExec … file_type=liquid_parquet — full scan |
"default"."public"."clean_docs" |
USearchExec: k=5, filtered=false |
The two-part form was re-run after the three-part form succeeded, so the index was
loaded and warm in the registry. It still full-scanned. This is deterministic on the
shape of the reference, not a lazy-load or cache-warmth artifact.
Root cause
Two sides must agree on a registry key, and only one normalizes.
Lookup (this crate) — does not normalize. The rule builds its key from the raw
TableReference carried in the plan. table_ref_to_str (src/rule.rs:390) joins
whichever parts happen to be present:
Full → catalog::schema::table (3 parts)
Partial → schema::table (2 parts)
Bare → table (1 part)
src/rule.rs:209 then appends the column, producing a 4-, 3-, or 2-part key.
DataFusion preserves the reference as written — it resolves the name to find the table
provider, but does not rewrite TableScan.table_name into a canonical form. Observable
in the two plans above: TableScan: public.clean_docs vs
TableScan: default.public.clean_docs.
Registration (runtimedb) — normalizes. refresh_for_tables
(runtimedb/src/vector/mod.rs:1373) calls
normalize_table_ref(table_ref, default_catalog, default_schema) to fill in the missing
catalog/schema, translates the visible catalog alias to the real connection name, and
registers under a always-4-part key (mod.rs:1686):
__db_h4tn5esw2roy7zg4sh1fqv8rov::public::clean_docs::embedding
So a two-part reference produces public::clean_docs::embedding and misses the map.
It never even reaches the alias translation: ScopedVectorIndexResolver::translate
(mod.rs:1211) only rewrites keys of exactly 4 parts and passes anything shorter through
untouched (mod.rs:1213).
peek returns None, build_rewrite bails at src/rule.rs:212, and because that is the
same return path used for legitimate declines (metric mismatch, DESC, SELECT *,
stacked filters), nothing is logged.
Fix
Resolve the table reference against the session defaults before building the key, so all
three written forms collapse to the same canonical 4-part key and the lookup side becomes
symmetric with the registration side.
DataFusion provides both halves already, and the rule currently discards them:
TableReference::resolve(default_catalog, default_schema) returns a
ResolvedTableReference, which is three parts by construction.
OptimizerConfig::options() exposes catalog.default_catalog and
catalog.default_schema; runtimedb sets both per query
(runtimedb/src/engine/query.rs:1179-1180).
The rule already receives that config and throws it away — the parameter is named
_config at src/rule.rs:344. It needs threading from rewrite() through try_match
into build_rewrite.
Note this makes index matching agree with table resolution; it does not make more names
valid. A bare reference still resolves against the default schema, so on a database whose
default schema is main while the table lives in public, FROM clean_docs continues to
miss — exactly as plain SQL does.
Also worth fixing: the silence
The miss is indistinguishable from a legitimate decline. A debug! (or trace!) on
"distance shape matched but no index registered under key X" would have made this a
five-minute diagnosis. Worth adding alongside the fix, independent of it.
Impact
Anything that emits a short reference silently loses acceleration:
- hand-written SQL via the CLI or
execute_sql
- LLM-authored SQL in agent tool surfaces — notably
hotdata-langchain's
tools.py:73, whose prompt currently tells the model "schema-qualified names also
resolve", which is true for correctness and misleading for performance
(tracked separately in hotdata-langchain)
Not affected, both verified by EXPLAIN against production:
hotdata-ibis — always emits the catalog explicitly
(sg.table(table_name, db=schema_name, catalog=conn), backend.py:455)
hotdata-langchain's vectorstore and search paths — table_ref hardcodes the
three-part form (vectorstore.py:171), and search.py:28 rejects anything else
Summary
The ANN rewrite fires only when the query spells the table with a fully qualified
three-part reference (
catalog.schema.table). A two-part (schema.table) or bare(
table) reference silently misses the index lookup and falls back to a full scan —correct results, no error, no warning, no log line.
The registry key is built from the table reference exactly as written in the query,
while registration keys the index by its resolved name. Only one of the two sides
normalizes, so the keys agree only by accident of how the user typed the
FROMclause.Reproduction
Verified against production (
api.hotdata.dev, workspaceroh-workspace) on a manageddatabase table with a
status=readycosine index over a 1536-dimList(Float32)column.Same index, same query, same session — only the
FROMclause differs:FROMclausepublic.clean_docsDataSourceExec … file_type=liquid_parquet— full scan"default"."public"."clean_docs"USearchExec: k=5, filtered=falseThe two-part form was re-run after the three-part form succeeded, so the index was
loaded and warm in the registry. It still full-scanned. This is deterministic on the
shape of the reference, not a lazy-load or cache-warmth artifact.
Root cause
Two sides must agree on a registry key, and only one normalizes.
Lookup (this crate) — does not normalize. The rule builds its key from the raw
TableReferencecarried in the plan.table_ref_to_str(src/rule.rs:390) joinswhichever parts happen to be present:
Full→catalog::schema::table(3 parts)Partial→schema::table(2 parts)Bare→table(1 part)src/rule.rs:209then appends the column, producing a 4-, 3-, or 2-part key.DataFusion preserves the reference as written — it resolves the name to find the table
provider, but does not rewrite
TableScan.table_nameinto a canonical form. Observablein the two plans above:
TableScan: public.clean_docsvsTableScan: default.public.clean_docs.Registration (runtimedb) — normalizes.
refresh_for_tables(
runtimedb/src/vector/mod.rs:1373) callsnormalize_table_ref(table_ref, default_catalog, default_schema)to fill in the missingcatalog/schema, translates the visible catalog alias to the real connection name, and
registers under a always-4-part key (
mod.rs:1686):So a two-part reference produces
public::clean_docs::embeddingand misses the map.It never even reaches the alias translation:
ScopedVectorIndexResolver::translate(
mod.rs:1211) only rewrites keys of exactly 4 parts and passes anything shorter throughuntouched (
mod.rs:1213).peekreturnsNone,build_rewritebails atsrc/rule.rs:212, and because that is thesame return path used for legitimate declines (metric mismatch,
DESC,SELECT *,stacked filters), nothing is logged.
Fix
Resolve the table reference against the session defaults before building the key, so all
three written forms collapse to the same canonical 4-part key and the lookup side becomes
symmetric with the registration side.
DataFusion provides both halves already, and the rule currently discards them:
TableReference::resolve(default_catalog, default_schema)returns aResolvedTableReference, which is three parts by construction.OptimizerConfig::options()exposescatalog.default_catalogandcatalog.default_schema; runtimedb sets both per query(
runtimedb/src/engine/query.rs:1179-1180).The rule already receives that config and throws it away — the parameter is named
_configatsrc/rule.rs:344. It needs threading fromrewrite()throughtry_matchinto
build_rewrite.Note this makes index matching agree with table resolution; it does not make more names
valid. A bare reference still resolves against the default schema, so on a database whose
default schema is
mainwhile the table lives inpublic,FROM clean_docscontinues tomiss — exactly as plain SQL does.
Also worth fixing: the silence
The miss is indistinguishable from a legitimate decline. A
debug!(ortrace!) on"distance shape matched but no index registered under key X" would have made this a
five-minute diagnosis. Worth adding alongside the fix, independent of it.
Impact
Anything that emits a short reference silently loses acceleration:
execute_sqlhotdata-langchain'stools.py:73, whose prompt currently tells the model "schema-qualified names alsoresolve", which is true for correctness and misleading for performance
(tracked separately in hotdata-langchain)
Not affected, both verified by
EXPLAINagainst production:hotdata-ibis— always emits the catalog explicitly(
sg.table(table_name, db=schema_name, catalog=conn),backend.py:455)hotdata-langchain's vectorstore and search paths —table_refhardcodes thethree-part form (
vectorstore.py:171), andsearch.py:28rejects anything else