feat(search): semantic / related-message search (LC-549) #519

Merged
longjacksonle merged 7 commits from feat/LC-549-semantic-search into main 2026-07-07 19:24:47 +02:00

Embeddings-backed search that surfaces conceptually-similar messages keyword FTS misses. Two features light up when an operator points LETS_CHAT_EMBEDDINGS_URL at an OpenAI-compatible /v1/embeddings endpoint; both are hidden and search stays on FTS when it is absent.

A "Find related" item in the message overflow menu ranks other messages in the same room by cosine similarity to that one and shows the nearest few in the shared #thread-panel slot as jump links. The source vector is the stored embedding, or embedded on demand if the background populator has not reached the message yet; hits below a similarity floor are dropped so an empty result reads honestly.

2. Opt-in semantic search mode

A "Semantic" toggle on the room-header search box ranks the query against the room's stored embeddings instead of FTS keywords. On any miss (embeddings not configured, the query cannot be embedded, a DB error) it falls through to the existing FTS path, so search always returns something. The DM/room label mapping is extracted into a shared render_results helper used by both search paths.

Infrastructure

  • embeddings.rs: same operator-config posture as the LLM client (LC-396). EmbeddingClient trait, a reqwest /v1/embeddings impl via the trusted-outbound helper (a localhost embedder works), a deterministic hashed mock for tests, plus cosine_similarity and little-endian f32 vec<->bytes helpers.
  • Storage: a message_embeddings sidecar table (one f32-BLOB per message, ON DELETE CASCADE) rather than a column on messages, keeping the hot message SELECT surface and RawMessage mapping untouched (the column-drift trap from LC-547). A hard-deleted or self-destructed (LC-547) message drops its embedding automatically.
  • Population: new messages are embedded best-effort in a background task off the send path, so an embeddings-endpoint failure never fails a send and just means the message will not surface in semantic results.

Trust / privacy posture

Reached through http_client::outbound_trusted_post (not public-IP SSRF-filtered, so a local engine works). Message text leaves the device only when an operator has configured an endpoint. Ranking is a Rust cosine scan of one room's vectors at a time, fine at self-host scale.

Tests

  • Unit (CI/lib): cosine properties, byte round-trip, mock ranks shared-word text higher.
  • Integration routes_related_search.rs: related surfaces the near message and drops the unrelated one below the floor; semantic mode ranks by meaning; no-embeddings related endpoint is refused.
  • Regenerated the LC-77 webhook/email render fixtures for the new embeddings-gated (CSS-hidden) menu button.

Scope notes / follow-ups

  • Semantic mode is scoped to the room-header box (room access is a single check, candidate set is one room). Home/enclave-wide semantic ranking needs an access-resolved cross-room candidate set and is a natural follow-up; the infra here (client, storage, ranking, degradation) is exactly what it would build on.
  • No backfill of embeddings for pre-existing messages: only messages sent after an endpoint is configured are embedded (plus on-demand for a related source). A one-off backfill sweep is a follow-up.

Gates: just check, full just test, just test-saas all green.

🤖 Generated with Claude Code

https://claude.ai/code/session_01S5obszLoUgUi8qaF7Hi5qz

