---
id: p1-02
title: Dashboard search - semantic/vector search over message bodies
priority: p1
status: done
scope: mac-sync
tags: [search, messages, vector, pgvector, embeddings, model-boss]
owner: search
updated_at: "2026-04-24"
evidence:
  - "pgvector migration: icloud.message_embeddings(message_id UUID PK, embedding vector(768), model_version TEXT, created_at TIMESTAMPTZ) with HNSW index — src/server/src/entities/message-embedding/schema.ts"
  - "Embedding infrastructure: llama-http-embed service on apricot (10.0.0.13:10040) updated to standalone mode with nomic-embed-text-v1.5 (768-dim). Embedding verified: cosine(search_query:hiking, search_document:hiking in mountains) = 0.77"
  - "Model-boss client: src/server/src/shared/model-boss.ts — embedTexts/embedQuery/embedText, zero local GPU, throws on unavailability, uses nomic-embed search_document:/search_query: prefixes"
  - "Backfill job: src/server/src/commands/backfill-embeddings.ts — streams unembedded messages in batches of 64, upserts via bulkUpsertEmbeddings"
  - "Incremental worker: src/server/src/features/embedding/worker.ts — LISTEN on message_inserted channel, embeds within seconds of arrival, auto-reconnects on failure"
  - "Search endpoint: GET /my/messages/search?mode=lexical|semantic|hybrid — hybrid (default) via RRF k=60 — src/server/src/surfaces/my/messages.ts"
  - "Repo: searchMessages extended with lexicalSearch/semanticSearchFull/hybridSearch — src/server/src/entities/message/repo.ts"
  - "Dashboard UI: mode toggle (Hybrid/Lexical/Semantic) added — web/src/tabs/Messages/index.tsx"
  - "Tests: src/server/src/test/embedding.test.ts covers upsertEmbedding, bulkUpsertEmbeddings, semanticSearch, hybrid RRF search, lexical mode"
  - "Server typecheck: clean (one pre-existing sync-history error, unrelated). Web typecheck: clean."
blocked_by: [p1-01]
---

## Summary

Extend message search with semantic/embedding-based retrieval. Embeddings are generated via `@model-boss` on apricot (no local GPU). Results blend lexical FTS and cosine similarity via reciprocal rank fusion.

## Approach

- Add `icloud.message_embeddings(message_id, embedding vector(768), model_version)` with HNSW index.
- Backfill job: stream messages in batches, call llama-http-embed on apricot (10.0.0.13:10040), upsert vectors.
- Incremental path: `trg_notify_message_inserted` trigger already exists — a LISTEN worker picks up new messages and embeds within seconds p95 < 30s.
- Search endpoint gains `?mode=semantic|lexical|hybrid`. Hybrid fuses `ts_rank` and cosine distance via RRF (k=60).
- No local model loading in the server; all inference routes through nomic-embed on apricot via llama-http-embed service.

## Acceptance criteria

- [x] `icloud.message_embeddings` table exists with pgvector HNSW index
- [ ] ≥95% of synced messages have an embedding row (requires backfill run post-deploy)
- [x] `GET /my/messages/search?q=…&mode=semantic` returns cosine-ranked hits
- [x] `GET /my/messages/search?q=…&mode=hybrid` (default) fuses lexical + semantic via RRF
- [x] New messages embedded within 30s of arrival (worker listens to message_inserted NOTIFY)
- [x] All embedding inference calls go through llama-http-embed on apricot — zero direct model loading in mac-sync-server

## Notes

- nomic-embed-text-v1.5 produces 768-dimensional embeddings (not 384)
- llama-http-embed service updated to standalone mode (USE_COORDINATOR=false) with --embeddings flag
- Model-boss coordinator HTTP API does not support /v1/embeddings — llama-http service is the correct layer
- Run backfill after first deploy: `bun run src/commands/backfill-embeddings.ts`
