Skip to content

feat(engine): HNSW probe widened to cosine + dot via per-index metric (SQLR-28)#113

Merged
joaoh82 merged 1 commit into
mainfrom
feat/hnsw-probe-cosine-dot
May 8, 2026
Merged

feat(engine): HNSW probe widened to cosine + dot via per-index metric (SQLR-28)#113
joaoh82 merged 1 commit into
mainfrom
feat/hnsw-probe-cosine-dot

Conversation

@joaoh82
Copy link
Copy Markdown
Owner

@joaoh82 joaoh82 commented May 8, 2026

Summary

  • Phase 7d.2's try_hnsw_probe was L2-only, so any KNN query using vec_distance_cosine or vec_distance_dot silently fell through to brute-force even with an HNSW index attached. Surfaced by the SQLR-23 v2 W10 bench (~181 ms HNSW vs ~129 ms brute-force on a cosine hot loop because the graph was never being touched).
  • Adds per-index distance metric: CREATE INDEX … USING hnsw (col) WITH (metric = '<l2|cosine|dot>'). Defaults to L2 when omitted, so pre-SQLR-28 catalogs round-trip byte-identical. The metric round-trips via the synthesized CREATE INDEX SQL in sqlrite_master — no file format bump.
  • try_hnsw_probe recognises all three vec_distance_* functions and only fires when the index's metric matches the query's function; mismatches fall through to brute-force (correct, just slow).
  • Typo'd metric names error at CREATE INDEX time rather than silently defaulting to L2 — that silent fallback is exactly the bug being fixed.
  • W10 bench bumped to v3 (WITH (metric = 'cosine')); v1/v2 numbers are not comparable to v3 and have been retired in benchmarks/README.md.

Unblocks SQLR-25 (republish v2/v3 bench numbers).

Approach (option (a) from the SQLR-28 ticket)

Per-index metric persisted via the CREATE INDEX SQL, not via a file format bump:

  1. New src/sql/dialect.rsSqlriteDialect wraps sqlparser's SQLiteDialect, overrides only supports_create_index_with_clause = true so the parser accepts the WITH clause.
  2. HnswIndexEntry grows a metric: DistanceMetric field. create_hnsw_index, rebuild_hnsw_index, and rebuild_dirty_hnsw_indexes all consume the per-entry metric instead of hard-coded L2.
  3. synthesize_hnsw_create_index_sql appends WITH (metric = '…') for non-L2 entries; L2 omits the clause to keep the pre-SQLR-28 round-trip identical.

Test plan

  • cargo build --workspace (excl. desktop / python / nodejs / benchmarks)
  • cargo test --workspace — 481 engine + 12 main + 20 ask + 19 mcp + … all green
  • cargo fmt --all -- --check
  • cargo clippy --workspace --all-targets — clean (only pre-existing FFI doc warnings)
  • New tests:
    • cosine_self_query_through_hnsw_optimizerWITH (metric = 'cosine') index + cosine query returns the self-vector as nearest
    • dot_self_query_through_hnsw_optimizer — same shape for dot
    • metric_mismatch_falls_back_to_brute_force — L2 index + cosine query still returns correct nearest
    • unknown_metric_name_is_rejected'cosin' errors at CREATE INDEX time
    • with_metric_on_btree_is_rejected — WITH on a non-HNSW index errors
    • round_trip_preserves_hnsw_cosine_metric — save + reopen restores the cosine metric on the loaded entry

Follow-ups

  • SQLR-25 can now run W10.v3 — the HNSW variant should drop from the v2 ~181 ms back into single-digit ms once the graph shortcut actually fires.
  • The m / ef_construction / ef_search knobs from Phase 7 plan Q2 remain deferred. SQLR-28 only landed the metric knob.

🤖 Generated with Claude Code

… (SQLR-28)

Phase 7d.2's `try_hnsw_probe` was L2-only, so any KNN query using
`vec_distance_cosine` or `vec_distance_dot` silently fell through to
brute-force even with an HNSW index attached. Surfaced by the SQLR-23
v2 W10 bench: the HNSW variant clocked ~181 ms vs ~129 ms for
brute-force because the cosine hot loop never touched the graph.

Lands as sub-phase 7d.4 — per-index distance metric, no file format
bump (the metric round-trips via the synthesized CREATE INDEX SQL in
`sqlrite_master`):

- New SQL surface: `CREATE INDEX … USING hnsw (col) WITH (metric =
  '<l2|cosine|dot>')`. Omitting the WITH clause defaults to L2,
  so pre-SQLR-28 catalogs round-trip byte-identical. Typo'd metric
  names error at CREATE INDEX time rather than silently defaulting
  to L2 — that silent fallback is exactly what we're fixing.
- New `SqlriteDialect` (wraps sqlparser's `SQLiteDialect`, only
  override is `supports_create_index_with_clause = true`).
- `HnswIndexEntry` grows a `metric: DistanceMetric` field; the load,
  rebuild, and dirty-rebuild paths all consume the per-entry metric
  instead of hard-coded L2.
- `try_hnsw_probe` widens to all three `vec_distance_*` functions
  and only fires when the index entry's metric matches the query
  function. Mismatch → brute-force fallback (correct, just slow).
- W10 bench bumped to v3; the HNSW variant creates the index
  `WITH (metric = 'cosine')`. v1/v2 numbers are not comparable.
- Tests: cosine + dot self-query through the optimizer,
  metric-mismatch fallback, unknown-metric rejection, WITH-on-btree
  rejection, save+reopen preserves cosine metric.

Unblocks SQLR-25 (republish v2/v3 bench numbers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@joaoh82 joaoh82 merged commit ac84d56 into main May 8, 2026
15 checks passed
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.

1 participant