import type Database from 'better-sqlite3';
export interface TextSearchResult {
chunkId: string;
documentId: string;
content: string;
rank: number;
meta: Record<string, unknown>;
}
/**
* FTS5 full-text search with BM25 ranking.
*/
export function textSearch(
db: Database.Database,
query: string,
options: { limit?: number; scope?: string } = {}
): TextSearchResult[] {
const { limit = 10, scope } = options;
// Keep only alphanumeric, spaces, and basic punctuation safe for FTS5
const safeQuery = query.replace(/[^a-zA-Z0-9\s]/g, ' ').replace(/\s+/g, ' ').trim();
if (!safeQuery) return [];
let sql: string;
let params: unknown[];
if (scope) {
sql = `
SELECT c.id AS chunk_id, c.document_id, c.content,
rank AS bm25_rank, d.meta
FROM chunks_fts
JOIN chunks c ON chunks_fts.rowid = c.rowid
JOIN documents d ON c.document_id = d.id
WHERE chunks_fts MATCH ?
AND d.scope = ?
ORDER BY rank
LIMIT ?`;
params = [safeQuery, scope, limit];
} else {
sql = `
SELECT c.id AS chunk_id, c.document_id, c.content,
rank AS bm25_rank, d.meta
FROM chunks_fts
JOIN chunks c ON chunks_fts.rowid = c.rowid
JOIN documents d ON c.document_id = d.id
WHERE chunks_fts MATCH ?
ORDER BY rank
LIMIT ?`;
params = [safeQuery, limit];
}
const rows = db.prepare(sql).all(...params) as {
chunk_id: string;
document_id: string;
content: string;
bm25_rank: number;
meta: string;
}[];
return rows.map((r) => ({
chunkId: r.chunk_id,
documentId: r.document_id,
content: r.content,
rank: r.bm25_rank,
meta: JSON.parse(r.meta),
}));
}