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:
parent
118710999b
commit
f7fa7bc7a1
|
|
@ -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 });
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue