* Search nodes by name using FTS with fallback to LIKE for better matching * * Search strategy: * 1. Try FTS5 prefix match (query*) for word-start matching * 2. If no results, try LIKE for substring matching (e.g., "signIn" finds "signInWithGoogle") * 3. Score results based on match q
(query: string, options: SearchOptions = {})
| 1161 | * 3. Score results based on match quality |
| 1162 | */ |
| 1163 | searchNodes(query: string, options: SearchOptions = {}): SearchResult[] { |
| 1164 | const { limit = 100, offset = 0 } = options; |
| 1165 | |
| 1166 | // Parse field-qualified bits out of the raw query (kind:, lang:, |
| 1167 | // path:, name:). Anything not recognised stays in `text` and goes |
| 1168 | // to FTS unchanged. Filters compose with the SearchOptions arg — |
| 1169 | // both are applied (intersection-style). |
| 1170 | const parsed = parseQuery(query); |
| 1171 | const mergedKinds = |
| 1172 | parsed.kinds.length > 0 |
| 1173 | ? Array.from(new Set([...(options.kinds ?? []), ...parsed.kinds])) |
| 1174 | : options.kinds; |
| 1175 | const mergedLanguages = |
| 1176 | parsed.languages.length > 0 |
| 1177 | ? Array.from(new Set([...(options.languages ?? []), ...parsed.languages])) |
| 1178 | : options.languages; |
| 1179 | const pathFilters = parsed.pathFilters; |
| 1180 | const nameFilters = parsed.nameFilters; |
| 1181 | // The text portion drives FTS/LIKE; if all the user typed was |
| 1182 | // filters (`kind:function`), we still need *some* candidate set, |
| 1183 | // so synthesise an empty-text path that returns everything matching |
| 1184 | // the filters. |
| 1185 | const text = parsed.text; |
| 1186 | const kinds = mergedKinds; |
| 1187 | const languages = mergedLanguages; |
| 1188 | |
| 1189 | // First try FTS5 with prefix matching |
| 1190 | let results = text |
| 1191 | ? this.searchNodesFTS(text, { kinds, languages, limit, offset }) |
| 1192 | // Over-fetch by 5× when running filter-only (no text). The |
| 1193 | // post-scoring path: + name: filters can be very selective, so |
| 1194 | // a smaller multiplier risks returning fewer than `limit` |
| 1195 | // results despite the DB having plenty of matches. |
| 1196 | : this.searchAllByFilters({ kinds, languages, limit: limit * 5 }); |
| 1197 | |
| 1198 | // If no FTS results, try LIKE-based substring search |
| 1199 | if (results.length === 0 && text.length >= 2) { |
| 1200 | results = this.searchNodesLike(text, { kinds, languages, limit, offset }); |
| 1201 | } |
| 1202 | |
| 1203 | // Final fuzzy fallback: scan all known names and keep those within |
| 1204 | // a tight Levenshtein distance. Only fires when both FTS and LIKE |
| 1205 | // returned nothing AND there's a text portion long enough to be |
| 1206 | // worth fuzzing (1-char queries would match too much). |
| 1207 | if (results.length === 0 && text.length >= 3) { |
| 1208 | results = this.searchNodesFuzzy(text, { kinds, languages, limit }); |
| 1209 | } |
| 1210 | |
| 1211 | // Supplement: ensure exact name matches are always candidates. |
| 1212 | // BM25 can bury short exact-match names (e.g. "getBean") under hundreds of |
| 1213 | // compound names (e.g. "getBeanDescriptor") in large codebases, |
| 1214 | // pushing them past the FTS fetch limit before post-hoc scoring can help. |
| 1215 | // Use the max BM25 score as the base so the nameMatchBonus (exact=30 vs |
| 1216 | // prefix=20) actually differentiates them after rescoring. |
| 1217 | if (results.length > 0 && query) { |
| 1218 | const existingIds = new Set(results.map(r => r.node.id)); |
| 1219 | const maxFtsScore = Math.max(...results.map(r => r.score)); |
| 1220 | const terms = query.split(/\s+/).filter(t => t.length >= 2); |
no test coverage detected