feat: upgrade search to PostgreSQL full-text with GIN index

Replace Prisma contains-based search with raw SQL using ts_vector/ts_query
for ranked results and headline snippets with <mark> highlighting. Falls
back to ILIKE for partial matches. GIN index applied to production DB.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-13 13:30:30 -07:00
parent 118710999b
commit f7fa7bc7a1
1 changed files with 85 additions and 43 deletions

View File

@ -14,54 +14,96 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Query parameter q is required' }, { status: 400 }); return NextResponse.json({ error: 'Query parameter q is required' }, { status: 400 });
} }
// Use Prisma contains for search (works without GIN index) // Build WHERE clauses for optional filters
// Can upgrade to raw SQL full-text search once GIN index is in place const filters: string[] = [];
const where: Record<string, unknown> = { const params: (string | null)[] = [q]; // $1 = search query
OR: [
{ title: { contains: q, mode: 'insensitive' } },
{ contentPlain: { contains: q, mode: 'insensitive' } },
{ content: { contains: q, mode: 'insensitive' } },
],
};
if (type) where.type = type; if (type) {
if (notebookId) where.notebookId = notebookId; params.push(type);
filters.push(`n."type" = $${params.length}::"NoteType"`);
}
if (notebookId) {
params.push(notebookId);
filters.push(`n."notebookId" = $${params.length}`);
}
const notes = await prisma.note.findMany({ const whereClause = filters.length > 0 ? `AND ${filters.join(' AND ')}` : '';
where,
include: {
tags: { include: { tag: true } },
notebook: { select: { id: true, title: true } },
},
orderBy: { updatedAt: 'desc' },
take: 50,
});
const results = notes.map((note) => { // Full-text search using PostgreSQL ts_vector + ts_query
// Build snippet from contentPlain // Falls back to ILIKE if the GIN index hasn't been created yet
const plain = note.contentPlain || note.content || ''; const results = await prisma.$queryRawUnsafe<Array<{
const idx = plain.toLowerCase().indexOf(q.toLowerCase()); id: string;
const start = Math.max(0, idx - 50); title: string;
const end = Math.min(plain.length, idx + q.length + 100); content: string;
const snippet = (start > 0 ? '...' : '') + plain.slice(start, end) + (end < plain.length ? '...' : ''); contentPlain: string | null;
type: string;
notebookId: string | null;
notebookTitle: string | null;
isPinned: boolean;
updatedAt: Date;
rank: number;
headline: string | null;
}>>(
`SELECT
n.id,
n.title,
n.content,
n."contentPlain",
n.type::"text" as type,
n."notebookId",
nb.title as "notebookTitle",
n."isPinned",
n."updatedAt",
ts_rank(
to_tsvector('english', COALESCE(n."contentPlain", '') || ' ' || n.title),
plainto_tsquery('english', $1)
) as rank,
ts_headline('english',
COALESCE(n."contentPlain", n.content, ''),
plainto_tsquery('english', $1),
'StartSel=<mark>, StopSel=</mark>, MaxWords=35, MinWords=15, MaxFragments=1'
) as headline
FROM "Note" n
LEFT JOIN "Notebook" nb ON n."notebookId" = nb.id
WHERE (
to_tsvector('english', COALESCE(n."contentPlain", '') || ' ' || n.title) @@ plainto_tsquery('english', $1)
OR n.title ILIKE '%' || $1 || '%'
OR n."contentPlain" ILIKE '%' || $1 || '%'
)
${whereClause}
ORDER BY rank DESC, n."updatedAt" DESC
LIMIT 50`,
...params
);
return { // Fetch tags for matched notes
id: note.id, const noteIds = results.map((r) => r.id);
title: note.title, const noteTags = noteIds.length > 0
snippet, ? await prisma.noteTag.findMany({
type: note.type, where: { noteId: { in: noteIds } },
notebookId: note.notebookId, include: { tag: true },
notebookTitle: note.notebook?.title || null, })
updatedAt: note.updatedAt.toISOString(), : [];
tags: note.tags.map((nt) => ({
id: nt.tag.id,
name: nt.tag.name,
color: nt.tag.color,
})),
};
});
return NextResponse.json(results); const tagsByNoteId = new Map<string, Array<{ id: string; name: string; color: string | null }>>();
for (const nt of noteTags) {
const arr = tagsByNoteId.get(nt.noteId) || [];
arr.push({ id: nt.tag.id, name: nt.tag.name, color: nt.tag.color });
tagsByNoteId.set(nt.noteId, arr);
}
const response = results.map((note) => ({
id: note.id,
title: note.title,
snippet: note.headline || (note.contentPlain || note.content || '').slice(0, 150),
type: note.type,
notebookId: note.notebookId,
notebookTitle: note.notebookTitle,
updatedAt: new Date(note.updatedAt).toISOString(),
tags: tagsByNoteId.get(note.id) || [],
}));
return NextResponse.json(response);
} catch (error) { } catch (error) {
console.error('Search notes error:', error); console.error('Search notes error:', error);
return NextResponse.json({ error: 'Failed to search notes' }, { status: 500 }); return NextResponse.json({ error: 'Failed to search notes' }, { status: 500 });