* 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 = {})
| 773 | * 3. Score results based on match quality |
| 774 | */ |
| 775 | searchNodes(query: string, options: SearchOptions = {}): SearchResult[] { |
| 776 | const { limit = 100, offset = 0 } = options; |
| 777 | |
| 778 | // Parse field-qualified bits out of the raw query (kind:, lang:, |
| 779 | // path:, name:). Anything not recognised stays in `text` and goes |
| 780 | // to FTS unchanged. Filters compose with the SearchOptions arg — |
| 781 | // both are applied (intersection-style). |
| 782 | const parsed = parseQuery(query); |
| 783 | const mergedKinds = |
| 784 | parsed.kinds.length > 0 |
| 785 | ? Array.from(new Set([...(options.kinds ?? []), ...parsed.kinds])) |
| 786 | : options.kinds; |
| 787 | const mergedLanguages = |
| 788 | parsed.languages.length > 0 |
| 789 | ? Array.from(new Set([...(options.languages ?? []), ...parsed.languages])) |
| 790 | : options.languages; |
| 791 | const pathFilters = parsed.pathFilters; |
| 792 | const nameFilters = parsed.nameFilters; |
| 793 | // The text portion drives FTS/LIKE; if all the user typed was |
| 794 | // filters (`kind:function`), we still need *some* candidate set, |
| 795 | // so synthesise an empty-text path that returns everything matching |
| 796 | // the filters. |
| 797 | const text = parsed.text; |
| 798 | const kinds = mergedKinds; |
| 799 | const languages = mergedLanguages; |
| 800 | |
| 801 | // First try FTS5 with prefix matching |
| 802 | let results = text |
| 803 | ? this.searchNodesFTS(text, { kinds, languages, limit, offset }) |
| 804 | // Over-fetch by 5× when running filter-only (no text). The |
| 805 | // post-scoring path: + name: filters can be very selective, so |
| 806 | // a smaller multiplier risks returning fewer than `limit` |
| 807 | // results despite the DB having plenty of matches. |
| 808 | : this.searchAllByFilters({ kinds, languages, limit: limit * 5 }); |
| 809 | |
| 810 | // If no FTS results, try LIKE-based substring search |
| 811 | if (results.length === 0 && text.length >= 2) { |
| 812 | results = this.searchNodesLike(text, { kinds, languages, limit, offset }); |
| 813 | } |
| 814 | |
| 815 | // Final fuzzy fallback: scan all known names and keep those within |
| 816 | // a tight Levenshtein distance. Only fires when both FTS and LIKE |
| 817 | // returned nothing AND there's a text portion long enough to be |
| 818 | // worth fuzzing (1-char queries would match too much). |
| 819 | if (results.length === 0 && text.length >= 3) { |
| 820 | results = this.searchNodesFuzzy(text, { kinds, languages, limit }); |
| 821 | } |
| 822 | |
| 823 | // Supplement: ensure exact name matches are always candidates. |
| 824 | // BM25 can bury short exact-match names (e.g. "getBean") under hundreds of |
| 825 | // compound names (e.g. "getBeanDescriptor") in large codebases, |
| 826 | // pushing them past the FTS fetch limit before post-hoc scoring can help. |
| 827 | // Use the max BM25 score as the base so the nameMatchBonus (exact=30 vs |
| 828 | // prefix=20) actually differentiates them after rescoring. |
| 829 | if (results.length > 0 && query) { |
| 830 | const existingIds = new Set(results.map(r => r.node.id)); |
| 831 | const maxFtsScore = Math.max(...results.map(r => r.score)); |
| 832 | const terms = query.split(/\s+/).filter(t => t.length >= 2); |
no test coverage detected