(
cwd: string,
query: string,
opts: RetrieveOptions = {},
)
| 432 | * MUST treat null as "skip silently", never as an error. |
| 433 | */ |
| 434 | export async function retrieveRelevantFiles( |
| 435 | cwd: string, |
| 436 | query: string, |
| 437 | opts: RetrieveOptions = {}, |
| 438 | ): Promise<RankedFile[] | null> { |
| 439 | const baseUrl = opts.baseUrl ?? process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434'; |
| 440 | const model = opts.embeddingModel ?? 'nomic-embed-text'; |
| 441 | const topKChunks = opts.topKChunks ?? 24; |
| 442 | const maxFiles = opts.maxFiles ?? 6; |
| 443 | |
| 444 | try { |
| 445 | if (!(await ollamaReachable(baseUrl, opts.signal))) return null; |
| 446 | |
| 447 | const idxPath = indexPath(cwd, model); |
| 448 | let idx = await loadIndex(idxPath); |
| 449 | |
| 450 | if (!idx) { |
| 451 | if (!opts.buildIfMissing) { |
| 452 | // No JSON index — but a SQLite index might still exist. Probe it. |
| 453 | try { |
| 454 | const { openSqliteIndex } = await import('./sqlite-index.js'); |
| 455 | const probe = await openSqliteIndex(cwd, model); |
| 456 | if (probe) { probe.db.close(); } |
| 457 | else return null; |
| 458 | } catch { return null; } |
| 459 | } else { |
| 460 | idx = await buildIndex(cwd, model, baseUrl, opts.maxFilesToIndex ?? 1500, opts.signal, () => {}); |
| 461 | await persistIndex(cwd, model, idx); |
| 462 | } |
| 463 | } |
| 464 | |
| 465 | const [queryEmbedding] = await ollamaEmbed(baseUrl, model, [query], opts.signal); |
| 466 | if (!queryEmbedding) return null; |
| 467 | |
| 468 | // Hybrid (BM25 + semantic) ranking over the persisted index (SQLite preferred). |
| 469 | const scored = await searchPersisted(cwd, model, query, queryEmbedding, topKChunks, idx); |
| 470 | if (scored.length === 0) return null; |
| 471 | |
| 472 | // Stage-2 cross-encoder rerank (optional). The bi-encoder gives us a fast, |
| 473 | // wide candidate set; the cross-encoder re-scores query+doc jointly for true |
| 474 | // relevance, then we narrow to maxFiles. If no reranker is reachable this is |
| 475 | // a no-op and we keep the bi-encoder order. |
| 476 | let ranked: RankedFile[]; |
| 477 | if (opts.rerank) { |
| 478 | const candPool = opts.rerankCandidates ?? 40; |
| 479 | const wideFiles = aggregateToFiles(scored, candPool); // wide net first |
| 480 | try { |
| 481 | const { crossEncoderRerank } = await import('./reranker.js'); |
| 482 | const reranked = await crossEncoderRerank( |
| 483 | query, |
| 484 | wideFiles.map(f => ({ |
| 485 | id: f.file, |
| 486 | // Feed the cross-encoder the REAL code (prefixed with the path for light |
| 487 | // context). Falling back to the filename+line string only when a candidate |
| 488 | // has no chunk text — otherwise the reranker scores on path tokens alone, |
| 489 | // which is what made its scores degenerate. |
| 490 | text: f.text && f.text.trim() |
| 491 | ? `${f.file}\n${f.text}` |
no test coverage detected