Fuse a vector-similarity branch into #529's lexical RRF. Query embed via a
global TEI sidecar (or per-workspace provider), degrading transparently to the
byte-identical Phase-A lexical path on any failure; a kill-switch and env knobs
gate it.
- ai.service: resolveEmbeddingProvider (workspace→global TEI fallback) with a
deterministic config fingerprint; embedQuery (query prefix + short 800ms
timeout); extract embedWithModel core shared with embedTexts.
- page-embedding.repo: vectorCandidateArm fragment (page-level NN, dim +
active-fingerprint filtered) + fingerprint on insertChunks.
- search.service: 3-branch RRF over lexical ∪ vector candidates; try/catch wraps
only the embed (permission filter stays outside, fail-closed); semantic
degrade emits search.semantic.degraded; response gains semantic{state,available,reason}.
- indexer: resolve provider, prepend doc prefix, stamp fingerprint per row.
- migration 20260712T120000: add nullable page_embeddings.fingerprint + composite index.
- infra: TEI embeddings sidecar in docker-compose + EMBEDDING_*/SEARCH_* in .env.example.
- tests: 7 semantic int cases (vector-only hit, sidecar-down, no-provider,
permission-over-union fail-closed, hung sidecar, lexical∪vector de-dup,
fingerprint isolation) + fingerprint/prefix/timeout unit tests.
total now = permission-filtered size of (lexical ∪ vector top-N) — a documented
change from Phase A's exact lexical count (falls back to it on degrade).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>