U5: Semantic cache + pgvector + indexes#10
Conversation
Adds pgvector extension, semantic_cache table (ivfflat cosine index), and three WHERE-predicate/covering indexes for hot-path queries (health cooldowns, recent gateway logs, passed exams). New semantic-cache.ts library provides graceful lookup/store via local Ollama nomic-embed-text; all operations no-op on failure. Library is not yet wired into the chat completions route — follow-up. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds an optional Postgres-backed semantic cache using pgvector (Ollama embeddings + cosine similarity) and introduces several new “hot path” DB indexes to improve query performance, while aiming to fail gracefully when pgvector/Ollama are unavailable.
Changes:
- Add
src/lib/semantic-cache.tswith embedding, similarity lookup, idempotent storage, and cache stats helpers. - Extend DB migrations to provision
pgvector+semantic_cache(ivfflat cosine index) with fallback logging. - Add new performance indexes on
health_logs,gateway_logs, andexam_attempts.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| src/lib/semantic-cache.ts | New semantic cache library: embeds via Ollama and queries/stores in semantic_cache. |
| src/lib/db/migrate.ts | Migration adds pgvector + semantic_cache table/indexes and additional performance indexes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const json = await res.json(); | ||
| const embedding = json.embedding as number[] | undefined; | ||
| return Array.isArray(embedding) && embedding.length > 0 ? embedding : null; | ||
| } catch { |
There was a problem hiding this comment.
embedQuery accepts any embedding length, but the DB column is vector(768). If Ollama returns a different dimension (model change, config, API change), every store/lookup will fail and silently no-op. Add an explicit embedding.length === 768 (and optionally finite-number) validation before returning/using the embedding so failures are fast and diagnosable.
| // Update hit count + last_used_at (fire and forget) | ||
| sql` | ||
| UPDATE semantic_cache | ||
| SET hit_count = hit_count + 1, last_used_at = now() | ||
| WHERE query = ${row.query} | ||
| `.catch(() => {}); |
There was a problem hiding this comment.
The hit counter update keys on query, but query is not guaranteed unique (you store query.slice(0, 2000) while query_hash is computed from the full query). This UPDATE can increment multiple rows if different long queries share the same first 2000 chars. Update by a unique identifier instead (e.g., select id or query_hash and use that in the WHERE clause).
| await sql` | ||
| CREATE INDEX IF NOT EXISTS idx_health_cooldown_active | ||
| ON health_logs(model_id, cooldown_until) | ||
| WHERE cooldown_until > now() |
There was a problem hiding this comment.
This partial index predicate uses now(), which is STABLE (not IMMUTABLE). Postgres will reject it with "functions in index predicate must be marked IMMUTABLE", so the index will never be created (and you'll log a warning every startup). Use an immutable predicate (e.g., cooldown_until IS NOT NULL) or replace with a normal index and keep the time comparison in the query.
| WHERE cooldown_until > now() | |
| WHERE cooldown_until IS NOT NULL |
| CREATE INDEX IF NOT EXISTS idx_semantic_cache_embedding | ||
| ON semantic_cache USING ivfflat (embedding vector_cosine_ops) | ||
| WITH (lists = 100) | ||
| `; | ||
| await sql`CREATE INDEX IF NOT EXISTS idx_semantic_cache_used ON semantic_cache(last_used_at DESC)`; | ||
| console.log("[migrate] pgvector + semantic_cache ready"); | ||
| } catch (err) { | ||
| console.warn("[migrate] pgvector not available — semantic cache disabled:", String(err).slice(0, 150)); | ||
| } | ||
|
|
||
| // ─── Performance indexes ───────────────────────────────────────────────────── | ||
| // Note: CREATE INDEX CONCURRENTLY cannot run inside transaction, | ||
| // so we use normal CREATE INDEX IF NOT EXISTS here | ||
| await sql` | ||
| CREATE INDEX IF NOT EXISTS idx_health_cooldown_active | ||
| ON health_logs(model_id, cooldown_until) | ||
| WHERE cooldown_until > now() | ||
| `.catch((err) => console.warn("[migrate] idx_health_cooldown_active:", String(err).slice(0, 100))); | ||
|
|
||
| await sql` | ||
| CREATE INDEX IF NOT EXISTS idx_gateway_recent | ||
| ON gateway_logs(created_at DESC, status) | ||
| `.catch((err) => console.warn("[migrate] idx_gateway_recent:", String(err).slice(0, 100))); | ||
|
|
||
| await sql` | ||
| CREATE INDEX IF NOT EXISTS idx_exam_passed_recent |
There was a problem hiding this comment.
These indexes are created with plain CREATE INDEX, which can take an ACCESS EXCLUSIVE lock and block writes on large tables during startup migrations. Since runMigrations() isn't running inside an explicit transaction block, consider CREATE INDEX CONCURRENTLY (or moving index creation to an offline/one-time migration step) to reduce production impact.
| CREATE INDEX IF NOT EXISTS idx_semantic_cache_embedding | |
| ON semantic_cache USING ivfflat (embedding vector_cosine_ops) | |
| WITH (lists = 100) | |
| `; | |
| await sql`CREATE INDEX IF NOT EXISTS idx_semantic_cache_used ON semantic_cache(last_used_at DESC)`; | |
| console.log("[migrate] pgvector + semantic_cache ready"); | |
| } catch (err) { | |
| console.warn("[migrate] pgvector not available — semantic cache disabled:", String(err).slice(0, 150)); | |
| } | |
| // ─── Performance indexes ───────────────────────────────────────────────────── | |
| // Note: CREATE INDEX CONCURRENTLY cannot run inside transaction, | |
| // so we use normal CREATE INDEX IF NOT EXISTS here | |
| await sql` | |
| CREATE INDEX IF NOT EXISTS idx_health_cooldown_active | |
| ON health_logs(model_id, cooldown_until) | |
| WHERE cooldown_until > now() | |
| `.catch((err) => console.warn("[migrate] idx_health_cooldown_active:", String(err).slice(0, 100))); | |
| await sql` | |
| CREATE INDEX IF NOT EXISTS idx_gateway_recent | |
| ON gateway_logs(created_at DESC, status) | |
| `.catch((err) => console.warn("[migrate] idx_gateway_recent:", String(err).slice(0, 100))); | |
| await sql` | |
| CREATE INDEX IF NOT EXISTS idx_exam_passed_recent | |
| CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_semantic_cache_embedding | |
| ON semantic_cache USING ivfflat (embedding vector_cosine_ops) | |
| WITH (lists = 100) | |
| `; | |
| await sql`CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_semantic_cache_used ON semantic_cache(last_used_at DESC)`; | |
| console.log("[migrate] pgvector + semantic_cache ready"); | |
| } catch (err) { | |
| console.warn("[migrate] pgvector not available — semantic cache disabled:", String(err).slice(0, 150)); | |
| } | |
| // ─── Performance indexes ───────────────────────────────────────────────────── | |
| // runMigrations() is not inside an explicit transaction, so prefer | |
| // CREATE INDEX CONCURRENTLY IF NOT EXISTS to reduce write blocking during startup | |
| await sql` | |
| CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_health_cooldown_active | |
| ON health_logs(model_id, cooldown_until) | |
| WHERE cooldown_until > now() | |
| `.catch((err) => console.warn("[migrate] idx_health_cooldown_active:", String(err).slice(0, 100))); | |
| await sql` | |
| CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_gateway_recent | |
| ON gateway_logs(created_at DESC, status) | |
| `.catch((err) => console.warn("[migrate] idx_gateway_recent:", String(err).slice(0, 100))); | |
| await sql` | |
| CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_exam_passed_recent |
…ssion, latency, RPM, breaker, fallback) Tackled the audit list end-to-end. Two of the requested ten were already implemented in route.ts (empty-body retry on both proxied + relaxed-retry paths), one was deferred (undici dispatcher re-enable — Node 20 already uses undici keep-alive and the previous nvidia-hang bug has no public fix record), one was scoped out (Thai exam dataset is a separate task). Speed - #1 Client-side RPM throttle (rate-budget.ts) — Redis sliding-window per (provider, model). Skip locally before burning the upstream 429 budget, which is what triggers minutes-long cooldowns. Per-provider RPM defaults in free-model-catalog map documented free-tier limits (Groq 30, SEA-LION 10, Typhoon 200, etc.). Fail-open on Redis outage. - #2 Latency-aware sort — added SambaNova to FAST_STREAM_PROVIDERS, plus a PROVIDER_LATENCY_HINT_MS table so providers with no production data yet rank by their documented first-token latency instead of being pinned at 9999999 (which previously stranded new providers at the back forever). Reliability - #4 Fallback chain — provider-diversity interleave in reorderForLatency: the first 6 candidates round-robin across providers, so a single-provider outage no longer stalls the whole chain. Tail keeps strict latency order. - #5 Circuit breaker (3-state) — acquireCircuitProbe/releaseCircuitProbe in learning.ts adds explicit half-open: when fail_streak>=2 and cooldown has expired, only HALF_OPEN_PROBE_LIMIT concurrent probes are admitted. API exported; wiring into chat/completions left for the fallback-chain refactor follow-up. - #6 Deprecation watcher — deprecatedAfter field on FreeModelCatalogEntry. getActiveFreeModelCatalog filters EOL models from /v1/models, scanner, search, and isHardcodedFreeModel. getModelsDeprecatingSoon emits warn logs from the worker scan cycle for anything within 7 days of EOL. Cerebras llama-3.3-70b (2026-02-16) and qwen-3-235b-a22b-instruct-2507 (2026-05-27) tagged. Quality - #8 Tool-call argument JSON validation — validateToolCallArguments in tool-call-repair.ts. isResponseBad now rejects responses where any tool_call argument string fails JSON.parse or isn't a JSON object; caller falls back to next provider instead of returning malformed tool_calls to clients. - #10 Regression warning — checkRegressionWarning() in learning.ts compares last-1h success rate vs prior-23h baseline per modelId; logs REGRESSION warning when drop >=20pp (with min sample sizes both sides). 10-min cooldown per modelId prevents log spam. Hooked into recordOutcomeLearning fire-and-forget on non-quota fails. Tests 95/95, build 0/0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
semantic_cachetable (ivfflat cosine index) with graceful fallback when the extension is unavailable.idx_health_cooldown_active(partial),idx_gateway_recent,idx_exam_passed_recent(partial).src/lib/semantic-cache.tslibrary: embed via local Ollamanomic-embed-text, cosine-similarity lookup, idempotent store, stats helper. All operations no-op silently on failure.Library is not yet wired into
v1/chat/completions— follow-up unit.Test plan
rtk npx next build— 0 errors, 0 warnings[migrate] pgvector + semantic_cache readylog line (or graceful warning if ext missing)\d health_logs/\d gateway_logs/\d exam_attempts