graph-indexer 1.0.3 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/enrichment.mjs ADDED
@@ -0,0 +1,347 @@
1
+ /**
2
+ * @file enrichment.mjs
3
+ * @description Optional LLM-assisted semantic enrichment. Standard embeddings
4
+ * match text proximity, not intent — a query like "payment webhook
5
+ * bottleneck" misses code that never uses those words. This module
6
+ * routes the codebase's most central chunks (top PageRank files)
7
+ * through a local LLM to produce two enrichment fields per chunk:
8
+ *
9
+ * • chunk.summary — one declarative sentence in developer vocabulary,
10
+ * added as the leading field of the embedding payload so the vector
11
+ * is anchored toward query vocabulary (not just code syntax).
12
+ *
13
+ * • chunk.concepts — domain keyword array (e.g. ["authentication",
14
+ * "JWT", "middleware"]), joined into chunk.hyde for BM25 indexing.
15
+ *
16
+ * Only the top-PageRank subset of chunks is enriched (hard-capped).
17
+ * Generation is injectable for deterministic testing without a live model.
18
+ * @author MaquinaTech <https://github.com/MaquinaTech>
19
+ * @copyright (c) 2026 MaquinaTech. All rights reserved.
20
+ * @license MIT
21
+ */
22
+ import fs from 'fs';
23
+ import path from 'path';
24
+ import { computePageRank, truncateForEmbedding, TEST_FILE_RE, EXAMPLE_DIR_RE } from './search-core.mjs';
25
+
26
+ // ─── Persistent enrichment cache ───────────────────────────────────────────────
27
+ // Keyed by content_hash: the same code always yields the same (cached) summary
28
+ // and concepts, which makes enrichment INCREMENTAL — a re-index only sends new
29
+ // or changed chunks to the LLM, and the embedding cache (keyed by content_hash +
30
+ // enrichment digest, see search-core.embeddingKeyFor) keeps hitting too. Without
31
+ // this cache every index run re-enriched and re-embedded the full core set.
32
+
33
+ /** Load `code-index.enrichment.json` → Map<content_hash, {summary, concepts, model}>. */
34
+ export function loadEnrichmentCache(cachePath) {
35
+ try {
36
+ if (fs.existsSync(cachePath)) {
37
+ const data = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
38
+ return new Map(Object.entries(data.entries || data));
39
+ }
40
+ } catch { /* corrupt cache → start fresh */ }
41
+ return new Map();
42
+ }
43
+
44
+ /** Persist the enrichment cache (atomic write via tmp + rename). */
45
+ export function saveEnrichmentCache(cachePath, cache) {
46
+ const payload = JSON.stringify({ version: 1, entries: Object.fromEntries(cache) });
47
+ const tmp = `${cachePath}.tmp`;
48
+ fs.writeFileSync(tmp, payload);
49
+ fs.renameSync(tmp, cachePath);
50
+ }
51
+
52
+ /** Attach a cached enrichment entry to a chunk (summary, concepts, hyde). */
53
+ export function attachEnrichment(chunk, entry) {
54
+ if (!entry) return false;
55
+ chunk.summary = entry.summary || '';
56
+ chunk.concepts = Array.isArray(entry.concepts) ? entry.concepts : [];
57
+ chunk.hyde = chunk.concepts.join(' ') || chunk.summary;
58
+ return Boolean(chunk.summary || chunk.concepts.length);
59
+ }
60
+
61
+ // Two-line output format optimised for small (1.5B–3B) models:
62
+ // • SUMMARY — declarative sentence in developer vocabulary; goes into the vector
63
+ // embedding as the leading field, aligned with nomic-embed-text's
64
+ // search_document: training objective.
65
+ // • TAGS — comma-separated domain keywords; joined into chunk.hyde so BM25
66
+ // receives high-IDF concept terms instead of question stopword noise.
67
+ //
68
+ // One-shot example is mandatory for 1.5B models to reliably follow the format.
69
+ export const buildEnrichPrompt = (chunk) => {
70
+ const code = (chunk.code_snippet || '').slice(0, 700);
71
+ const base = path.basename(chunk.file_path);
72
+ const ctx = chunk.class_context ? ` (method of class ${chunk.class_context})` : '';
73
+ const doc = chunk.docstring ? `Doc: ${chunk.docstring.slice(0, 200)}\n` : '';
74
+ return (
75
+ `Output exactly two lines for this ${chunk.node_type} "${chunk.name}"${ctx} in ${base}:\n`
76
+ + `SUMMARY: one sentence describing what it DOES in plain developer vocabulary (the behavior, not the syntax)\n`
77
+ + `TAGS: 5-8 comma-separated search keywords a developer would type to find this\n\n`
78
+ + `Example:\n`
79
+ + `SUMMARY: validates JWT tokens and refreshes expired authentication sessions\n`
80
+ + `TAGS: authentication, JWT, token validation, session refresh, middleware\n\n`
81
+ + doc
82
+ + `Code:\n${code}\n`
83
+ + `Output:`
84
+ );
85
+ };
86
+
87
+ /**
88
+ * Default generator: a single non-streaming Ollama /api/generate call. Returns the
89
+ * response text, or null on any failure (enrichment is best-effort and never fatal).
90
+ */
91
+ export async function ollamaGenerate(prompt, { model, ollamaHost, timeoutMs = 30000, options = null } = {}) {
92
+ try {
93
+ const res = await fetch(`${ollamaHost}/api/generate`, {
94
+ method: 'POST',
95
+ headers: { 'Content-Type': 'application/json' },
96
+ body: JSON.stringify({
97
+ model, prompt, stream: false,
98
+ options: options || { temperature: 0.1, num_predict: 150 },
99
+ }),
100
+ signal: AbortSignal.timeout(timeoutMs),
101
+ });
102
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
103
+ const data = await res.json();
104
+ return (data.response || '').trim() || null;
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Parse the SUMMARY + TAGS response into { summary, concepts, hyde }.
112
+ *
113
+ * hyde = concepts.join(' ') — a space-separated keyword string fed to BM25.
114
+ * Concepts are domain terms only (stopwords scrubbed), so every token has
115
+ * meaningful IDF weight. Falls back to extracting non-stopword keywords from
116
+ * the summary when the model omits the TAGS line (graceful degradation).
117
+ */
118
+ export function parseEnrichResponse(text) {
119
+ if (!text) return null;
120
+ const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
121
+ let summary = '';
122
+ const concepts = [];
123
+
124
+ const _stop = new Set([
125
+ 'the', 'a', 'an', 'this', 'that', 'these', 'those', 'is', 'are', 'was', 'were', 'be',
126
+ 'been', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should',
127
+ 'to', 'of', 'in', 'for', 'on', 'with', 'at', 'by', 'from', 'as', 'it', 'its', 'and', 'or',
128
+ 'not', 'no', 'so', 'if', 'but', 'how', 'what', 'when', 'where', 'which', 'who',
129
+ ]);
130
+
131
+ for (const line of lines) {
132
+ const sm = line.match(/^SUMMARY:\s*(.+)$/i);
133
+ if (sm) { summary = sm[1].trim(); continue; }
134
+ const tm = line.match(/^TAGS?:\s*(.+)$/i);
135
+ if (tm) {
136
+ tm[1].split(',').forEach(t => {
137
+ const tok = t.trim().toLowerCase();
138
+ if (tok.length >= 2 && tok.length <= 40 && !_stop.has(tok)) concepts.push(tok);
139
+ });
140
+ }
141
+ }
142
+
143
+ if (!summary && lines.length) summary = lines[0].replace(/^SUMMARY:\s*/i, '').trim();
144
+
145
+ // Fallback: derive concepts from the summary when TAGS line is absent or empty.
146
+ if (concepts.length === 0 && summary) {
147
+ const words = summary.toLowerCase().match(/\b[a-z][a-z-]{2,}\b/g) || [];
148
+ concepts.push(...words.filter(w => !_stop.has(w)).slice(0, 8));
149
+ }
150
+
151
+ if (!summary && concepts.length === 0) return null;
152
+ const hyde = concepts.join(' ') || summary;
153
+ return { summary, concepts, hyde };
154
+ }
155
+
156
+ // ─── LLM reranking (query time, opt-in) ─────────────────────────────────────────
157
+ // Fusion resolves most queries, but natural-language queries can end in near-ties
158
+ // between the semantically right chunk and a lexically similar neighbour — a gap
159
+ // no static boost can close. Reranking shows the fused top-M to a local LLM with
160
+ // one line of context per candidate and lets it order them. One generation call
161
+ // per query (~0.5–2 s with a 1.5B model), so it is opt-in and only fires for
162
+ // natural-language queries.
163
+
164
+ /** One-line description of a chunk for the rerank prompt. (A/B-tested against a
165
+ * richer 3-line variant with signature + a wider window: the compact form wins —
166
+ * extra context and extra candidates both ADD noise for a small judge model.) */
167
+ function rerankCandidateLine(i, chunk) {
168
+ const desc = chunk.summary
169
+ || (chunk.docstring || '').split('\n')[0].slice(0, 110)
170
+ || (chunk.code_snippet || '').split('\n')[0].slice(0, 110);
171
+ const ctx = chunk.class_context ? `${chunk.class_context}.` : '';
172
+ return `${i + 1}. ${ctx}${chunk.name} [${chunk.node_type}] in ${chunk.file_path} — ${desc}`;
173
+ }
174
+
175
+ export function buildRerankPrompt(queryText, chunks) {
176
+ return (
177
+ `A developer searches a codebase for:\n"${queryText}"\n\n`
178
+ + `Candidates:\n${chunks.map((c, i) => rerankCandidateLine(i, c)).join('\n')}\n\n`
179
+ + `Order the candidates from best to worst match for the search. `
180
+ + `Output ONLY the numbers, comma-separated (e.g. 3,1,2). No other text.\n`
181
+ + `Answer:`
182
+ );
183
+ }
184
+
185
+ /** Parse "3, 1,2 ..." → unique 0-based indices < n, or null when unusable. */
186
+ export function parseRerankResponse(text, n) {
187
+ if (!text) return null;
188
+ const seen = new Set();
189
+ const order = [];
190
+ for (const m of String(text).matchAll(/\d+/g)) {
191
+ const idx = Number(m[0]) - 1;
192
+ if (idx >= 0 && idx < n && !seen.has(idx)) { seen.add(idx); order.push(idx); }
193
+ if (order.length >= n) break;
194
+ }
195
+ return order.length > 0 ? order : null;
196
+ }
197
+
198
+ /**
199
+ * Rerank the fused top-M results with a local LLM. Best-effort: any failure
200
+ * (model unreachable, unparseable output) returns the original order.
201
+ *
202
+ * @param {string} queryText
203
+ * @param {Array<{score:number, chunk:object}>} results Fused results (mutated copy returned).
204
+ * @param {object} opts
205
+ * @param {(prompt:string)=>Promise<string|null>} opts.generate
206
+ * @param {number} [opts.topM] How many leading results to rerank (default 8).
207
+ * @returns {Promise<Array<{score:number, chunk:object}>>}
208
+ */
209
+ export async function rerankResults(queryText, results, { generate, topM = 8 } = {}) {
210
+ const head = results.slice(0, topM);
211
+ if (head.length < 2) return results;
212
+ const raw = await generate(buildRerankPrompt(queryText, head.map(r => r.chunk)));
213
+ const order = parseRerankResponse(raw, head.length);
214
+ if (!order) return results;
215
+ const picked = order.map(i => head[i]);
216
+ const rest = head.filter((_, i) => !order.includes(i));
217
+ return [...picked, ...rest, ...results.slice(topM)];
218
+ }
219
+
220
+ /**
221
+ * Select the chunks worth enriching.
222
+ *
223
+ * v1 selected the top-PageRank files only — and measured terribly: PageRank
224
+ * rewards the most-IMPORTED files, which are generic leaf utilities (utils.js,
225
+ * bind.js), while the behavioural code agents actually search for conceptually
226
+ * (request dispatchers, interceptor managers, adapters) sits mid-graph and was
227
+ * never enriched. v2 selects ALL substantive chunks in production source —
228
+ * tests, specs and example/docs trees are excluded, since agents search for
229
+ * implementations — and uses PageRank only to ORDER the queue, so the per-run
230
+ * `maxChunks` cap eats the most central files first. Combined with the
231
+ * persistent cache, coverage converges over a few index runs at a bounded
232
+ * per-run cost.
233
+ *
234
+ * `coreRatio` (default 1.0 = all production files) is kept for installs that
235
+ * want to bound enrichment to the most central share of files.
236
+ */
237
+ export function selectCoreChunks(chunks, graph, { coreRatio = 1.0, maxChunks = 500 } = {}) {
238
+ const pr = computePageRank(graph);
239
+ let coreFiles = null;
240
+ if (coreRatio > 0 && coreRatio < 1) {
241
+ const files = Array.from(pr.keys()).sort((a, b) => (pr.get(b) ?? 0) - (pr.get(a) ?? 0));
242
+ coreFiles = new Set(files.slice(0, Math.max(1, Math.ceil(files.length * coreRatio))));
243
+ }
244
+
245
+ const selected = chunks.filter(c =>
246
+ c.name && c.name !== 'anonymous' && c.name !== 'default_export'
247
+ && (c.end_line - c.start_line) >= 4 // skip trivial stubs (too little code for useful enrichment)
248
+ && !TEST_FILE_RE.test(c.file_path) // agents search implementations, not test bodies
249
+ && !EXAMPLE_DIR_RE.test(c.file_path)
250
+ && (!coreFiles || coreFiles.has(c.file_path))
251
+ );
252
+ selected.sort((a, b) => (pr.get(b.file_path) ?? 0) - (pr.get(a.file_path) ?? 0));
253
+ return selected.slice(0, maxChunks);
254
+ }
255
+
256
+ /**
257
+ * Enrich the core chunks in place, attaching `chunk.hyde` and `chunk.summary`.
258
+ *
259
+ * Uses a true sliding-window pool: each worker immediately picks the next task
260
+ * when done, so concurrency is always saturated rather than waiting for the
261
+ * slowest request in a batch before advancing.
262
+ *
263
+ * @param {object[]} chunks All extracted chunks (mutated in place).
264
+ * @param {object} graph { dependencies, importedBy }.
265
+ * @param {object} config Resolved config (uses config.enrichment + config.ollamaHost).
266
+ * @param {object} [deps]
267
+ * @param {(prompt:string)=>Promise<string|null>} [deps.generate] Override generator (tests).
268
+ * @param {number} [deps.concurrency] Defaults to config.enrichment.concurrency.
269
+ * @param {string} [deps.cachePath] Defaults to config.enrichmentCachePath. Pass false to disable.
270
+ * @returns {Promise<{enriched:number, attempted:number, cached:number}>}
271
+ */
272
+ export async function enrichCoreChunks(chunks, graph, config, { generate, concurrency, cachePath } = {}) {
273
+ const { model, coreRatio, maxChunks } = config.enrichment;
274
+ const ollamaHost = config.ollamaHost;
275
+ const gen = generate || ((prompt) => ollamaGenerate(prompt, { model, ollamaHost, timeoutMs: 20000 }));
276
+ const slots = concurrency ?? config.enrichment.concurrency ?? 12;
277
+ const cacheFile = cachePath === false ? null : (cachePath || config.enrichmentCachePath || null);
278
+ const cache = cacheFile ? loadEnrichmentCache(cacheFile) : new Map();
279
+
280
+ // Cache pass FIRST, over ALL chunks — not just the current selection. The
281
+ // selection rules (test/example filters, coreRatio, caps) evolve between
282
+ // versions and runs; if cached enrichment were only attached to selected
283
+ // chunks, the same code would carry an enriched embedding key in one build
284
+ // and a plain one in the next, silently desynchronising the JSON and SQLite
285
+ // artifacts that share one embeddings bin. Cache membership is the single
286
+ // deterministic source of truth for "is this chunk enriched"; selection only
287
+ // decides which NEW chunks get LLM calls.
288
+ let cached = 0;
289
+ for (const chunk of chunks) {
290
+ if (chunk.content_hash && attachEnrichment(chunk, cache.get(chunk.content_hash))) cached++;
291
+ }
292
+
293
+ const core = selectCoreChunks(chunks, graph, { coreRatio, maxChunks });
294
+ if (core.length === 0 && cached === 0) {
295
+ console.log('🧠 LLM enrichment: no core chunks selected (empty graph) — skipping.');
296
+ return { enriched: 0, attempted: 0, cached: 0 };
297
+ }
298
+ const pending = core.filter(c => !c.summary && !(c.concepts?.length > 0));
299
+
300
+ if (pending.length === 0) {
301
+ console.log(`🧠 LLM enrichment: all ${cached} core chunks served from cache.`);
302
+ return { enriched: cached, attempted: 0, cached };
303
+ }
304
+
305
+ console.log(`🧠 LLM enrichment: ${pending.length} chunks via ${model} (${cached} from cache, concurrency: ${slots}) …`);
306
+ let enriched = 0, attempted = 0, failures = 0;
307
+ let aborted = false;
308
+
309
+ // Sliding-window pool: `slots` workers each drain the queue independently.
310
+ // No batch boundaries — a worker immediately takes the next item when done.
311
+ let cursor = 0;
312
+ async function runWorker() {
313
+ while (cursor < pending.length && !aborted) {
314
+ const chunk = pending[cursor++];
315
+ attempted++;
316
+ const raw = await gen(buildEnrichPrompt(chunk));
317
+ const parsed = parseEnrichResponse(raw);
318
+ if (parsed) {
319
+ chunk.summary = parsed.summary;
320
+ chunk.concepts = parsed.concepts;
321
+ chunk.hyde = parsed.hyde; // = concepts.join(' ') — domain keywords for BM25
322
+ if (chunk.content_hash) {
323
+ cache.set(chunk.content_hash, { summary: parsed.summary, concepts: parsed.concepts, model });
324
+ }
325
+ enriched++;
326
+ } else {
327
+ failures++;
328
+ }
329
+ process.stdout.write(`\r enriched ${enriched}/${pending.length} (failures: ${failures}) `);
330
+ // Bail early if the model is clearly unreachable.
331
+ if (attempted >= slots * 2 && enriched === 0) {
332
+ aborted = true;
333
+ console.log(`\n⚠️ LLM enrichment: generator unreachable (${failures} failures) — continuing without enrichment.`);
334
+ }
335
+ }
336
+ }
337
+
338
+ await Promise.all(Array.from({ length: Math.min(slots, pending.length) }, runWorker));
339
+
340
+ if (cacheFile && enriched > 0) {
341
+ try { saveEnrichmentCache(cacheFile, cache); }
342
+ catch (err) { console.log(`⚠️ enrichment cache save failed: ${err.message}`); }
343
+ }
344
+
345
+ console.log(`\n ✓ enrichment complete: ${enriched} enriched, ${cached} cached, ${failures} failed.`);
346
+ return { enriched: enriched + cached, attempted, cached };
347
+ }
package/indexer.mjs CHANGED
@@ -1,32 +1,30 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * @file indexer.mjs
4
- * @description In-Memory Graph Indexer — Bootstrap Engine. Reads .ts/.tsx/.js/.jsx files → Tree-sitter AST → Local Embeddings → code-index.json. Zero external dependencies (only Tree-sitter). No ChromaDB.
4
+ * @description Bootstrap indexer. Walks a repository, extracts Tree-sitter AST
5
+ * chunks + cross-file topology, generates local embeddings (Ollama),
6
+ * optionally enriches the most central chunks with an LLM, and writes
7
+ * the index to the configured backend — the default in-memory JSON
8
+ * artifacts, or a disk-backed SQLite database (--use-sqlite).
5
9
  * @author MaquinaTech <https://github.com/MaquinaTech>
6
10
  * @copyright (c) 2026 MaquinaTech. All rights reserved.
7
11
  * @license MIT
8
- * Copyright (c) 2026 MaquinaTech. All rights reserved.
9
- * Permission is hereby granted, free of charge, to any person obtaining a copy
10
- * of this software and associated documentation files (the "Software"), to deal
11
- * in the Software without restriction, including without limitation the rights
12
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
- * copies of the Software, and to permit persons to whom the Software is
14
- * furnished to do so, subject to the following conditions: The above copyright
15
- * notice and this permission notice shall be included in all copies or
16
- * substantial portions of the Software.
17
12
  */
18
13
  import fs from 'fs';
19
14
  import path from 'path';
20
15
  import {
21
16
  MAX_FILE_SIZE_BYTES, EXTENSIONS, getParserForFile, buildIgnoreFilter, getLocalEmbeddingsBatch,
22
- extractImportsFromAST, extractSemanticChunks, resolveLocalImports
17
+ extractImportsFromAST, extractSemanticChunks, resolveLocalImports, buildEmbeddingPayload,
23
18
  } from './parser-utils.mjs';
24
- import { MemoryGraphIndex, writeEmbeddingBinary } from './core-engine.mjs';
19
+ import { readEmbeddingBinary, writeEmbeddingBinary } from './core-engine.mjs';
20
+ import { embeddingKeyFor, summaryEmbeddingText, SUMMARY_VEC_SUFFIX } from './search-core.mjs';
21
+ import { resolveConfig } from './config.mjs';
22
+ import { enrichCoreChunks } from './enrichment.mjs';
25
23
 
26
- const args = process.argv.slice(2);
27
- const repoArg = args[args.indexOf("--repo") + 1] ?? process.cwd();
28
- const PROJECT_ROOT = path.resolve(repoArg);
29
- const INDEX_PATH = path.join(PROJECT_ROOT, 'code-index.json');
24
+ const config = resolveConfig();
25
+ const PROJECT_ROOT = config.projectRoot;
26
+ const INDEX_PATH = config.indexPath;
27
+ const EMBEDDINGS_PATH = config.embeddingPath;
30
28
 
31
29
  function walkRepo(dir, root, ig, files = []) {
32
30
  for (const entry of fs.readdirSync(dir)) {
@@ -44,18 +42,18 @@ function walkRepo(dir, root, ig, files = []) {
44
42
  }
45
43
 
46
44
  async function main() {
47
- console.log(`\n🚀 Starting Optimized Indexer\n📂 Directory: ${PROJECT_ROOT}\n`);
45
+ console.log(`\n🚀 Starting Optimized Indexer\n📂 Directory: ${PROJECT_ROOT}`);
46
+ console.log(`🗄 Storage: ${config.storage}${config.enrichment.enabled ? ` · 🧠 LLM enrichment: ${config.enrichment.model}` : ''}\n`);
48
47
 
49
48
  const ig = buildIgnoreFilter(PROJECT_ROOT);
50
49
  const files = walkRepo(PROJECT_ROOT, PROJECT_ROOT, ig);
51
50
  console.log(`Found ${files.length} files to analyse.\n`);
52
51
 
53
- const db = new MemoryGraphIndex(INDEX_PATH);
54
- db.load();
55
- const existingCache = db.embeddingCache;
52
+ const existingCache = readEmbeddingBinary(EMBEDDINGS_PATH);
56
53
  console.log(`📦 Loaded ${existingCache.size} cached embeddings from previous runs.\n`);
57
54
 
58
55
  const indexData = { chunks: [], graph: { dependencies: {}, importedBy: {} }, embeddingCache: {} };
56
+ const pendingChunks = [];
59
57
  let totalCheckedFiles = 0;
60
58
 
61
59
  for (const absolutePath of files) {
@@ -69,93 +67,97 @@ async function main() {
69
67
  const stats = await fs.promises.stat(absolutePath);
70
68
  if (stats.size > MAX_FILE_SIZE_BYTES) continue;
71
69
 
72
- let content = await fs.promises.readFile(absolutePath, 'utf-8');
70
+ const content = await fs.promises.readFile(absolutePath, 'utf-8');
73
71
  if (!content.trim()) continue;
74
72
 
75
73
  const ext = path.extname(absolutePath);
76
74
  const parser = getParserForFile(ext);
77
75
  if (!parser) continue;
78
76
 
79
- let tree = parser.parse((offset) => offset < content.length ? content.slice(offset, offset + 4096) : null);
77
+ const tree = parser.parse((offset) => offset < content.length ? content.slice(offset, offset + 4096) : null);
80
78
  const rawImports = extractImportsFromAST(tree.rootNode, ext);
81
79
  const imports = resolveLocalImports(rawImports, relPath, PROJECT_ROOT);
82
80
  indexData.graph.dependencies[relPath] = imports;
83
81
 
84
- const chunks = extractSemanticChunks(tree.rootNode, relPath, content, ext);
85
- for (const chunk of chunks) {
86
- if (existingCache.has(chunk.content_hash)) {
87
- indexData.embeddingCache[chunk.content_hash] = Array.from(existingCache.get(chunk.content_hash));
88
- indexData.chunks.push(chunk);
89
- } else {
90
- indexData.chunksToEmbed = indexData.chunksToEmbed || [];
91
- indexData.chunksToEmbed.push(chunk);
92
- }
82
+ // Chunks are collected first; embedding/enrichment happen in batch below so
83
+ // we can route the high-value subset through the LLM before vectorising.
84
+ for (const chunk of extractSemanticChunks(tree.rootNode, relPath, content, ext)) {
85
+ pendingChunks.push(chunk);
93
86
  }
94
87
  } catch (err) {
95
88
  console.error(`\n💥 Error in ${relPath}: ${err.message}`);
96
89
  }
97
90
  }
98
91
 
99
- const pendingChunks = indexData.chunksToEmbed || [];
92
+ // ── Optional LLM enrichment of the most central chunks (HyDE + summaries) ──────
93
+ // Runs before embedding so the hypothetical questions ride the same vector.
94
+ if (config.enrichment.enabled) {
95
+ await enrichCoreChunks(pendingChunks, indexData.graph, config);
96
+ }
97
+
98
+ // ── Embedding generation (cache-aware) ────────────────────────────────────────
99
+ // Vectors are keyed by embeddingKeyFor(chunk): content_hash for plain chunks,
100
+ // content_hash + enrichment digest for enriched ones. Because the enrichment
101
+ // cache returns the same summary for the same code, enriched chunks now HIT
102
+ // this cache on re-runs — previously every enriched chunk was re-embedded on
103
+ // every single index run.
104
+ const toEmbed = [];
105
+ for (const chunk of pendingChunks) {
106
+ const vecKey = embeddingKeyFor(chunk);
107
+ const sKey = vecKey + SUMMARY_VEC_SUFFIX;
108
+ // An enriched chunk needs BOTH vectors cached (code payload + summary) to
109
+ // skip embedding — e.g. indexes built before dual vectors only have the base.
110
+ const summaryMissing = summaryEmbeddingText(chunk) && !existingCache.has(sKey);
111
+ if (existingCache.has(vecKey) && !summaryMissing) {
112
+ indexData.embeddingCache[vecKey] = Array.from(existingCache.get(vecKey));
113
+ if (existingCache.has(sKey)) indexData.embeddingCache[sKey] = Array.from(existingCache.get(sKey));
114
+ indexData.chunks.push(chunk);
115
+ } else {
116
+ toEmbed.push(chunk);
117
+ }
118
+ }
119
+
100
120
  console.log(`\n\n🧠 Embedding Generation (Ollama)`);
101
121
  console.log(`Chunks reused from cache: ${indexData.chunks.length}`);
102
- console.log(`New chunks to process: ${pendingChunks.length}`);
122
+ console.log(`New chunks to process: ${toEmbed.length}`);
103
123
 
104
- if (pendingChunks.length > 0) {
124
+ if (toEmbed.length > 0) {
105
125
  const BATCH_SIZE = 64;
106
126
  const CONCURRENCY = 4;
107
127
  const batches = [];
108
- for (let i = 0; i < pendingChunks.length; i += BATCH_SIZE) {
109
- batches.push(pendingChunks.slice(i, i + BATCH_SIZE));
110
- }
111
-
112
- let completedChunksCount = 0;
113
- console.time("Embedding Generation Duration");
128
+ for (let i = 0; i < toEmbed.length; i += BATCH_SIZE) batches.push(toEmbed.slice(i, i + BATCH_SIZE));
114
129
 
130
+ let completed = 0;
131
+ console.time('Embedding Generation Duration');
115
132
  const worker = async (batch) => {
116
- const textsToEmbed = batch.map(c => {
117
- const dependencies = indexData.graph.dependencies[c.file_path] || [];
118
- const cleanDeps = dependencies.map(d => {
119
- const depsFile = path.relative(PROJECT_ROOT, d);
120
- const chunkMatches = indexData.chunks.filter(c => c.file_path === depsFile);
121
- return chunkMatches.map(c => c.name).join(' ');
122
- });
123
- const topologicalContext = cleanDeps.length > 0
124
- ? `This code architectural neighborhood connects with: ${cleanDeps.join(', ')}.`
125
- : '';
126
-
127
- return [
128
- `File Location: ${c.file_path}`,
129
- `Symbol Name: ${c.node_type} -> ${c.name}`,
130
- c.docstring ? `Developer Documentation: ${c.docstring}` : '',
131
- c.type_refs?.length ? `Type References: ${c.type_refs.join(', ')}` : '',
132
- topologicalContext,
133
- `--- Source Code ---`,
134
- c.code_snippet
135
- ].filter(Boolean).join('\n');
133
+ // Enriched chunks get TWO vectors: the full code payload (base key)
134
+ // and a compact summary-only text (`key|s`) that matches the vocabulary
135
+ // of natural-language queries (see search-core.summaryEmbeddingText).
136
+ const entries = [];
137
+ for (const c of batch) {
138
+ entries.push({ key: embeddingKeyFor(c), text: buildEmbeddingPayload(c, indexData.graph.dependencies[c.file_path] || []) });
139
+ const sText = summaryEmbeddingText(c);
140
+ if (sText) entries.push({ key: embeddingKeyFor(c) + SUMMARY_VEC_SUFFIX, text: sText });
141
+ }
142
+ const matrix = await getLocalEmbeddingsBatch(entries.map(e => e.text), true, {
143
+ ollamaHost: config.ollamaHost, model: config.embedModel,
136
144
  });
137
-
138
- const embeddingsMatrix = await getLocalEmbeddingsBatch(textsToEmbed, true);
139
- if (embeddingsMatrix && embeddingsMatrix.length === batch.length) {
140
- for (let j = 0; j < batch.length; j++) {
141
- const chunk = batch[j];
142
- indexData.embeddingCache[chunk.content_hash] = embeddingsMatrix[j];
143
- indexData.chunks.push(chunk);
145
+ if (matrix && matrix.length === entries.length) {
146
+ for (let j = 0; j < entries.length; j++) {
147
+ indexData.embeddingCache[entries[j].key] = matrix[j];
144
148
  }
145
- } else {
146
- for (const chunk of batch) indexData.chunks.push(chunk);
147
149
  }
148
- completedChunksCount += batch.length;
149
- process.stdout.write(`\r🤖 Embedding Progress: [${completedChunksCount}/${pendingChunks.length}] Chunks processed...`);
150
+ for (const chunk of batch) indexData.chunks.push(chunk);
151
+ completed += batch.length;
152
+ process.stdout.write(`\r🤖 Embedding Progress: [${completed}/${toEmbed.length}] Chunks processed...`);
150
153
  };
151
-
152
154
  for (let i = 0; i < batches.length; i += CONCURRENCY) {
153
- const batchGroup = batches.slice(i, i + CONCURRENCY);
154
- await Promise.all(batchGroup.map(batch => worker(batch)));
155
+ await Promise.all(batches.slice(i, i + CONCURRENCY).map(worker));
155
156
  }
156
- console.timeEnd("Embedding Generation Duration");
157
+ console.timeEnd('Embedding Generation Duration');
157
158
  }
158
159
 
160
+ // ── Reverse topology edges ────────────────────────────────────────────────────
159
161
  for (const [filePath, imports] of Object.entries(indexData.graph.dependencies)) {
160
162
  for (const dep of imports) {
161
163
  if (!indexData.graph.importedBy[dep]) indexData.graph.importedBy[dep] = [];
@@ -163,18 +165,28 @@ async function main() {
163
165
  }
164
166
  }
165
167
 
166
- const EMBEDDINGS_PATH = INDEX_PATH.replace(/\.json$/, '.embeddings.bin');
167
- const tmpPath = `${INDEX_PATH}.tmp`;
168
- const tmpBinPath = `${EMBEDDINGS_PATH}.tmp`;
169
- await Promise.all([
170
- fs.promises.writeFile(tmpPath, JSON.stringify({ chunks: indexData.chunks, graph: indexData.graph })),
171
- fs.promises.writeFile(tmpBinPath, writeEmbeddingBinary(indexData.embeddingCache)),
172
- ]);
173
- await Promise.all([
174
- fs.promises.rename(tmpPath, INDEX_PATH),
175
- fs.promises.rename(tmpBinPath, EMBEDDINGS_PATH),
176
- ]);
177
- console.log(`\n🎉 Indexing completed blazingly fast. Total fragments: ${indexData.chunks.length}\n`);
168
+ // ── Persist to the configured backend ─────────────────────────────────────────
169
+ if (config.storage === 'sqlite') {
170
+ const { SqliteGraphStore } = await import('./sqlite-store.mjs');
171
+ const store = new SqliteGraphStore(config.sqlitePath, { embeddingPath: EMBEDDINGS_PATH });
172
+ const res = store.buildFrom({
173
+ chunks: indexData.chunks, graph: indexData.graph, embeddingCache: indexData.embeddingCache,
174
+ });
175
+ console.log(`\n🎉 SQLite index built: ${res.chunks} chunks · ${res.terms} terms · dim ${res.dim}`);
176
+ console.log(` → ${config.sqlitePath}\n`);
177
+ } else {
178
+ const tmpPath = `${INDEX_PATH}.tmp`;
179
+ const tmpBinPath = `${EMBEDDINGS_PATH}.tmp`;
180
+ await Promise.all([
181
+ fs.promises.writeFile(tmpPath, JSON.stringify({ chunks: indexData.chunks, graph: indexData.graph })),
182
+ fs.promises.writeFile(tmpBinPath, writeEmbeddingBinary(indexData.embeddingCache)),
183
+ ]);
184
+ await Promise.all([
185
+ fs.promises.rename(tmpPath, INDEX_PATH),
186
+ fs.promises.rename(tmpBinPath, EMBEDDINGS_PATH),
187
+ ]);
188
+ console.log(`\n🎉 Indexing completed blazingly fast. Total fragments: ${indexData.chunks.length}\n`);
189
+ }
178
190
  }
179
191
 
180
- main().catch(console.error);
192
+ main().catch(console.error);
package/init.mjs CHANGED
@@ -263,6 +263,10 @@ function updateGitignore() {
263
263
  const entries = [
264
264
  'code-index.json',
265
265
  'code-index.embeddings.bin',
266
+ 'code-index.db',
267
+ 'code-index.db-wal',
268
+ 'code-index.db-shm',
269
+ 'code-index.enrichment.json',
266
270
  '.idx-daemon.pid',
267
271
  '.idx-daemon.log'
268
272
  ];
@@ -336,7 +340,6 @@ log(' 1. Run `npm run mcp:index` to index this project');
336
340
  log(' 2. Restart your IDE to activate the MCP server');
337
341
  log(' 3. Run `npm run mcp:start` if your IDE needs the server started manually');
338
342
 
339
- // Mensaje final de agradecimiento
340
343
  log('\n─────────────────────────────────────────────────────────────────');
341
344
  log('✨ Thank you for setting up graph-indexer!');
342
345
  log(' Enjoy your blazing-fast, AST-precise codebase search.');
@@ -348,5 +351,4 @@ log(' copy the system prompt from our repository:');
348
351
  log(' https://github.com/MaquinaTech/graph-indexer/blob/main/PROMPT.md?plain=1');
349
352
  log('─────────────────────────────────────────────────────────────────\n');
350
353
 
351
- // Cierre limpio de la ejecución para devolver el control a la terminal
352
354
  process.exit(0);