feat: migrate semantic search to pgvector + full-text search
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m12s

Replace JSON-string embeddings with native pgvector(1536) storage and
add PostgreSQL full-text search (tsvector/GIN) with Reciprocal Rank Fusion
for hybrid keyword + semantic ranking.

Changes:
- NoteEmbedding.embedding: String → vector(1536) via pgvector
- NoteEmbedding: added updatedAt for reindex tracking
- Note: added tsv (tsvector) with auto-update trigger for FTS
- semantic-search.service: hybrid FTS + vector search with RRF fusion
- embedding.service: toVectorString() for pgvector SQL literals
- Removed JS-side cosine similarity loops (now DB-side via <=>)
- Added HNSW index on NoteEmbedding.embedding (cosine distance)
- Added GIN index on Note.tsv for FTS queries

Schema migration in: prisma/migrations/20260512120000_pgvector_and_fts_search/

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Antigravity
2026-05-12 07:03:56 +00:00
parent 92c3a6f307
commit 03e6a62b80
43 changed files with 4024 additions and 786 deletions

View File

@@ -538,21 +538,25 @@ export function registerTools(server, prisma) {
}
case 'search_notes': {
const where = noteWhere(uid, {
isArchived: args.includeArchived || false,
OR: [
{ title: { contains: args.query } },
{ content: { contains: args.query } },
],
...(args.notebookId ? { notebookId: args.notebookId } : {}),
});
const safeQuery = (args.query || '').replace(/'/g, "''");
const userClause = uid ? `AND "userId" = '${uid}'` : '';
const notebookClause = args.notebookId ? `AND "notebookId" = '${args.notebookId.replace(/'/g, "''")}'` : '';
const notes = await prisma.note.findMany({
where,
orderBy: [{ isPinned: 'desc' }, { updatedAt: 'desc' }],
take: DEFAULT_SEARCH_LIMIT,
});
return textResult(notes.map(parseNoteLightweight));
const ftsRows = await prisma.$queryRawUnsafe(`
SELECT id, title, content, color, type, "isPinned", "isArchived",
"isMarkdown", size, "createdAt", "updatedAt", "notebookId",
images, labels, "checkItems", reminder, "isReminderDone"
FROM "Note"
WHERE "tsv" @@ plainto_tsquery('simple', '${safeQuery}')
AND "trashedAt" IS NULL
AND "isArchived" = ${args.includeArchived ? 'true' : 'false'}
${userClause}
${notebookClause}
ORDER BY ts_rank("tsv", plainto_tsquery('simple', '${safeQuery}')) DESC
LIMIT ${DEFAULT_SEARCH_LIMIT}
`);
return textResult(ftsRows.map(parseNoteLightweight));
}
case 'move_note': {