feat: migrate semantic search to pgvector + full-text search
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m12s
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:
@@ -1,11 +1,10 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { validateEmbedding } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Admin endpoint to validate all embeddings in the database
|
||||
* Returns a list of notes with invalid embeddings
|
||||
* Admin endpoint to validate all pgvector embeddings in the database.
|
||||
* Uses native SQL to check for valid vector format.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
@@ -14,7 +13,6 @@ export async function GET() {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { role: true }
|
||||
@@ -24,72 +22,34 @@ export async function GET() {
|
||||
return NextResponse.json({ error: 'Forbidden - Admin only' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Fetch all notes with embeddings
|
||||
const allNotes = await prisma.note.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
noteEmbedding: true
|
||||
}
|
||||
})
|
||||
const totalResult: Array<{ total: bigint }> = await prisma.$queryRawUnsafe(
|
||||
`SELECT COUNT(*)::bigint as total FROM "Note" WHERE "trashedAt" IS NULL`
|
||||
)
|
||||
const total = Number(totalResult[0]?.total ?? 0)
|
||||
|
||||
const invalidNotes: Array<{
|
||||
id: string
|
||||
title: string
|
||||
issues: string[]
|
||||
}> = []
|
||||
const withEmbedding: Array<{ count: bigint }> = await prisma.$queryRawUnsafe(
|
||||
`SELECT COUNT(*)::bigint as count FROM "NoteEmbedding"`
|
||||
)
|
||||
const validCount = Number(withEmbedding[0]?.count ?? 0)
|
||||
|
||||
let validCount = 0
|
||||
let missingCount = 0
|
||||
let invalidCount = 0
|
||||
const invalidResult: Array<{ count: bigint }> = await prisma.$queryRawUnsafe(
|
||||
`SELECT COUNT(*)::bigint as count FROM "NoteEmbedding" e
|
||||
WHERE e."embedding" IS NULL
|
||||
OR array_length(string_to_array(replace(replace(e."embedding"::text, '[', ''), ']', ''), ','), 1) != 1536`
|
||||
)
|
||||
const invalidCount = Number(invalidResult[0]?.count ?? 0)
|
||||
|
||||
for (const note of allNotes) {
|
||||
// Check if embedding is missing
|
||||
if (!note.noteEmbedding?.embedding) {
|
||||
missingCount++
|
||||
invalidNotes.push({
|
||||
id: note.id,
|
||||
title: note.title || 'Untitled',
|
||||
issues: ['Missing embedding']
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate embedding
|
||||
try {
|
||||
if (!note.noteEmbedding?.embedding) continue
|
||||
const embedding = JSON.parse(note.noteEmbedding.embedding) as number[]
|
||||
const validation = validateEmbedding(embedding)
|
||||
|
||||
if (!validation.valid) {
|
||||
invalidCount++
|
||||
invalidNotes.push({
|
||||
id: note.id,
|
||||
title: note.title || 'Untitled',
|
||||
issues: validation.issues
|
||||
})
|
||||
} else {
|
||||
validCount++
|
||||
}
|
||||
} catch (error) {
|
||||
invalidCount++
|
||||
invalidNotes.push({
|
||||
id: note.id,
|
||||
title: note.title || 'Untitled',
|
||||
issues: [`Failed to parse embedding: ${error}`]
|
||||
})
|
||||
}
|
||||
}
|
||||
const missingCount = total - validCount
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
summary: {
|
||||
total: allNotes.length,
|
||||
valid: validCount,
|
||||
missing: missingCount,
|
||||
total,
|
||||
valid: validCount - invalidCount,
|
||||
missing: missingCount > 0 ? missingCount : 0,
|
||||
invalid: invalidCount
|
||||
},
|
||||
invalidNotes
|
||||
invalidNotes: []
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[EMBEDDING_VALIDATION] Error:', error)
|
||||
|
||||
Reference in New Issue
Block a user