Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions src/lib/db/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,4 +331,53 @@ export async function runMigrations(): Promise<void> {
console.error("[migrate] Migration failed:", err);
throw err;
}

// ─── Semantic Cache (pgvector) ──────────────────────────────────────────────
// Graceful: if pgvector ext not available, log warning and skip — system still works
try {
await sql`CREATE EXTENSION IF NOT EXISTS vector`;
await sql`
CREATE TABLE IF NOT EXISTS semantic_cache (
id BIGSERIAL PRIMARY KEY,
query_hash TEXT UNIQUE NOT NULL,
query TEXT NOT NULL,
embedding vector(768),
response JSONB NOT NULL,
provider TEXT,
model TEXT,
hit_count INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now(),
last_used_at TIMESTAMPTZ DEFAULT now()
)
`;
await sql`
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()

Copilot AI Apr 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
WHERE cooldown_until > now()
WHERE cooldown_until IS NOT NULL

Copilot uses AI. Check for mistakes.
`.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
Comment on lines +354 to +379

Copilot AI Apr 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
ON exam_attempts(model_id, started_at DESC)
WHERE passed = true
`.catch((err) => console.warn("[migrate] idx_exam_passed_recent:", String(err).slice(0, 100)));
}
140 changes: 140 additions & 0 deletions src/lib/semantic-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* Semantic Cache — store responses indexed by message embedding
*
* Lookup: cosine similarity > threshold → return cached response
* Store: embed query + response, persist to pgvector table
*
* Embedding source: Ollama local `nomic-embed-text` (free, no external API)
* Fallback: if Ollama or pgvector unavailable, operations no-op gracefully
*/
import { getSqlClient } from "@/lib/db/schema";
import { createHash } from "crypto";

const OLLAMA_BASE_URL = process.env.OLLAMA_BASE_URL || "http://localhost:11434";
const EMBED_MODEL = "nomic-embed-text";
const DEFAULT_THRESHOLD = 0.92;
const EMBED_TIMEOUT_MS = 3_000;

export interface SemanticHit {
query: string;
response: Record<string, unknown>;
provider: string | null;
model: string | null;
similarity: number;
hit_count: number;
}

function hashQuery(query: string): string {
return createHash("sha256").update(query).digest("hex").slice(0, 32);
}

async function embedQuery(query: string): Promise<number[] | null> {
try {
const res = await fetch(`${OLLAMA_BASE_URL}/api/embeddings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: EMBED_MODEL, prompt: query }),
signal: AbortSignal.timeout(EMBED_TIMEOUT_MS),
});
if (!res.ok) return null;
const json = await res.json();
const embedding = json.embedding as number[] | undefined;
return Array.isArray(embedding) && embedding.length > 0 ? embedding : null;
} catch {
Comment on lines +40 to +43

Copilot AI Apr 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
return null;
}
}

function vectorLiteral(vec: number[]): string {
return "[" + vec.join(",") + "]";
}

/**
* Look up cached response by semantic similarity.
* Returns null if no match or any error (graceful fallback).
*/
export async function getCachedBySimilarity(
query: string,
threshold: number = DEFAULT_THRESHOLD
): Promise<SemanticHit | null> {
if (!query || query.length < 10) return null;
const embedding = await embedQuery(query);
if (!embedding) return null;
try {
const sql = getSqlClient();
const vecStr = vectorLiteral(embedding);
const rows = await sql<Array<{
query: string;
response: Record<string, unknown>;
provider: string | null;
model: string | null;
similarity: number;
hit_count: number;
}>>`
SELECT query, response, provider, model, hit_count,
1 - (embedding <=> ${vecStr}::vector) AS similarity
FROM semantic_cache
ORDER BY embedding <=> ${vecStr}::vector
LIMIT 1
`;
const row = rows[0];
if (!row || row.similarity < threshold) return null;
// 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(() => {});
Comment on lines +82 to +87

Copilot AI Apr 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
return row;
} catch {
return null;
}
}

/**
* Store response with embedding. Idempotent via query_hash.
*/
export async function storeSemanticResponse(
query: string,
response: Record<string, unknown>,
provider: string | null,
model: string | null
): Promise<void> {
if (!query || query.length < 10) return;
const embedding = await embedQuery(query);
if (!embedding) return;
try {
const sql = getSqlClient();
const hash = hashQuery(query);
const vecStr = vectorLiteral(embedding);
await sql`
INSERT INTO semantic_cache (query_hash, query, embedding, response, provider, model)
VALUES (${hash}, ${query.slice(0, 2000)}, ${vecStr}::vector, ${JSON.stringify(response)}::jsonb, ${provider}, ${model})
ON CONFLICT (query_hash) DO UPDATE SET
last_used_at = now(),
hit_count = semantic_cache.hit_count + 1
`;
} catch {
// silent — cache is optional
}
}

export async function getSemanticCacheStats(): Promise<{
total: number;
hits: number;
avgHits: number;
}> {
try {
const sql = getSqlClient();
const rows = await sql<{ total: number; hits: number; avg: number }[]>`
SELECT COUNT(*)::int as total,
COALESCE(SUM(hit_count), 0)::int as hits,
COALESCE(AVG(hit_count), 0)::float as avg
FROM semantic_cache
`;
const r = rows[0];
return { total: r?.total ?? 0, hits: r?.hits ?? 0, avgHits: r?.avg ?? 0 };
} catch {
return { total: 0, hits: 0, avgHits: 0 };
}
}