## LC-549: Semantic / related-message search (Round 4, Vein B - search, reach item) Embeddings-backed search that surfaces conceptually-similar messages keyword FTS misses. Two features light up when an operator points `LETS_CHAT_EMBEDDINGS_URL` at an OpenAI-compatible `/v1/embeddings` endpoint; both are hidden and search stays on FTS when it is absent. ### 1. "Find related" (per message) A "Find related" item in the message overflow menu ranks other messages in the same room by cosine similarity to that one and shows the nearest few in the shared `#thread-panel` slot as jump links. The source vector is the stored embedding, or embedded on demand if the background populator has not reached the message yet; hits below a similarity floor are dropped so an empty result reads honestly. ### 2. Opt-in semantic search mode A "Semantic" toggle on the room-header search box ranks the query against the room's stored embeddings instead of FTS keywords. On any miss (embeddings not configured, the query cannot be embedded, a DB error) it falls through to the existing FTS path, so search always returns something. The DM/room label mapping is extracted into a shared `render_results` helper used by both search paths. ### Infrastructure - `embeddings.rs`: same operator-config posture as the LLM client (LC-396). `EmbeddingClient` trait, a reqwest `/v1/embeddings` impl via the trusted-outbound helper (a localhost embedder works), a deterministic hashed mock for tests, plus `cosine_similarity` and little-endian f32 vec<->bytes helpers. - Storage: a `message_embeddings` sidecar table (one f32-BLOB per message, `ON DELETE CASCADE`) rather than a column on `messages`, keeping the hot message SELECT surface and `RawMessage` mapping untouched (the column-drift trap from LC-547). A hard-deleted or self-destructed (LC-547) message drops its embedding automatically. - Population: new messages are embedded best-effort in a background task off the send path, so an embeddings-endpoint failure never fails a send and just means the message will not surface in semantic results. ### Trust / privacy posture Reached through `http_client::outbound_trusted_post` (not public-IP SSRF-filtered, so a local engine works). Message text leaves the device only when an operator has configured an endpoint. Ranking is a Rust cosine scan of one room's vectors at a time, fine at self-host scale. ### Tests - Unit (CI/lib): cosine properties, byte round-trip, mock ranks shared-word text higher. - Integration `routes_related_search.rs`: related surfaces the near message and drops the unrelated one below the floor; semantic mode ranks by meaning; no-embeddings related endpoint is refused. - Regenerated the LC-77 webhook/email render fixtures for the new embeddings-gated (CSS-hidden) menu button. ### Scope notes / follow-ups - Semantic mode is scoped to the room-header box (room access is a single check, candidate set is one room). Home/enclave-wide semantic ranking needs an access-resolved cross-room candidate set and is a natural follow-up; the infra here (client, storage, ranking, degradation) is exactly what it would build on. - No backfill of embeddings for pre-existing messages: only messages sent after an endpoint is configured are embedded (plus on-demand for a related source). A one-off backfill sweep is a follow-up. Gates: `just check`, full `just test`, `just test-saas` all green. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01S5obszLoUgUi8qaF7Hi5qz
Add an optional text-embeddings client mirroring the LLM client's operator-config posture (LlmConfig/LC-396): EmbeddingsConfig::from_env (LETS_CHAT_EMBEDDINGS_URL/_API_KEY/_MODEL), an EmbeddingClient trait, a reqwest impl that POSTs /v1/embeddings through the trusted-outbound helper (so a localhost embedder works), and a deterministic hashed-bag-of-words MockEmbeddingClient for tests. Plus cosine_similarity and little-endian f32 vec<->bytes helpers for storage and nearest-neighbour ranking. Unit tests cover cosine properties, byte round-trip, and that the mock ranks shared-word text higher.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5obszLoUgUi8qaF7Hi5qz
Add the message_embeddings sidecar table (one f32-BLOB vector per message, ON DELETE CASCADE to its message) rather than a column on messages, keeping the hot message SELECT surface and RawMessage mapping untouched (the column-drift trap from LC-547). db::message_embeddings provides upsert/get/exists/list_for_room over decoded vectors. db::chat::search_results_for_ids fetches SearchResult rows for a ranked id set within one room, preserving rank order and dropping deleted/quarantined rows, so semantic ranking can reuse the existing result rendering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5obszLoUgUi8qaF7Hi5qz
Add AppState.embedding_client (Some when LETS_CHAT_EMBEDDINGS_URL is set) and an embeddings_available() helper, constructed in main.rs from EmbeddingsConfig::from_env. Absent by default, so the features stay hidden and search keeps to the FTS keyword path. Backfill embedding_client: None across every test AppState construction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5obszLoUgUi8qaF7Hi5qz
Add GET /messages/{id}/related: rank other messages in the same room by cosine similarity to this one and render the nearest few into the shared #thread-panel slot (jump links, like search). The source vector is the stored embedding, or embedded on demand if the background populator has not reached it; hits below a similarity floor are dropped so an empty result reads honestly. New messages are embedded best-effort in a background task off the send path (related::embed_message), so a failure never fails a send. The menu item and the page-root data-lc-embeddings gate (CSS mirrors data-lc-llm) hide the feature when no embeddings endpoint is configured. Regenerate the LC-77 webhook/email fixtures for the new (embeddings-gated, CSS-hidden) menu button.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5obszLoUgUi8qaF7Hi5qz
Add a "Semantic" toggle to the room-header search: when checked (and an embeddings endpoint is configured), the query is embedded and ranked against the room's stored message embeddings instead of FTS keywords, surfacing conceptually similar posts keyword search misses. Any miss - embeddings not configured, the query cannot be embedded, or a DB error - falls through to the existing FTS path, so search always returns something. The results-building (DM/room label mapping) is extracted into a shared render_results helper used by both paths; the toggle is CSS-hidden unless embeddings are configured.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5obszLoUgUi8qaF7Hi5qz
Add room-msg-related, related-heading/subheading/empty and partials-room-search-semantic in en and es, keeping the catalog parity the i18n_catalog test enforces.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5obszLoUgUi8qaF7Hi5qz
test(routes): related + semantic search integration coverage (LC-549)
All checks were successful
check-secrets / Kingfisher (push) Successful in 5s
check-secrets / Nosey parker (push) Successful in 5s
check-secrets / TruffleHog (push) Successful in 5s
check-secrets / Nosey parker (pull_request) Successful in 8s
check-secrets / TruffleHog (pull_request) Successful in 9s
check-secrets / Kingfisher (pull_request) Successful in 12s
Check / clippy + fmt + tests (pull_request) Successful in 7m3s
Create release / Create release from merged PR (pull_request) Has been skipped
0fdb724f38
Drive the feature over HTTP with the deterministic mock embedder: /messages/{id}/related surfaces the shared-word message and drops the unrelated one below the similarity floor; semantic search mode ranks by meaning; and with no embeddings client the related endpoint is refused.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5obszLoUgUi8qaF7Hi5qz
longjacksonle deleted branch feat/LC-549-semantic-search 2026-07-07 19:24:47 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!519
No description provided.