feat: Phase 2 — embeddings + hybrid memory.search

Adds a small embedder sidecar (Xenova/bge-small-en-v1.5, ONNX, CPU-only)
that the web app calls inline on memory.write and memory.update, and on
demand from the new memory.search tool.

memory.search performs three candidate fetches in parallel — pgvector
cosine similarity, Postgres full-text via plainto_tsquery + ts_rank_cd,
and tag-set overlap — then fuses them with Reciprocal Rank Fusion
(k=60). Each result carries its per-source rank so the model can see
*why* a memory surfaced.

The migrator boot step gained an idempotent embedding backfill: any row
with embedding IS NULL is batched (32 at a time) through the embedder
after SQL migrations apply. Safe to run on every boot.

New tool memory.update fixes the missing edit path; centralises the
re-embed-on-content-change rule alongside write.

Stack additions:
- apps/embedder/ — Fastify server, persistent /data/models volume so the
  ~30 MB model only downloads once
- apps/web/lib/embedder.ts — typed HTTP client with batched embed +
  health probe
- packages/schemas — MemoryUpdateInput, MemorySearchInput
- docker-compose — embedder service, healthcheck, app + migrator both
  depend_on it healthy; EMBEDDER_URL promoted to a required env var

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 09:04:44 -07:00
co-authored by Claude Opus 4.7
parent e1fa1197f4
commit 9a8b504f51
12 changed files with 1523 additions and 19 deletions
+33 -3
View File
@@ -41,8 +41,32 @@ services:
networks:
- internal
# One-shot migration runner. Exits 0 when migrations are up-to-date;
# `app` waits on its successful completion before starting.
# Embedding sidecar — loads bge-small-en-v1.5 once and serves /embed.
# First boot downloads the model (~30 MB) into a named volume so future
# boots are warm.
embedder:
image: ${EMBEDDER_IMAGE_REF:-shared-memory-embedder:local}
build:
context: .
dockerfile: apps/embedder/Dockerfile
restart: unless-stopped
environment:
EMBEDDING_MODEL: ${EMBEDDING_MODEL:-Xenova/bge-small-en-v1.5}
EMBEDDING_DIM: ${EMBEDDING_DIM:-384}
LOG_LEVEL: ${LOG_LEVEL:-info}
volumes:
- embedder_models:/data/models
healthcheck:
test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:8080/health | grep -q '\"ready\":true' || exit 1"]
interval: 15s
timeout: 5s
retries: 5
start_period: 180s
networks:
- internal
# One-shot migration runner + embedding backfill. Exits 0 when both are
# up-to-date; `app` waits on its successful completion before starting.
migrator:
image: ${IMAGE_REF:-shared-memory-web:local}
build:
@@ -52,8 +76,11 @@ services:
depends_on:
db:
condition: service_healthy
embedder:
condition: service_healthy
environment:
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
EMBEDDER_URL: ${EMBEDDER_URL:-http://embedder:8080}
command: ["node", "apps/web/migrate.mjs"]
networks:
- internal
@@ -67,6 +94,8 @@ services:
depends_on:
db:
condition: service_healthy
embedder:
condition: service_healthy
migrator:
condition: service_completed_successfully
environment:
@@ -87,7 +116,7 @@ services:
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
EMBEDDER_URL: ${EMBEDDER_URL:-}
EMBEDDER_URL: ${EMBEDDER_URL:-http://embedder:8080}
EMBEDDING_MODEL: ${EMBEDDING_MODEL:-Xenova/bge-small-en-v1.5}
EMBEDDING_DIM: ${EMBEDDING_DIM:-384}
@@ -137,6 +166,7 @@ volumes:
db_data:
caddy_data:
caddy_config:
embedder_models:
networks:
internal: