CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
semantic-search.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 3cbe3d6 | 1 | /** |
| 2 | * Block D1 — Semantic code search. | |
| 3 | * | |
| 4 | * Two embedding backends: | |
| 5 | * 1. Voyage AI (`voyage-code-3`, 1024-dim) if VOYAGE_API_KEY is set. | |
| 6 | * 2. Lexical fallback: 512-dim hashing bag-of-words, L2-normalised. | |
| 7 | * Deterministic, no network, good-enough baseline for tests + graceful | |
| 8 | * degradation when no API key is configured. | |
| 9 | * | |
| 10 | * Embeddings are stored in `code_chunks.embedding` as a JSON-encoded number | |
| 11 | * array (schema = text) so we don't depend on pgvector. Cosine similarity | |
| 12 | * is computed in JS. If/when scale demands it, swap the column type to | |
| 13 | * `vector(1024)` and push cosine into Postgres. | |
| 14 | */ | |
| 15 | ||
| 16 | import { eq } from "drizzle-orm"; | |
| 17 | import { db } from "../db"; | |
| 18 | import { codeChunks } from "../db/schema"; | |
| 19 | import { | |
| 20 | getTree, | |
| 21 | getBlob, | |
| 22 | type GitTreeEntry, | |
| 23 | } from "../git/repository"; | |
| 24 | ||
| 25 | // --------------------------------------------------------------------------- | |
| 26 | // Pure helpers | |
| 27 | // --------------------------------------------------------------------------- | |
| 28 | ||
| 29 | /** | |
| 30 | * Split code into identifier fragments. Splits on non-word boundaries, then | |
| 31 | * further splits `camelCase` and `snake_case` / kebab-case tokens into their | |
| 32 | * constituent pieces. All lowercase. Drops single-character tokens and pure | |
| 33 | * numeric tokens to keep the feature space meaningful. | |
| 34 | */ | |
| 35 | export function tokenize(code: string): string[] { | |
| 36 | if (!code) return []; | |
| 37 | const out: string[] = []; | |
| 38 | // First pass: split on non-alphanumeric (keep underscores for snake_case detection) | |
| 39 | const rough = code.split(/[^A-Za-z0-9_]+/).filter(Boolean); | |
| 40 | for (const tok of rough) { | |
| 41 | // Split snake_case / kebab (kebab already gone; underscores split here) | |
| 42 | const underscoreParts = tok.split(/_+/).filter(Boolean); | |
| 43 | for (const part of underscoreParts) { | |
| 44 | // Split camelCase / PascalCase: insert boundary before each uppercase | |
| 45 | // letter that follows a lowercase or digit, and before the last upper | |
| 46 | // in a run of uppers followed by a lower (XMLParser -> XML, Parser). | |
| 47 | const camelParts = part | |
| 48 | .replace(/([a-z0-9])([A-Z])/g, "$1 $2") | |
| 49 | .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") | |
| 50 | .split(/\s+/) | |
| 51 | .filter(Boolean); | |
| 52 | for (const cp of camelParts) { | |
| 53 | const lower = cp.toLowerCase(); | |
| 54 | if (lower.length < 2) continue; | |
| 55 | if (/^\d+$/.test(lower)) continue; | |
| 56 | out.push(lower); | |
| 57 | } | |
| 58 | } | |
| 59 | } | |
| 60 | return out; | |
| 61 | } | |
| 62 | ||
| 63 | /** | |
| 64 | * FNV-1a 32-bit hash of a string. Deterministic + fast enough for the tiny | |
| 65 | * token volumes we throw at it. | |
| 66 | */ | |
| 67 | function fnv1a(s: string): number { | |
| 68 | let h = 0x811c9dc5; | |
| 69 | for (let i = 0; i < s.length; i++) { | |
| 70 | h ^= s.charCodeAt(i); | |
| 71 | // 32-bit FNV prime multiplication via Math.imul | |
| 72 | h = Math.imul(h, 0x01000193); | |
| 73 | } | |
| 74 | return h >>> 0; | |
| 75 | } | |
| 76 | ||
| 77 | /** | |
| 78 | * Feature-hashing embedding with the sign trick. Maps each token into one of | |
| 79 | * `dim` slots; a second hash decides whether to add +1 or -1. Finally L2- | |
| 80 | * normalises so cosine ≈ dot product. | |
| 81 | */ | |
| 82 | export function hashEmbed(tokens: string[], dim = 512): number[] { | |
| 83 | const v = new Array<number>(dim).fill(0); | |
| 84 | if (!tokens.length) return v; | |
| 85 | for (const tok of tokens) { | |
| 86 | const h = fnv1a(tok); | |
| 87 | const slot = h % dim; | |
| 88 | // Sign from a perturbed second hash so it's not correlated with slot | |
| 89 | const signHash = fnv1a("\x00" + tok); | |
| 90 | const sign = signHash & 1 ? 1 : -1; | |
| 91 | v[slot] += sign; | |
| 92 | } | |
| 93 | let sumsq = 0; | |
| 94 | for (let i = 0; i < dim; i++) sumsq += v[i] * v[i]; | |
| 95 | if (sumsq === 0) return v; | |
| 96 | const inv = 1 / Math.sqrt(sumsq); | |
| 97 | for (let i = 0; i < dim; i++) v[i] *= inv; | |
| 98 | return v; | |
| 99 | } | |
| 100 | ||
| 101 | /** Cosine similarity. Assumes a, b are same length. Handles zero vectors. */ | |
| 102 | export function cosine(a: number[], b: number[]): number { | |
| 103 | if (!a || !b) return 0; | |
| 104 | const n = Math.min(a.length, b.length); | |
| 105 | let dot = 0; | |
| 106 | let na = 0; | |
| 107 | let nb = 0; | |
| 108 | for (let i = 0; i < n; i++) { | |
| 109 | dot += a[i] * b[i]; | |
| 110 | na += a[i] * a[i]; | |
| 111 | nb += b[i] * b[i]; | |
| 112 | } | |
| 113 | if (na === 0 || nb === 0) return 0; | |
| 114 | return dot / (Math.sqrt(na) * Math.sqrt(nb)); | |
| 115 | } | |
| 116 | ||
| 117 | const CODE_EXTS = new Set([ | |
| 118 | "ts", | |
| 119 | "tsx", | |
| 120 | "js", | |
| 121 | "jsx", | |
| 122 | "mjs", | |
| 123 | "cjs", | |
| 124 | "py", | |
| 125 | "go", | |
| 126 | "rs", | |
| 127 | "java", | |
| 128 | "rb", | |
| 129 | "php", | |
| 130 | "c", | |
| 131 | "cpp", | |
| 132 | "cc", | |
| 133 | "h", | |
| 134 | "hpp", | |
| 135 | "md", | |
| 136 | "mdx", | |
| 137 | "yaml", | |
| 138 | "yml", | |
| 139 | "json", | |
| 140 | "css", | |
| 141 | "html", | |
| 142 | "htm", | |
| 143 | ]); | |
| 144 | ||
| 145 | const SKIP_FILES = new Set([ | |
| 146 | "package-lock.json", | |
| 147 | "yarn.lock", | |
| 148 | "pnpm-lock.yaml", | |
| 149 | "bun.lockb", | |
| 150 | "bun.lock", | |
| 151 | "poetry.lock", | |
| 152 | "cargo.lock", | |
| 153 | "composer.lock", | |
| 154 | "gemfile.lock", | |
| 155 | ]); | |
| 156 | ||
| 157 | /** | |
| 158 | * Is the given path a code-like file we should index? Rejects lock files, | |
| 159 | * known binary extensions, images, and anything without an extension we | |
| 160 | * recognise. | |
| 161 | */ | |
| 162 | export function isCodeFile(path: string): boolean { | |
| 163 | if (!path) return false; | |
| 164 | const base = path.split("/").pop() || ""; | |
| 165 | const lower = base.toLowerCase(); | |
| 166 | if (SKIP_FILES.has(lower)) return false; | |
| 167 | const dot = lower.lastIndexOf("."); | |
| 168 | if (dot === -1) return false; | |
| 169 | const ext = lower.slice(dot + 1); | |
| 170 | if (!CODE_EXTS.has(ext)) return false; | |
| 171 | return true; | |
| 172 | } | |
| 173 | ||
| 174 | /** | |
| 175 | * Split a file's content into overlapping chunks of ~maxLines lines with | |
| 176 | * a 5-line overlap. Skips non-code files. Returns [] for empty / binary- | |
| 177 | * looking content. | |
| 178 | */ | |
| 179 | export function chunkFile( | |
| 180 | path: string, | |
| 181 | content: string, | |
| 182 | maxLines = 40 | |
| 183 | ): Array<{ path: string; startLine: number; endLine: number; content: string }> { | |
| 184 | if (!isCodeFile(path)) return []; | |
| 185 | if (!content) return []; | |
| 186 | if (content.includes("\0")) return []; // binary blob, skip | |
| 187 | const lines = content.split("\n"); | |
| 188 | if (lines.length === 0) return []; | |
| 189 | ||
| 190 | const overlap = 5; | |
| 191 | const step = Math.max(1, maxLines - overlap); | |
| 192 | const out: Array<{ | |
| 193 | path: string; | |
| 194 | startLine: number; | |
| 195 | endLine: number; | |
| 196 | content: string; | |
| 197 | }> = []; | |
| 198 | ||
| 199 | // For short files, emit a single chunk. | |
| 200 | if (lines.length <= maxLines) { | |
| 201 | out.push({ | |
| 202 | path, | |
| 203 | startLine: 1, | |
| 204 | endLine: lines.length, | |
| 205 | content: lines.join("\n"), | |
| 206 | }); | |
| 207 | return out; | |
| 208 | } | |
| 209 | ||
| 210 | for (let start = 0; start < lines.length; start += step) { | |
| 211 | const end = Math.min(lines.length, start + maxLines); | |
| 212 | out.push({ | |
| 213 | path, | |
| 214 | startLine: start + 1, | |
| 215 | endLine: end, | |
| 216 | content: lines.slice(start, end).join("\n"), | |
| 217 | }); | |
| 218 | if (end >= lines.length) break; | |
| 219 | } | |
| 220 | return out; | |
| 221 | } | |
| 222 | ||
| 223 | // --------------------------------------------------------------------------- | |
| 224 | // Provider detection | |
| 225 | // --------------------------------------------------------------------------- | |
| 226 | ||
| 227 | export function isEmbeddingsProviderAvailable(): { | |
| 228 | voyage: boolean; | |
| 229 | fallback: true; | |
| 230 | } { | |
| 231 | return { | |
| 232 | voyage: !!process.env.VOYAGE_API_KEY, | |
| 233 | fallback: true, | |
| 234 | }; | |
| 235 | } | |
| 236 | ||
| 237 | const VOYAGE_MODEL = "voyage-code-3"; | |
| 238 | const FALLBACK_MODEL = "gluecron-hash-512"; | |
| 239 | const VOYAGE_BATCH = 128; | |
| 240 | ||
| 241 | /** | |
| 242 | * Embed a batch of texts. Uses Voyage AI when VOYAGE_API_KEY is set, and | |
| 243 | * falls back to `hashEmbed(tokenize(...))` per text otherwise — or if the | |
| 244 | * Voyage request fails for any reason. | |
| 245 | * | |
| 246 | * Never throws. Always returns the same number of vectors as inputs. | |
| 247 | */ | |
| 248 | export async function embedBatch( | |
| 249 | texts: string[], | |
| 250 | inputType: "document" | "query" | |
| 251 | ): Promise<{ vectors: number[][]; model: string }> { | |
| 252 | if (!texts.length) return { vectors: [], model: FALLBACK_MODEL }; | |
| 253 | ||
| 254 | const apiKey = process.env.VOYAGE_API_KEY; | |
| 255 | if (apiKey) { | |
| 256 | const all: number[][] = []; | |
| 257 | let ok = true; | |
| 258 | for (let i = 0; i < texts.length && ok; i += VOYAGE_BATCH) { | |
| 259 | const slice = texts.slice(i, i + VOYAGE_BATCH); | |
| 260 | try { | |
| 261 | const resp = await fetch("https://api.voyageai.com/v1/embeddings", { | |
| 262 | method: "POST", | |
| 263 | headers: { | |
| 264 | "content-type": "application/json", | |
| 265 | authorization: `Bearer ${apiKey}`, | |
| 266 | }, | |
| 267 | body: JSON.stringify({ | |
| 268 | input: slice, | |
| 269 | model: VOYAGE_MODEL, | |
| 270 | input_type: inputType, | |
| 271 | }), | |
| 272 | }); | |
| 273 | if (!resp.ok) { | |
| 274 | ok = false; | |
| 275 | break; | |
| 276 | } | |
| 277 | const json: any = await resp.json(); | |
| 278 | const data = Array.isArray(json?.data) ? json.data : null; | |
| 279 | if (!data || data.length !== slice.length) { | |
| 280 | ok = false; | |
| 281 | break; | |
| 282 | } | |
| 283 | for (const row of data) { | |
| 284 | const emb = row?.embedding; | |
| 285 | if (!Array.isArray(emb)) { | |
| 286 | ok = false; | |
| 287 | break; | |
| 288 | } | |
| 289 | all.push(emb as number[]); | |
| 290 | } | |
| 291 | } catch { | |
| 292 | ok = false; | |
| 293 | break; | |
| 294 | } | |
| 295 | } | |
| 296 | if (ok && all.length === texts.length) { | |
| 297 | return { vectors: all, model: VOYAGE_MODEL }; | |
| 298 | } | |
| 299 | // fall through to fallback for the entire batch | |
| 300 | } | |
| 301 | ||
| 302 | const vectors = texts.map((t) => hashEmbed(tokenize(t), 512)); | |
| 303 | return { vectors, model: FALLBACK_MODEL }; | |
| 304 | } | |
| 305 | ||
| 306 | // --------------------------------------------------------------------------- | |
| 307 | // Tree walking | |
| 308 | // --------------------------------------------------------------------------- | |
| 309 | ||
| 310 | const MAX_CHUNKS_PER_REPO = 2000; | |
| 311 | const MAX_BLOB_BYTES = 256 * 1024; // 256KB — skip anything larger | |
| 312 | ||
| 313 | async function walkCodePaths( | |
| 314 | owner: string, | |
| 315 | repo: string, | |
| 316 | ref: string, | |
| 317 | maxFiles = 5000 | |
| 318 | ): Promise<string[]> { | |
| 319 | const out: string[] = []; | |
| 320 | const queue: string[] = [""]; | |
| 321 | while (queue.length && out.length < maxFiles) { | |
| 322 | const dir = queue.shift()!; | |
| 323 | let entries: GitTreeEntry[] = []; | |
| 324 | try { | |
| 325 | entries = await getTree(owner, repo, ref, dir); | |
| 326 | } catch { | |
| 327 | continue; | |
| 328 | } | |
| 329 | for (const e of entries) { | |
| 330 | const p = dir ? `${dir}/${e.name}` : e.name; | |
| 331 | if (e.type === "tree") { | |
| 332 | // Skip common noise directories. | |
| 333 | const base = e.name.toLowerCase(); | |
| 334 | if ( | |
| 335 | base === "node_modules" || | |
| 336 | base === ".git" || | |
| 337 | base === "dist" || | |
| 338 | base === "build" || | |
| 339 | base === "vendor" || | |
| 340 | base === ".next" || | |
| 341 | base === ".turbo" || | |
| 342 | base === "target" || | |
| 343 | base === "__pycache__" | |
| 344 | ) { | |
| 345 | continue; | |
| 346 | } | |
| 347 | queue.push(p); | |
| 348 | } else if (e.type === "blob") { | |
| 349 | if (!isCodeFile(p)) continue; | |
| 350 | if (e.size !== undefined && e.size > MAX_BLOB_BYTES) continue; | |
| 351 | out.push(p); | |
| 352 | if (out.length >= maxFiles) break; | |
| 353 | } | |
| 354 | } | |
| 355 | } | |
| 356 | return out; | |
| 357 | } | |
| 358 | ||
| 359 | // --------------------------------------------------------------------------- | |
| 360 | // Indexing | |
| 361 | // --------------------------------------------------------------------------- | |
| 362 | ||
| 363 | export interface IndexResult { | |
| 364 | chunksIndexed: number; | |
| 365 | model: string; | |
| 366 | } | |
| 367 | ||
| 368 | /** | |
| 369 | * Walk the repo tree at the given commit sha, chunk every code file, embed | |
| 370 | * the chunks in batches, and replace any previous index rows for this repo. | |
| 371 | * | |
| 372 | * Caps total chunks at `MAX_CHUNKS_PER_REPO` (logs + stops). Never throws — | |
| 373 | * returns `{ chunksIndexed: 0, model }` on any failure. | |
| 374 | */ | |
| 375 | export async function indexRepository(args: { | |
| 376 | owner: string; | |
| 377 | repo: string; | |
| 378 | repositoryId: string; | |
| 379 | commitSha: string; | |
| 380 | }): Promise<IndexResult> { | |
| 381 | const { owner, repo, repositoryId, commitSha } = args; | |
| 382 | ||
| 383 | let chunks: Array<{ | |
| 384 | path: string; | |
| 385 | startLine: number; | |
| 386 | endLine: number; | |
| 387 | content: string; | |
| 388 | }> = []; | |
| 389 | ||
| 390 | try { | |
| 391 | const paths = await walkCodePaths(owner, repo, commitSha); | |
| 392 | for (const p of paths) { | |
| 393 | if (chunks.length >= MAX_CHUNKS_PER_REPO) { | |
| 394 | console.warn( | |
| 395 | `[semantic-search] chunk cap hit (${MAX_CHUNKS_PER_REPO}) for ${owner}/${repo} @ ${commitSha}; truncating` | |
| 396 | ); | |
| 397 | break; | |
| 398 | } | |
| 399 | let blob; | |
| 400 | try { | |
| 401 | blob = await getBlob(owner, repo, commitSha, p); | |
| 402 | } catch { | |
| 403 | continue; | |
| 404 | } | |
| 405 | if (!blob || blob.isBinary) continue; | |
| 406 | const fileChunks = chunkFile(p, blob.content, 40); | |
| 407 | for (const ch of fileChunks) { | |
| 408 | if (chunks.length >= MAX_CHUNKS_PER_REPO) break; | |
| 409 | chunks.push(ch); | |
| 410 | } | |
| 411 | } | |
| 412 | } catch (err) { | |
| 413 | console.error( | |
| 414 | `[semantic-search] tree walk failed for ${owner}/${repo}:`, | |
| 415 | err | |
| 416 | ); | |
| 417 | return { chunksIndexed: 0, model: FALLBACK_MODEL }; | |
| 418 | } | |
| 419 | ||
| 420 | if (!chunks.length) { | |
| 421 | // Still wipe old rows so stale indexes don't linger. | |
| 422 | try { | |
| 423 | await db.delete(codeChunks).where(eq(codeChunks.repositoryId, repositoryId)); | |
| 424 | } catch {} | |
| 425 | return { chunksIndexed: 0, model: FALLBACK_MODEL }; | |
| 426 | } | |
| 427 | ||
| 428 | // Embed in batches sized for Voyage's 128/request limit. | |
| 429 | let model = FALLBACK_MODEL; | |
| 430 | const vectors: number[][] = []; | |
| 431 | try { | |
| 432 | for (let i = 0; i < chunks.length; i += VOYAGE_BATCH) { | |
| 433 | const slice = chunks.slice(i, i + VOYAGE_BATCH); | |
| 434 | const { vectors: vs, model: m } = await embedBatch( | |
| 435 | slice.map((c) => `${c.path}\n${c.content}`), | |
| 436 | "document" | |
| 437 | ); | |
| 438 | model = m; | |
| 439 | for (const v of vs) vectors.push(v); | |
| 440 | } | |
| 441 | } catch (err) { | |
| 442 | console.error(`[semantic-search] embed failed for ${owner}/${repo}:`, err); | |
| 443 | return { chunksIndexed: 0, model }; | |
| 444 | } | |
| 445 | ||
| 446 | if (vectors.length !== chunks.length) { | |
| 447 | console.error( | |
| 448 | `[semantic-search] vector/chunk length mismatch (${vectors.length} vs ${chunks.length})` | |
| 449 | ); | |
| 450 | return { chunksIndexed: 0, model }; | |
| 451 | } | |
| 452 | ||
| 453 | try { | |
| 454 | await db.delete(codeChunks).where(eq(codeChunks.repositoryId, repositoryId)); | |
| 455 | ||
| 456 | // Batch-insert to avoid one-row-per-roundtrip. | |
| 457 | const INSERT_BATCH = 100; | |
| 458 | for (let i = 0; i < chunks.length; i += INSERT_BATCH) { | |
| 459 | const slice = chunks.slice(i, i + INSERT_BATCH); | |
| 460 | const rows = slice.map((c, j) => ({ | |
| 461 | repositoryId, | |
| 462 | commitSha, | |
| 463 | path: c.path, | |
| 464 | startLine: c.startLine, | |
| 465 | endLine: c.endLine, | |
| 466 | content: c.content, | |
| 467 | embedding: JSON.stringify(vectors[i + j]), | |
| 468 | embeddingModel: model, | |
| 469 | })); | |
| 470 | await db.insert(codeChunks).values(rows); | |
| 471 | } | |
| 472 | } catch (err) { | |
| 473 | console.error(`[semantic-search] DB write failed for ${owner}/${repo}:`, err); | |
| 474 | return { chunksIndexed: 0, model }; | |
| 475 | } | |
| 476 | ||
| 477 | return { chunksIndexed: chunks.length, model }; | |
| 478 | } | |
| 479 | ||
| 480 | // --------------------------------------------------------------------------- | |
| 481 | // Search | |
| 482 | // --------------------------------------------------------------------------- | |
| 483 | ||
| 484 | export interface SearchHit { | |
| 485 | path: string; | |
| 486 | startLine: number; | |
| 487 | endLine: number; | |
| 488 | content: string; | |
| 489 | score: number; | |
| 490 | } | |
| 491 | ||
| 492 | /** | |
| 493 | * Embed `query`, load all chunk embeddings for this repo, rank by cosine, | |
| 494 | * return the top `limit`. Empty array if the repo has no indexed chunks or | |
| 495 | * anything fails. | |
| 496 | */ | |
| 497 | export async function searchRepository(args: { | |
| 498 | repositoryId: string; | |
| 499 | query: string; | |
| 500 | limit?: number; | |
| 501 | }): Promise<SearchHit[]> { | |
| 502 | const { repositoryId, query } = args; | |
| 503 | const limit = args.limit ?? 20; | |
| 504 | const q = (query || "").trim(); | |
| 505 | if (!q) return []; | |
| 506 | ||
| 507 | let rows: Array<{ | |
| 508 | path: string; | |
| 509 | startLine: number; | |
| 510 | endLine: number; | |
| 511 | content: string; | |
| 512 | embedding: string | null; | |
| 513 | embeddingModel: string | null; | |
| 514 | }>; | |
| 515 | try { | |
| 516 | rows = await db | |
| 517 | .select({ | |
| 518 | path: codeChunks.path, | |
| 519 | startLine: codeChunks.startLine, | |
| 520 | endLine: codeChunks.endLine, | |
| 521 | content: codeChunks.content, | |
| 522 | embedding: codeChunks.embedding, | |
| 523 | embeddingModel: codeChunks.embeddingModel, | |
| 524 | }) | |
| 525 | .from(codeChunks) | |
| 526 | .where(eq(codeChunks.repositoryId, repositoryId)); | |
| 527 | } catch { | |
| 528 | return []; | |
| 529 | } | |
| 530 | ||
| 531 | if (!rows.length) return []; | |
| 532 | ||
| 533 | // Assume all rows use the same model (indexRepository rewrites them in bulk). | |
| 534 | const model = rows[0].embeddingModel || FALLBACK_MODEL; | |
| 535 | ||
| 536 | let queryVec: number[]; | |
| 537 | if (model === VOYAGE_MODEL && process.env.VOYAGE_API_KEY) { | |
| 538 | const { vectors } = await embedBatch([q], "query"); | |
| 539 | queryVec = vectors[0]; | |
| 540 | } else { | |
| 541 | queryVec = hashEmbed(tokenize(q), 512); | |
| 542 | } | |
| 543 | ||
| 544 | const scored: SearchHit[] = []; | |
| 545 | for (const r of rows) { | |
| 546 | if (!r.embedding) continue; | |
| 547 | let v: number[]; | |
| 548 | try { | |
| 549 | v = JSON.parse(r.embedding); | |
| 550 | } catch { | |
| 551 | continue; | |
| 552 | } | |
| 553 | if (!Array.isArray(v) || v.length === 0) continue; | |
| 554 | const s = cosine(queryVec, v); | |
| 555 | scored.push({ | |
| 556 | path: r.path, | |
| 557 | startLine: r.startLine, | |
| 558 | endLine: r.endLine, | |
| 559 | content: r.content, | |
| 560 | score: s, | |
| 561 | }); | |
| 562 | } | |
| 563 | scored.sort((a, b) => b.score - a.score); | |
| 564 | return scored.slice(0, limit); | |
| 565 | } | |
| 566 | ||
| 567 | // --------------------------------------------------------------------------- | |
| 568 | // Test-only exports — pure helpers, no DB dependency. | |
| 569 | // --------------------------------------------------------------------------- | |
| 570 | ||
| 571 | export const __test = { | |
| 572 | tokenize, | |
| 573 | hashEmbed, | |
| 574 | cosine, | |
| 575 | isCodeFile, | |
| 576 | chunkFile, | |
| 577 | fnv1a, | |
| 578 | }; |