/** * VectorIndex * * Builds and queries a LanceDB vector index over the call graph functions. * Each function is represented by a document combining its signature, docstring, * file path, language, and topological metadata (fanIn/fanOut, hub, entry point). * * Storage: /vector-index/ (LanceDB database folder) * Table name: "functions" * * Usage: * // Build (after openlore analyze --embed) * await VectorIndex.build(outputDir, nodes, signatures, hubIds, entryPointIds, embedSvc); * * // Search * const results = await VectorIndex.search(outputDir, "authenticate user with JWT", embedSvc); */ import type { CallEdge, FunctionNode } from './call-graph.js'; import type { FileSignatureMap } from './signature-extractor.js'; import type { Embedder } from './embedding-service.js'; import { type MatchEvidence, type SearchableFields } from './retrieval-evidence.js'; export type { MatchEvidence, MatchField, RetrievalTier } from './retrieval-evidence.js'; export { TOKENIZER_VERSION, tokenize } from './bm25-tokenizer.js'; export interface FunctionRecord { id: string; name: string; filePath: string; className: string; language: string; signature: string; docstring: string; fanIn: number; fanOut: number; isHub: boolean; isEntryPoint: boolean; /** Concatenated text used for embedding */ text: string; /** Embedding vector */ vector: number[]; } export interface SearchResult { record: Omit; /** * Relevance score. For hybrid search (default): RRF score, higher = more relevant. * For dense-only search: cosine distance from LanceDB, lower = more similar. */ score: number; /** Optional for legacy test doubles; every production search path emits it. */ scoreKind?: 'rrf' | 'bm25' | 'cosine_distance'; /** Optional for legacy test doubles; every production search path emits it. */ matchEvidence?: MatchEvidence; /** Vocabulary terms that contributed a non-zero score to this result. */ expansionTerms?: string[]; } export interface KeywordMissDiagnostics { missedTokens: string[]; nearTokens: Array<{ queryToken: string; indexedTokens: string[]; }>; } export interface VectorIndexMeta { hasEmbeddings: boolean; dim: number; model: string | null; builtAt: string; /** Changes only after a complete rebuild; incremental mutations preserve it. */ fullBuildAt?: string; schemaVersion: number; /** * Version of the BM25 tokenizer that produced this index's corpus. A mismatch * against the running `TOKENIZER_VERSION` means an incremental patch would mix * token sets, so `updateFiles` defers (`deferred: 'tokenizer-changed'`) and a * full rebuild re-stamps. A legacy meta without this field is treated as v1. */ tokenizerVersion?: number; /** Corpus identity of the verified repository-vocabulary sidecar, when present. */ vocabularyContentStamp?: string; /** Present only when an incremental update could neither add nor restore rows. */ degraded?: { reason: 'incremental-update-restore-failed'; recordedAt: string; }; } declare function dbPathFor(outputDir: string): string; export interface Bm25Corpus { docs: Array<{ id: string; tfMap: Map; length: number; }>; /** term → number of documents containing it */ df: Map; avgLength: number; N: number; } export declare function buildBm25Corpus(records: Array<{ id: string; text: string; }>): Bm25Corpus; /** * The per-corpus lookup structures a keyword query needs, built once per corpus OBJECT * and carried across an incremental patch rather than rebuilt. * * `byTerm` maps a term to the ids of the documents containing it; `indexById` maps a * document id back to its position in `corpus.docs`. Postings are keyed by ID rather * than by position precisely so a patch can maintain them in O(edit): a patch drops * removed documents from the middle of `docs`, which renumbers every position after * them, but leaves every surviving document's id alone. * * Neither structure is part of {@link Bm25Corpus} and neither reaches the persisted * sidecar — both are derivable from `docs`. * (change: optimize-serving-hot-path-caches) */ interface Bm25Postings { byTerm: Map>; indexById: Map; /** False when two documents share an id, which would make `indexById` lossy. */ idsAreUnique: boolean; } export declare function _bm25WorkCountersForTesting(): { postingsBuilds: number; docsScored: number; }; export declare function _resetBm25WorkCountersForTesting(): void; /** * The doc indices that can score above zero for any of `tokenSets`, ascending. * * Exactly the set the caller's `score > 0 || expansionScore > 0` filter would keep: * `bm25Score` contributes only for a term with `df > 0` AND `tf > 0`, and both the idf * and the tf-norm factors are strictly positive whenever those hold — so "appears in * some posting list" and "scores above zero" are the same predicate. Scoring the * candidates instead of every document is what keeps a keyword query from walking the * whole corpus. * * If two documents ever share an id the position map is lossy, so this returns every * index rather than silently dropping one — a slow answer, never a wrong one. * (change: optimize-serving-hot-path-caches) */ export declare function bm25CandidateDocs(corpus: Bm25Corpus, ...tokenSets: ReadonlyArray): number[]; export declare function bm25Score(corpus: Bm25Corpus, queryTokens: string[], docIdx: number): number; /** Attribute the ranker's exact aggregate term contributions across one bounded candidate's fields. */ export declare function bm25MatchEvidence(corpus: Bm25Corpus, queryTokens: string[], docIdx: number, fields: SearchableFields, tier?: 1 | 2): MatchEvidence; declare const _cacheStats: { tableHits: number; tableMisses: number; bm25Hits: number; bm25Misses: number; }; /** Clear every process-lifetime cache for one on-disk vector index. */ export declare function invalidateVectorIndexCaches(outputDir: string): void; /** Test-only: expose the canonical cache identity used for an index path. */ export declare const _vectorIndexCacheIdentityForTesting: typeof dbPathFor; /** * Test-only: run an operation under the index mutation lock, so the lock's contention, * reclamation and wait behavior can be asserted without building a real LanceDB table. */ export declare const _withVectorIndexMutationForTesting: (outputDir: string, operation: () => Promise, options?: VectorIndexLockOptions) => Promise; /** * Contention on the index's mutation lock, as a TYPED outcome. * * It used to be a bare `Error` carrying only the lock path, which the analyze command * could not tell apart from an embedding-provider failure — so a build that collided * with a concurrent one reported "keyword index used" and exited 0, over an index that * still had no vectors (observed 2026-09-20). The holder's identity and the lock's age * are exactly what distinguishes "wait for that build" from "your endpoint is down". */ export declare class VectorIndexLockContendedError extends Error { readonly lockPath: string; /** PID named by the lock payload, or null when it names no process. */ readonly holderPid: number | null; /** Age of the holder's last write, in milliseconds. */ readonly ageMs: number; /** Present when the lock names no process, so nothing can ever judge it stale. */ readonly disclosure?: string | undefined; constructor(lockPath: string, /** PID named by the lock payload, or null when it names no process. */ holderPid: number | null, /** Age of the holder's last write, in milliseconds. */ ageMs: number, /** Present when the lock names no process, so nothing can ever judge it stale. */ disclosure?: string | undefined); } /** How a caller wants to handle a lock another live process holds. */ export interface VectorIndexLockOptions { /** * `report` (the default) fails immediately with {@link VectorIndexLockContendedError}; * `wait` polls for the holder to finish. A caller that waits must be one whose work is * worth the delay — the analyze command under `--wait` — because the critical section * is a whole index build. */ contention?: 'wait' | 'report'; /** Bound for `wait`. Omitted, the shared lock loop's default bound applies. */ maxWaitMs?: number; /** Called when the acquire reclaimed a lock whose owner was dead, so the caller can say so. */ onReclaimed?: (lockPath: string) => void; } /** Test-only: clear in-memory BM25 + LanceDB caches to force cold path. */ export declare function _resetVectorIndexCachesForTesting(): void; /** Test-only proof that a cold request populated, and a warm request reused, each search cache. */ export declare function _vectorIndexCacheStatsForTesting(): Readonly; interface MutableTable { delete(predicate: string): Promise; add(rows: Record[]): Promise; } declare function replaceRowsWithRestore(table: MutableTable, predicate: string | null, replacementRows: Record[], previousRows: Record[], onRestoreFailure: () => Promise): Promise; /** Test-only: exercise the transactional delete/add helper without LanceDB. */ export declare const _replaceRowsWithRestoreForTesting: typeof replaceRowsWithRestore; /** * Absorb one incremental update into an existing corpus, returning the new corpus and rows. * * Pure — no cache, no disk — so it can be checked directly against {@link buildBm25Corpus}, which * is the only assurance that matters here (see `bm25-incremental-patch.test.ts`). */ declare function patchBm25Corpus(previous: Bm25Corpus, previousRows: Record[], changedFilePaths: Set, newRows: Record[], previousPostings?: Bm25Postings): { corpus: Bm25Corpus; rows: Record[]; postings?: Bm25Postings; }; /** Test-only: drive {@link patchBm25Corpus} directly, to diff it against a full rebuild. */ export declare const _patchBm25CorpusForTesting: typeof patchBm25Corpus; /** * Test-only: patch a corpus the way {@link patchBm25Cache} does — carrying the memoized * postings index across the new corpus object instead of leaving it to be rebuilt. * The cache-side half of the pure {@link patchBm25Corpus}, isolated from LanceDB. */ export declare function _patchBm25CorpusCarryingPostingsForTesting(previous: Bm25Corpus, previousRows: Record[], changedFilePaths: Set, newRows: Record[]): Bm25Corpus; export declare function searchableFieldsForFunctionRow(row: Record): SearchableFields; export declare class VectorIndex { /** User-facing disclosure for an index whose incremental rollback also failed. */ static degradationNotice(outputDir: string): string | null; /** * Build (or rebuild) the vector index from call graph nodes + signatures. * * When `incremental` is true and an existing index is found, only functions * whose text has changed since the last build are re-embedded. Unchanged * functions reuse their cached vectors. Pass `incremental: false` (or omit * when no index exists) to do a full rebuild. * * Returns a summary of how many functions were embedded vs reused. * * When `embedSvc` is null, builds a **keyword-only (BM25)** index: the corpus * rows are written without a `vector` column and the meta sidecar records * `hasEmbeddings: false`. Search then serves BM25 results and never attempts * ANN. Re-building a previously-embedded index with `embedSvc=null` downgrades * it to BM25-only (overwrite + meta update), and vice-versa upgrades it. */ static build(outputDir: string, nodes: FunctionNode[], signatures: FileSignatureMap[], hubIds: Set, entryPointIds: Set, embedSvc: Embedder | null, /** Optional map of filePath → source content for skeleton-based body indexing */ fileContents?: Map, /** When true, reuse cached vectors for unchanged functions */ incremental?: boolean, vocabularyExpansion?: boolean, callEdges?: readonly Pick[], /** How to behave when another process is already mutating this index. */ lock?: VectorIndexLockOptions): Promise<{ embedded: number; reused: number; total: number; hasEmbeddings: boolean; productionFunctions: number; testFunctions: number; signatureOnlySymbols: number; }>; private static buildUnlocked; /** * Watch-mode incremental update (Spec 13.1). Replace only the rows for the * changed files with freshly-built records — a row-level delete+add instead of * the full-corpus read+overwrite that build() performs. The cold build() path * is untouched, protecting the `analyze --embed` contract (G7). * * - Embedded index: reuse existing vectors for rows whose embed-text is * unchanged (queried for the changed files only, not the whole corpus), * embed just the new/changed texts, then delete the changed files' old rows * and add the rebuilt ones. The LanceDB table handle in _tableCache stays * valid across row ops, so search() does not pay a reconnect. * - BM25-only index: delete+add the changed files' documents and patch the * cached BM25 corpus in place rather than dropping the whole corpus cache. */ static updateFiles(outputDir: string, nodes: FunctionNode[], changedFilePaths: Set, signatures: FileSignatureMap[], hubIds: Set, entryPointIds: Set, embedSvc: Embedder | null | undefined, fileContents?: Map, /** * Incremental updates keep the historical WAIT behavior: the watcher's critical * section is short, and a caller that loses the race should converge, not fail. */ lock?: VectorIndexLockOptions): Promise<{ embedded: number; reused: number; total: number; hasEmbeddings: boolean; deferred?: 'model-changed' | 'tokenizer-changed'; }>; private static updateFilesUnlocked; /** * Hybrid search over the index: dense (ANN) + sparse (BM25) merged via RRF. * * Dense recall fetches top `limit*5` candidates from the vector index. * Sparse recall scores the full corpus with BM25 (cached per session). * Reciprocal Rank Fusion (RRF) combines both rankings into a single list. * * Set `hybrid: false` to use dense-only search (original behaviour). * Returns up to `limit` results sorted by relevance (highest first). */ static search(outputDir: string, query: string, embedSvc: Embedder | null | undefined, opts?: { limit?: number; language?: string; minFanIn?: number; /** Enable hybrid dense+sparse retrieval via RRF (default: true when embedSvc available) */ hybrid?: boolean; /** Internal diagnostic: return the ordinary bounded candidate window before result cutoff. */ traceCandidates?: boolean; /** Disable repository-vocabulary query expansion without rebuilding. */ vocabularyExpansion?: boolean; /** Reports the retrieval mode that actually produced this result set. */ onRetrievalMode?: (mode: 'keyword' | 'keyword+vocabulary' | 'semantic') => void; }): Promise; /** * BM25-only search: used when no embedding service is available. * Scores the full corpus with BM25 and returns the top `limit` results. */ private static _bm25Only; /** * Explain an empty keyword result using the corpus already loaded by search(). * The lookup is bounded and deterministic: at most 12 distinct query tokens, * 3 near identifier tokens per miss, and no model or secondary index. */ static keywordMissDiagnostics(outputDir: string, query: string): Promise; /** * Returns true if a vector index has been built for this output directory. */ static exists(outputDir: string): boolean; } //# sourceMappingURL=vector-index.d.ts.map