| 1242 | // ─── FTS Search ─── |
| 1243 | |
| 1244 | ftsSearch(query: string, limit: number, ownerFilter?: string[]): Array<{ chunkId: string; score: number }> { |
| 1245 | const sanitized = sanitizeFtsQuery(query); |
| 1246 | if (!sanitized) return []; |
| 1247 | |
| 1248 | try { |
| 1249 | let sql = ` |
| 1250 | SELECT c.id as chunk_id, rank |
| 1251 | FROM chunks_fts f |
| 1252 | JOIN chunks c ON c.rowid = f.rowid |
| 1253 | WHERE chunks_fts MATCH ? AND c.dedup_status = 'active'`; |
| 1254 | const params: any[] = [sanitized]; |
| 1255 | |
| 1256 | if (ownerFilter && ownerFilter.length > 0) { |
| 1257 | const placeholders = ownerFilter.map(() => "?").join(","); |
| 1258 | sql += ` AND c.owner IN (${placeholders})`; |
| 1259 | params.push(...ownerFilter); |
| 1260 | } |
| 1261 | |
| 1262 | sql += ` ORDER BY rank LIMIT ?`; |
| 1263 | params.push(limit); |
| 1264 | |
| 1265 | const rows = this.db.prepare(sql).all(...params) as Array<{ chunk_id: string; rank: number }>; |
| 1266 | |
| 1267 | if (rows.length === 0) return []; |
| 1268 | const maxAbsRank = Math.max(...rows.map((r) => Math.abs(r.rank))); |
| 1269 | return rows.map((r) => ({ |
| 1270 | chunkId: r.chunk_id, |
| 1271 | score: maxAbsRank > 0 ? Math.abs(r.rank) / maxAbsRank : 0, |
| 1272 | })); |
| 1273 | } catch { |
| 1274 | this.log.warn(`FTS query failed for: "${sanitized}", returning empty`); |
| 1275 | return []; |
| 1276 | } |
| 1277 | } |
| 1278 | |
| 1279 | // ─── Pattern Search (LIKE-based, for CJK text where FTS tokenization is weak) ─── |
| 1280 | |