* FTS5 search with prefix matching
(query: string, options: SearchOptions)
| 1375 | * FTS5 search with prefix matching |
| 1376 | */ |
| 1377 | private searchNodesFTS(query: string, options: SearchOptions): SearchResult[] { |
| 1378 | const { kinds, languages, limit = 100, offset = 0 } = options; |
| 1379 | |
| 1380 | // Add prefix wildcard for better matching (e.g., "auth" matches "AuthService", "authenticate") |
| 1381 | // Escape special FTS5 characters and add prefix wildcard. |
| 1382 | // |
| 1383 | // `::` is a qualifier separator in Rust/C++/Ruby, not a token char, |
| 1384 | // so treat it as whitespace before the strip step. Otherwise queries |
| 1385 | // like `stage_apply::run` collapse to `stage_applyrun` (the colons |
| 1386 | // are stripped without splitting) and find nothing. See #173. |
| 1387 | const ftsQuery = query |
| 1388 | .replace(/::/g, ' ') // Rust/C++/Ruby qualifier separator |
| 1389 | .replace(/['"*():^]/g, '') // Remove FTS5 special chars |
| 1390 | .split(/\s+/) |
| 1391 | .filter(term => term.length > 0) |
| 1392 | // Strip FTS5 boolean operators to prevent query manipulation |
| 1393 | .filter(term => !/^(AND|OR|NOT|NEAR)$/i.test(term)) |
| 1394 | .map(term => `"${term}"*`) // Prefix match each term |
| 1395 | .join(' OR '); |
| 1396 | |
| 1397 | if (!ftsQuery) { |
| 1398 | return []; |
| 1399 | } |
| 1400 | |
| 1401 | // BM25 column weights: id=0, name=20, qualified_name=5, docstring=1, signature=2 |
| 1402 | // Heavy name weight ensures exact/prefix name matches rank above incidental |
| 1403 | // mentions in long docstrings or qualified names of nested symbols. |
| 1404 | // Fetch 5x requested limit so post-hoc rescoring (kindBonus, pathRelevance, |
| 1405 | // nameMatchBonus) can promote results that BM25 alone undervalues. |
| 1406 | const ftsLimit = Math.max(limit * 5, 100); |
| 1407 | |
| 1408 | let sql = ` |
| 1409 | SELECT nodes.*, bm25(nodes_fts, 0, 20, 5, 1, 2) as score |
| 1410 | FROM nodes_fts |
| 1411 | JOIN nodes ON nodes_fts.id = nodes.id |
| 1412 | WHERE nodes_fts MATCH ? |
| 1413 | `; |
| 1414 | |
| 1415 | const params: (string | number)[] = [ftsQuery]; |
| 1416 | |
| 1417 | if (kinds && kinds.length > 0) { |
| 1418 | sql += ` AND nodes.kind IN (${kinds.map(() => '?').join(',')})`; |
| 1419 | params.push(...kinds); |
| 1420 | } |
| 1421 | |
| 1422 | if (languages && languages.length > 0) { |
| 1423 | sql += ` AND nodes.language IN (${languages.map(() => '?').join(',')})`; |
| 1424 | params.push(...languages); |
| 1425 | } |
| 1426 | |
| 1427 | sql += ' ORDER BY score LIMIT ? OFFSET ?'; |
| 1428 | params.push(ftsLimit, offset); |
| 1429 | |
| 1430 | try { |
| 1431 | const rows = this.db.prepare(sql).all(...params) as (NodeRow & { score: number })[]; |
| 1432 | return rows.map((row) => ({ |
| 1433 | node: rowToNode(row), |
| 1434 | score: Math.abs(row.score), // bm25 returns negative scores |
no test coverage detected