@maxgfr/codeindex 2.9.0 → 2.11.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/LICENSE +21 -0
- package/README.md +75 -0
- package/docs/MIGRATION.md +29 -0
- package/package.json +1 -1
- package/scripts/engine.d.mts +73 -3
- package/scripts/engine.mjs +635 -31
- package/src/embed/encode.ts +143 -0
- package/src/embed/endpoint.ts +144 -0
- package/src/embed/index.ts +141 -0
- package/src/embed/model.ts +117 -0
- package/src/embed/search.ts +96 -0
- package/src/engine-cli.ts +184 -5
- package/src/engine.ts +33 -0
- package/src/extract/code.ts +15 -3
- package/src/mcp.ts +61 -10
- package/src/types.ts +6 -3
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { foldText } from "../util.js";
|
|
2
|
+
import type { StaticEmbedModel } from "./model.js";
|
|
3
|
+
|
|
4
|
+
// The pure-JS deterministic encoder — the reason this whole tier can produce
|
|
5
|
+
// goldens. Every step is chosen to be byte-identical on any platform:
|
|
6
|
+
// 1. tokenize : foldText (NFKD, strip combining marks) + camelCase/ACRONYM
|
|
7
|
+
// split + lowercase + split on non-alphanumeric runs.
|
|
8
|
+
// 2. wordpiece : greedy longest-match against the model vocab, with BERT-style
|
|
9
|
+
// "##" continuation pieces; an unsplittable word → the UNK row
|
|
10
|
+
// (or nothing when the model has no UNK).
|
|
11
|
+
// 3. gather : look up each token's row (double precision).
|
|
12
|
+
// 4. mean-pool : sum rows in FIXED token order, divide by token count. IEEE-754
|
|
13
|
+
// double add/div is deterministic for a fixed evaluation order.
|
|
14
|
+
// 5. L2-norm : divide by sqrt(Σ v²) — sqrt is correctly-rounded per IEEE-754,
|
|
15
|
+
// so identical across engines.
|
|
16
|
+
// 6. quantize : multiply the unit vector by 127, round-half-to-EVEN (NOT
|
|
17
|
+
// Math.round, which is half-up), clamp to [-127, 127] → int8.
|
|
18
|
+
// The fixed 1/127 scale means ranking is a PURE INTEGER dot product (no per-vector
|
|
19
|
+
// float scale to store or multiply): for a fixed query every corpus vector shares
|
|
20
|
+
// the same scale, so the integer dot Σ q·c is monotonic with the cosine estimate.
|
|
21
|
+
|
|
22
|
+
const QUANT = 127;
|
|
23
|
+
|
|
24
|
+
// Split raw text into lowercase, diacritic-folded alphanumeric words. camelCase
|
|
25
|
+
// and ACRONYMWord boundaries become spaces FIRST (so "verifyAuthToken" exposes
|
|
26
|
+
// "verify","auth","token" as separate words to wordpiece), then any
|
|
27
|
+
// non-alphanumeric run splits. Mirrors the boundary rules bm25 `subtokens` uses,
|
|
28
|
+
// keeping the two search tiers tokenizing code identifiers alike.
|
|
29
|
+
export function basicTokenize(text: string): string[] {
|
|
30
|
+
const spaced = foldText(text)
|
|
31
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
32
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2");
|
|
33
|
+
const out: string[] = [];
|
|
34
|
+
for (const part of spaced.toLowerCase().split(/[^a-z0-9]+/)) {
|
|
35
|
+
if (part) out.push(part);
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Greedy longest-match wordpiece for ONE word. The first piece is looked up as
|
|
41
|
+
// written; every subsequent piece is looked up with a "##" prefix. If no piece
|
|
42
|
+
// matches at some position the whole word is unresolvable → [unkId] (or [] when
|
|
43
|
+
// the model has no UNK row). Deterministic: longest-match is a fixed scan.
|
|
44
|
+
export function wordpiece(word: string, model: StaticEmbedModel): number[] {
|
|
45
|
+
if (!word) return [];
|
|
46
|
+
const ids: number[] = [];
|
|
47
|
+
let start = 0;
|
|
48
|
+
const n = word.length;
|
|
49
|
+
while (start < n) {
|
|
50
|
+
let end = n;
|
|
51
|
+
let match = -1;
|
|
52
|
+
while (end > start) {
|
|
53
|
+
const piece = start === 0 ? word.slice(start, end) : "##" + word.slice(start, end);
|
|
54
|
+
const id = model.vocab.get(piece);
|
|
55
|
+
if (id !== undefined) {
|
|
56
|
+
match = id;
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
end--;
|
|
60
|
+
}
|
|
61
|
+
if (match === -1) return model.unkId >= 0 ? [model.unkId] : []; // whole word is OOV
|
|
62
|
+
ids.push(match);
|
|
63
|
+
start = end;
|
|
64
|
+
}
|
|
65
|
+
return ids;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Full token-id sequence for a text: tokenize → wordpiece each word, concatenated
|
|
69
|
+
// in order. The order is load-bearing for determinism, but mean-pooling is
|
|
70
|
+
// order-independent anyway; the fixed order simply makes reasoning trivial.
|
|
71
|
+
export function tokenize(text: string, model: StaticEmbedModel): number[] {
|
|
72
|
+
const ids: number[] = [];
|
|
73
|
+
for (const word of basicTokenize(text)) {
|
|
74
|
+
for (const id of wordpiece(word, model)) ids.push(id);
|
|
75
|
+
}
|
|
76
|
+
return ids;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Round half to EVEN (banker's rounding). JS `Math.round` rounds half toward
|
|
80
|
+
// +∞, which is not symmetric and would bias quantization; half-to-even is the
|
|
81
|
+
// IEEE-754 default and keeps quantize(x) === -quantize(-x).
|
|
82
|
+
export function roundHalfToEven(x: number): number {
|
|
83
|
+
const f = Math.floor(x);
|
|
84
|
+
const diff = x - f;
|
|
85
|
+
if (diff < 0.5) return f;
|
|
86
|
+
if (diff > 0.5) return f + 1;
|
|
87
|
+
return f % 2 === 0 ? f : f + 1; // exactly .5 → nearest even
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// L2-normalize a float vector and quantize it to int8 at the fixed 1/127 scale
|
|
91
|
+
// (round-half-to-even, clamp to [-127,127]) — the SHARED tail of both tiers. The
|
|
92
|
+
// static encoder feeds its mean-pooled vector here; the endpoint tier feeds each
|
|
93
|
+
// float embedding it receives. Because the quantization is byte-identical, a
|
|
94
|
+
// given float vector maps to the same int8 bytes and the integer-dot ranking is
|
|
95
|
+
// consistent across tiers. A zero-norm input yields an all-zero vector (never
|
|
96
|
+
// NaN — it simply ranks last).
|
|
97
|
+
export function quantize(vec: ArrayLike<number>): Int8Array {
|
|
98
|
+
const dim = vec.length;
|
|
99
|
+
const out = new Int8Array(dim);
|
|
100
|
+
let sumsq = 0;
|
|
101
|
+
for (let d = 0; d < dim; d++) sumsq += vec[d]! * vec[d]!;
|
|
102
|
+
const norm = Math.sqrt(sumsq);
|
|
103
|
+
if (norm === 0) return out; // zero vector — nothing to rank on
|
|
104
|
+
for (let d = 0; d < dim; d++) {
|
|
105
|
+
let q = roundHalfToEven((vec[d]! / norm) * QUANT);
|
|
106
|
+
if (q > QUANT) q = QUANT;
|
|
107
|
+
else if (q < -QUANT) q = -QUANT;
|
|
108
|
+
out[d] = q;
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Encode a text to an int8 unit-ish vector. Empty / all-OOV / zero-norm inputs
|
|
114
|
+
// yield an all-zero vector (dot-products against it are 0 — it simply ranks
|
|
115
|
+
// last), never NaN. Pure and deterministic: same model + text → same bytes.
|
|
116
|
+
export function encode(model: StaticEmbedModel, text: string): Int8Array {
|
|
117
|
+
const { dim, weights } = model;
|
|
118
|
+
const ids = tokenize(text, model);
|
|
119
|
+
if (ids.length === 0) return new Int8Array(dim);
|
|
120
|
+
|
|
121
|
+
// mean-pool in double precision, fixed order.
|
|
122
|
+
const pooled = new Float64Array(dim);
|
|
123
|
+
for (const id of ids) {
|
|
124
|
+
const base = id * dim;
|
|
125
|
+
for (let d = 0; d < dim; d++) pooled[d]! += weights[base + d]!;
|
|
126
|
+
}
|
|
127
|
+
const inv = 1 / ids.length;
|
|
128
|
+
for (let d = 0; d < dim; d++) pooled[d]! *= inv;
|
|
129
|
+
|
|
130
|
+
// L2-normalize + int8-quantize (the shared tail, identical to the endpoint tier).
|
|
131
|
+
return quantize(pooled);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Integer dot product of two int8 vectors (the ranking primitive). Widened to a
|
|
135
|
+
// JS number (double), but every operand and partial sum is an exact integer
|
|
136
|
+
// (max |Σ| for dim 512 ≈ 512·127² ≈ 8.3M, far inside 2⁵³) — so this is an EXACT
|
|
137
|
+
// integer computation, deterministic everywhere.
|
|
138
|
+
export function intDot(a: Int8Array, b: Int8Array): number {
|
|
139
|
+
const n = Math.min(a.length, b.length);
|
|
140
|
+
let dot = 0;
|
|
141
|
+
for (let i = 0; i < n; i++) dot += a[i]! * b[i]!;
|
|
142
|
+
return dot;
|
|
143
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// HTTP embedding endpoint client — the "rich" tier (v2.11.0). The engine becomes
|
|
2
|
+
// a CONSUMER of a local containerized embedding server (e.g.
|
|
3
|
+
// ghcr.io/maxgfr/codeindex-embed): it POSTs corpus/query texts, receives float
|
|
4
|
+
// vectors, and runs them through the EXACT SAME L2-normalize + int8-quantize
|
|
5
|
+
// pipeline as the static tier (encode.ts `quantize`), so ranking stays a pure
|
|
6
|
+
// integer dot product. Node >=18 has global fetch, so this stays zero-dependency.
|
|
7
|
+
//
|
|
8
|
+
// The contract: POST { texts: string[] } → { vectors: number[][] } to
|
|
9
|
+
// `${base}/embed`, plus GET `${base}/healthz`. `base` is CODEINDEX_EMBED_ENDPOINT
|
|
10
|
+
// (or opts.url). The library NEVER orchestrates docker; running the server is a
|
|
11
|
+
// user/CI concern (`codeindex embed serve` only prints/runs the docker command).
|
|
12
|
+
//
|
|
13
|
+
// DETERMINISM: this tier is deterministic PER IMAGE DIGEST (the model is baked
|
|
14
|
+
// into a pinned image), NOT byte-golden — endpoint float vectors are provider-
|
|
15
|
+
// dependent. That is why endpoint indexes are built at search time and never
|
|
16
|
+
// serialized to embeddings.bin (which carries a determinism guarantee).
|
|
17
|
+
|
|
18
|
+
import type { RepoScan } from "../scan.js";
|
|
19
|
+
import { quantize } from "./encode.js";
|
|
20
|
+
import { EMBED_VERSION } from "./model.js";
|
|
21
|
+
import { embeddingUnits, type EmbeddingIndex, type EmbeddingRecord } from "./index.js";
|
|
22
|
+
|
|
23
|
+
export interface EmbedEndpointOptions {
|
|
24
|
+
// Endpoint BASE url (e.g. http://localhost:8756). Falls back to
|
|
25
|
+
// CODEINDEX_EMBED_ENDPOINT when omitted.
|
|
26
|
+
url?: string;
|
|
27
|
+
// Abort a request after this many ms (default from CODEINDEX_EMBED_TIMEOUT_MS,
|
|
28
|
+
// else 30_000).
|
|
29
|
+
timeoutMs?: number;
|
|
30
|
+
// Extra request headers (e.g. an auth token for a private endpoint).
|
|
31
|
+
headers?: Record<string, string>;
|
|
32
|
+
// Max texts per POST /embed request when embedding a corpus (default 64).
|
|
33
|
+
batchSize?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Resolve the configured endpoint BASE url, or undefined when neither opts.url
|
|
37
|
+
// nor CODEINDEX_EMBED_ENDPOINT is set — the "endpoint tier not requested" signal.
|
|
38
|
+
export function resolveEmbedEndpoint(opts: EmbedEndpointOptions = {}): string | undefined {
|
|
39
|
+
const url = opts.url ?? process.env.CODEINDEX_EMBED_ENDPOINT;
|
|
40
|
+
return url && url.trim() ? url.trim() : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function stripTrailingSlash(url: string): string {
|
|
44
|
+
return url.replace(/\/+$/, "");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The POST /embed URL for a base. Idempotent when the base already ends in
|
|
48
|
+
// /embed (so pointing the env straight at the embed route also works).
|
|
49
|
+
export function embedEndpointUrl(base: string): string {
|
|
50
|
+
const b = stripTrailingSlash(base);
|
|
51
|
+
return b.endsWith("/embed") ? b : b + "/embed";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The GET /healthz URL for a base (tolerates a base that already ends in /embed).
|
|
55
|
+
export function healthzUrl(base: string): string {
|
|
56
|
+
return stripTrailingSlash(base).replace(/\/embed$/, "") + "/healthz";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function resolveTimeout(opts: EmbedEndpointOptions): number {
|
|
60
|
+
if (typeof opts.timeoutMs === "number") return opts.timeoutMs;
|
|
61
|
+
const env = Number(process.env.CODEINDEX_EMBED_TIMEOUT_MS);
|
|
62
|
+
return Number.isFinite(env) && env > 0 ? env : 30_000;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Request float embeddings for a batch of texts from the endpoint's /embed
|
|
66
|
+
// route. Throws a clear error when no endpoint is configured, the request times
|
|
67
|
+
// out / fails, or the response is malformed; the caller decides whether to
|
|
68
|
+
// degrade. NOTE: endpoint vectors are float and are NOT byte-deterministic.
|
|
69
|
+
export async function embedViaEndpoint(texts: string[], opts: EmbedEndpointOptions = {}): Promise<number[][]> {
|
|
70
|
+
const base = resolveEmbedEndpoint(opts);
|
|
71
|
+
if (!base) throw new Error("no embedding endpoint configured (set CODEINDEX_EMBED_ENDPOINT or pass opts.url)");
|
|
72
|
+
const url = embedEndpointUrl(base);
|
|
73
|
+
const controller = new AbortController();
|
|
74
|
+
const timer = setTimeout(() => controller.abort(), resolveTimeout(opts));
|
|
75
|
+
try {
|
|
76
|
+
const res = await fetch(url, {
|
|
77
|
+
method: "POST",
|
|
78
|
+
headers: { "content-type": "application/json", ...(opts.headers ?? {}) },
|
|
79
|
+
body: JSON.stringify({ texts }),
|
|
80
|
+
signal: controller.signal,
|
|
81
|
+
});
|
|
82
|
+
if (!res.ok) throw new Error(`embedding endpoint ${url} returned HTTP ${res.status}`);
|
|
83
|
+
const data = (await res.json()) as { vectors?: unknown };
|
|
84
|
+
const vectors = data.vectors;
|
|
85
|
+
if (!Array.isArray(vectors) || !vectors.every((v) => Array.isArray(v) && v.every((x) => typeof x === "number"))) {
|
|
86
|
+
throw new Error(`embedding endpoint ${url} returned a malformed { vectors } payload`);
|
|
87
|
+
}
|
|
88
|
+
return vectors as number[][];
|
|
89
|
+
} finally {
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Is the endpoint reachable? GETs /healthz and returns true only on a 2xx. Never
|
|
95
|
+
// throws — a down/timed-out endpoint returns false (used by `embed status`).
|
|
96
|
+
export async function probeEndpoint(base: string, opts: EmbedEndpointOptions = {}): Promise<boolean> {
|
|
97
|
+
const controller = new AbortController();
|
|
98
|
+
const timer = setTimeout(() => controller.abort(), resolveTimeout(opts));
|
|
99
|
+
try {
|
|
100
|
+
const res = await fetch(healthzUrl(base), { signal: controller.signal, headers: opts.headers });
|
|
101
|
+
return res.ok;
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
} finally {
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Encode ONE query string via the endpoint → int8 vector, run through the shared
|
|
110
|
+
// quantize pipeline (so it lives in the same int8 space as an endpoint-built
|
|
111
|
+
// corpus). Throws on an unreachable/malformed endpoint — the caller degrades.
|
|
112
|
+
export async function encodeQueryViaEndpoint(query: string, opts: EmbedEndpointOptions = {}): Promise<Int8Array> {
|
|
113
|
+
const [vec] = await embedViaEndpoint([query], opts);
|
|
114
|
+
if (!vec) throw new Error("embedding endpoint returned no vector for the query");
|
|
115
|
+
return quantize(vec);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Build the int8 corpus index by embedding every corpus unit (the SAME units the
|
|
119
|
+
// static tier uses) via the endpoint, batched, then quantizing each float vector
|
|
120
|
+
// through the shared L2+int8 pipeline. The result plugs straight into
|
|
121
|
+
// searchSemantic's integer-dot ranking. Built at search time (never serialized).
|
|
122
|
+
export async function buildEndpointIndex(scan: RepoScan, opts: EmbedEndpointOptions = {}): Promise<EmbeddingIndex> {
|
|
123
|
+
const units = embeddingUnits(scan);
|
|
124
|
+
const batchSize = opts.batchSize && opts.batchSize > 0 ? opts.batchSize : 64;
|
|
125
|
+
const records: EmbeddingRecord[] = [];
|
|
126
|
+
let dim = 0;
|
|
127
|
+
for (let i = 0; i < units.length; i += batchSize) {
|
|
128
|
+
const batch = units.slice(i, i + batchSize);
|
|
129
|
+
const vectors = await embedViaEndpoint(batch.map((u) => u.text), opts);
|
|
130
|
+
if (vectors.length !== batch.length) {
|
|
131
|
+
throw new Error(`embedding endpoint returned ${vectors.length} vectors for ${batch.length} texts`);
|
|
132
|
+
}
|
|
133
|
+
for (let j = 0; j < batch.length; j++) {
|
|
134
|
+
const u = batch[j]!;
|
|
135
|
+
const vec = quantize(vectors[j]!);
|
|
136
|
+
if (vec.length > dim) dim = vec.length;
|
|
137
|
+
const rec: EmbeddingRecord = { file: u.file, vec };
|
|
138
|
+
if (u.symbol !== undefined) rec.symbol = u.symbol;
|
|
139
|
+
if (u.line !== undefined) rec.line = u.line;
|
|
140
|
+
records.push(rec);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { embedVersion: EMBED_VERSION, modelId: "endpoint", dim, records };
|
|
144
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { RepoScan } from "../scan.js";
|
|
2
|
+
import { encode } from "./encode.js";
|
|
3
|
+
import { EMBED_VERSION, type StaticEmbedModel } from "./model.js";
|
|
4
|
+
|
|
5
|
+
// The corpus embedding artifact. Granularity is PER-SYMBOL (each symbol gets its
|
|
6
|
+
// own record, so search can surface the exact symbol that matched), with a
|
|
7
|
+
// PER-FILE fallback record for files that declare no symbols (docs, config) so
|
|
8
|
+
// every file with content is still represented. Deterministic: records follow
|
|
9
|
+
// scan order (files sorted by rel) then declaration order within a file.
|
|
10
|
+
export interface EmbeddingRecord {
|
|
11
|
+
file: string; // repo-relative path
|
|
12
|
+
symbol?: string; // the declared symbol this vector represents (absent for a file-level record)
|
|
13
|
+
line?: number; // 1-based symbol line (absent for a file-level record)
|
|
14
|
+
vec: Int8Array; // length === dim
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface EmbeddingIndex {
|
|
18
|
+
embedVersion: number;
|
|
19
|
+
modelId: string;
|
|
20
|
+
dim: number;
|
|
21
|
+
records: EmbeddingRecord[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The text encoded for one symbol: its name (camelCase-split by the tokenizer),
|
|
25
|
+
// its signature, the owning file's one-line summary, and the file's path segments
|
|
26
|
+
// — the same signal bm25 indexes, but pooled into a single vector. Newline-joined
|
|
27
|
+
// so the tokenizer's non-alphanumeric split cleanly separates the parts.
|
|
28
|
+
function symbolText(rel: string, name: string, signature: string | undefined, summary: string | undefined): string {
|
|
29
|
+
return [name, signature ?? "", summary ?? "", rel.replace(/\//g, " ")].join("\n");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A file-level record's text (symbol-less files): title, summary, headings, path.
|
|
33
|
+
function fileText(rel: string, title: string | undefined, summary: string | undefined, headings: string[]): string {
|
|
34
|
+
return [title ?? "", summary ?? "", ...headings, rel.replace(/\//g, " ")].join("\n");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// One corpus item to embed: its target (file, optional symbol/line) plus the
|
|
38
|
+
// exact text to encode. This is the SINGLE definition of "what the corpus is",
|
|
39
|
+
// shared by the static tier (buildEmbeddingIndex) and the endpoint tier
|
|
40
|
+
// (buildEndpointIndex) so both embed byte-identical texts in the same order and
|
|
41
|
+
// differ ONLY in the encoder. Deterministic: scan order (files by rel) then
|
|
42
|
+
// declaration order within a file, deduped by symbol name (bm25 parity).
|
|
43
|
+
export interface EmbeddingUnit {
|
|
44
|
+
file: string;
|
|
45
|
+
symbol?: string;
|
|
46
|
+
line?: number;
|
|
47
|
+
text: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function embeddingUnits(scan: RepoScan): EmbeddingUnit[] {
|
|
51
|
+
const units: EmbeddingUnit[] = [];
|
|
52
|
+
for (const f of scan.files) {
|
|
53
|
+
const seen = new Set<string>();
|
|
54
|
+
let hadSymbol = false;
|
|
55
|
+
for (const s of f.symbols) {
|
|
56
|
+
if (seen.has(s.name)) continue; // dedupe by name within a file (bm25 parity)
|
|
57
|
+
seen.add(s.name);
|
|
58
|
+
hadSymbol = true;
|
|
59
|
+
units.push({ file: f.rel, symbol: s.name, line: s.line, text: symbolText(f.rel, s.name, s.signature, f.summary) });
|
|
60
|
+
}
|
|
61
|
+
if (!hadSymbol) {
|
|
62
|
+
const text = fileText(f.rel, f.title, f.summary, f.headings);
|
|
63
|
+
if (text.replace(/\s+/g, "")) units.push({ file: f.rel, text });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return units;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Build the corpus embedding index from a scan + a loaded model. Pure and
|
|
70
|
+
// deterministic (encode is byte-stable, scan order is fixed) → two builds of an
|
|
71
|
+
// unchanged repo produce byte-identical serialized bytes.
|
|
72
|
+
export function buildEmbeddingIndex(scan: RepoScan, model: StaticEmbedModel): EmbeddingIndex {
|
|
73
|
+
const records: EmbeddingRecord[] = embeddingUnits(scan).map((u) => {
|
|
74
|
+
const rec: EmbeddingRecord = { file: u.file, vec: encode(model, u.text) };
|
|
75
|
+
if (u.symbol !== undefined) rec.symbol = u.symbol;
|
|
76
|
+
if (u.line !== undefined) rec.line = u.line;
|
|
77
|
+
return rec;
|
|
78
|
+
});
|
|
79
|
+
return { embedVersion: EMBED_VERSION, modelId: model.modelId, dim: model.dim, records };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const MAGIC = "CIE1"; // codeindex embeddings, format 1
|
|
83
|
+
|
|
84
|
+
// Serialize to embeddings.bin: a fixed ASCII magic, a uint32-LE header length, a
|
|
85
|
+
// UTF-8 JSON header (per-record metadata in build order — carries NO absolute
|
|
86
|
+
// path or timestamp), then the packed int8 body (count × dim signed bytes). The
|
|
87
|
+
// header JSON key order is fixed by construction, and every body byte is written
|
|
88
|
+
// explicitly, so the bytes are fully deterministic. Returns a Uint8Array (a
|
|
89
|
+
// Buffer at runtime) so the public type surface stays free of the Node `Buffer`
|
|
90
|
+
// global — a consumer type-checking without @types/node still resolves it.
|
|
91
|
+
export function serializeEmbeddings(index: EmbeddingIndex): Uint8Array {
|
|
92
|
+
const header = JSON.stringify({
|
|
93
|
+
embedVersion: index.embedVersion,
|
|
94
|
+
modelId: index.modelId,
|
|
95
|
+
dim: index.dim,
|
|
96
|
+
count: index.records.length,
|
|
97
|
+
records: index.records.map((r) => ({ file: r.file, symbol: r.symbol ?? "", line: r.line ?? 0 })),
|
|
98
|
+
});
|
|
99
|
+
const headerBuf = Buffer.from(header, "utf8");
|
|
100
|
+
const body = Buffer.alloc(index.records.length * index.dim);
|
|
101
|
+
let off = 0;
|
|
102
|
+
for (const r of index.records) {
|
|
103
|
+
for (let d = 0; d < index.dim; d++) body.writeInt8(r.vec[d] ?? 0, off++);
|
|
104
|
+
}
|
|
105
|
+
const out = Buffer.alloc(8 + headerBuf.length + body.length);
|
|
106
|
+
out.write(MAGIC, 0, "ascii");
|
|
107
|
+
out.writeUInt32LE(headerBuf.length, 4);
|
|
108
|
+
headerBuf.copy(out, 8);
|
|
109
|
+
body.copy(out, 8 + headerBuf.length);
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Inverse of serializeEmbeddings. Accepts any Uint8Array (e.g. a fs.readFileSync
|
|
114
|
+
// Buffer) and wraps it as a Buffer VIEW (no copy) for the numeric reads. Throws
|
|
115
|
+
// on a bad magic (a corrupt or foreign file) so a caller fails loudly rather
|
|
116
|
+
// than misreading arbitrary bytes.
|
|
117
|
+
export function deserializeEmbeddings(bytes: Uint8Array): EmbeddingIndex {
|
|
118
|
+
const buf = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
119
|
+
if (buf.length < 8 || buf.toString("ascii", 0, 4) !== MAGIC) {
|
|
120
|
+
throw new Error("embeddings.bin: bad magic (not a codeindex embeddings artifact)");
|
|
121
|
+
}
|
|
122
|
+
const headerLen = buf.readUInt32LE(4);
|
|
123
|
+
const header = JSON.parse(buf.toString("utf8", 8, 8 + headerLen)) as {
|
|
124
|
+
embedVersion: number;
|
|
125
|
+
modelId: string;
|
|
126
|
+
dim: number;
|
|
127
|
+
count: number;
|
|
128
|
+
records: { file: string; symbol: string; line: number }[];
|
|
129
|
+
};
|
|
130
|
+
const bodyOff = 8 + headerLen;
|
|
131
|
+
const { dim } = header;
|
|
132
|
+
const records: EmbeddingRecord[] = header.records.map((m, i) => {
|
|
133
|
+
const vec = new Int8Array(dim);
|
|
134
|
+
for (let d = 0; d < dim; d++) vec[d] = buf.readInt8(bodyOff + i * dim + d);
|
|
135
|
+
const rec: EmbeddingRecord = { file: m.file, vec };
|
|
136
|
+
if (m.symbol) rec.symbol = m.symbol;
|
|
137
|
+
if (m.line) rec.line = m.line;
|
|
138
|
+
return rec;
|
|
139
|
+
});
|
|
140
|
+
return { embedVersion: header.embedVersion, modelId: header.modelId, dim, records };
|
|
141
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
// The deterministic static-embedding tier (v2.10.0). A *static* embedding model
|
|
5
|
+
// is a plain lookup table: token → row vector. Inference is tokenize → gather
|
|
6
|
+
// rows → mean-pool → L2-normalize → int8-quantize (see encode.ts). There is NO
|
|
7
|
+
// neural forward pass, no wasm, no float matmul — so the encode is pure-JS and
|
|
8
|
+
// byte-identical across platforms, which is the whole reason this tier exists.
|
|
9
|
+
//
|
|
10
|
+
// Activation is OPT-IN by asset presence, exactly like the tree-sitter grammar
|
|
11
|
+
// tier (src/ast/loader.ts): no model on disk → the engine silently stays
|
|
12
|
+
// lexical. Models are NEVER shipped in the npm tarball; the asset is fetched by
|
|
13
|
+
// `codeindex embed pull` into CODEINDEX_EMBED_DIR (or <repo>/.codeindex/models/).
|
|
14
|
+
|
|
15
|
+
// Bumped independently of SCHEMA_VERSION / EXTRACTOR_VERSION whenever the
|
|
16
|
+
// embeddings.bin artifact shape or the encode algorithm changes, so a consumer
|
|
17
|
+
// rejects an embeddings.bin written by an incompatible engine (or a different
|
|
18
|
+
// model) instead of misranking with it. SCHEMA_VERSION is deliberately left
|
|
19
|
+
// UNTOUCHED — embeddings are a purely additive sidecar with zero impact on the
|
|
20
|
+
// graph.json / symbols.json consumers.
|
|
21
|
+
export const EMBED_VERSION = 1;
|
|
22
|
+
|
|
23
|
+
// The default model-name a `pull` writes and a `status` reports. The real asset
|
|
24
|
+
// URL is intentionally NOT hard-coded yet (see resolveEmbedPullUrl): the asset
|
|
25
|
+
// is unpublished, so `pull` fails cleanly asking for CODEINDEX_EMBED_URL rather
|
|
26
|
+
// than fetching a phantom.
|
|
27
|
+
export const DEFAULT_EMBED_DIRNAME = "models";
|
|
28
|
+
|
|
29
|
+
// A loaded static-embedding model. `vocab` maps a wordpiece token to its row
|
|
30
|
+
// index; `weights` is the flat row-major (vocabSize × dim) matrix as IEEE-754
|
|
31
|
+
// doubles (float rows widen to double exactly, so mean-pool stays deterministic).
|
|
32
|
+
export interface StaticEmbedModel {
|
|
33
|
+
modelId: string;
|
|
34
|
+
dim: number;
|
|
35
|
+
unk: string; // the OOV token (e.g. "[UNK]"); may be absent from vocab
|
|
36
|
+
unkId: number; // row index of `unk`, or -1 when the model has no UNK row
|
|
37
|
+
vocabSize: number;
|
|
38
|
+
vocab: Map<string, number>;
|
|
39
|
+
weights: Float64Array; // length === vocabSize * dim, row-major
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The on-disk model.json header shape. `weights` is an array of `vocabSize`
|
|
43
|
+
// rows, each of length `dim`. Kept human-authorable (plain JSON numbers) so a
|
|
44
|
+
// tiny fixture model is committable and diffable; a real `pull`ed model uses the
|
|
45
|
+
// identical shape at scale.
|
|
46
|
+
interface ModelFile {
|
|
47
|
+
modelId: string;
|
|
48
|
+
dim: number;
|
|
49
|
+
unk?: string;
|
|
50
|
+
vocab: string[];
|
|
51
|
+
weights: number[][];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Where the model asset lives. CODEINDEX_EMBED_DIR wins outright (explicit
|
|
55
|
+
// override, mirrors CODEINDEX_GRAMMAR_DIR); otherwise <repo>/.codeindex/models/
|
|
56
|
+
// then <cwd>/.codeindex/models/. Returns undefined when no model.json is found
|
|
57
|
+
// anywhere — the silent-degradation signal. When CODEINDEX_EMBED_DIR is set but
|
|
58
|
+
// empty of a model, that is still "no model" (undefined), never an error.
|
|
59
|
+
export function resolveEmbedModelDir(repo?: string): string | undefined {
|
|
60
|
+
const env = process.env.CODEINDEX_EMBED_DIR;
|
|
61
|
+
const candidates: string[] = [];
|
|
62
|
+
if (env) candidates.push(env);
|
|
63
|
+
if (repo) candidates.push(join(repo, ".codeindex", DEFAULT_EMBED_DIRNAME));
|
|
64
|
+
candidates.push(join(process.cwd(), ".codeindex", DEFAULT_EMBED_DIRNAME));
|
|
65
|
+
for (const c of candidates) {
|
|
66
|
+
if (existsSync(join(c, "model.json"))) return c;
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// True when an activatable model is present (used by `embed status` and the MCP
|
|
72
|
+
// gate before attempting a semantic search).
|
|
73
|
+
export function hasEmbedModel(repo?: string): boolean {
|
|
74
|
+
return resolveEmbedModelDir(repo) !== undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Load and validate a static model from a directory containing model.json.
|
|
78
|
+
// Throws on a malformed file (bad dim, ragged weights) so a corrupt asset fails
|
|
79
|
+
// loudly at load rather than silently misranking. Returns undefined only when
|
|
80
|
+
// the directory has no model.json (the not-present case).
|
|
81
|
+
export function loadEmbedModel(dir?: string): StaticEmbedModel | undefined {
|
|
82
|
+
if (!dir) return undefined;
|
|
83
|
+
const path = join(dir, "model.json");
|
|
84
|
+
if (!existsSync(path)) return undefined;
|
|
85
|
+
const raw = JSON.parse(readFileSync(path, "utf8")) as ModelFile;
|
|
86
|
+
const { modelId, dim, vocab, weights } = raw;
|
|
87
|
+
if (typeof modelId !== "string" || !modelId) throw new Error(`embed model: missing modelId in ${path}`);
|
|
88
|
+
if (!Number.isInteger(dim) || dim <= 0) throw new Error(`embed model: bad dim ${dim} in ${path}`);
|
|
89
|
+
if (!Array.isArray(vocab) || !Array.isArray(weights) || vocab.length !== weights.length) {
|
|
90
|
+
throw new Error(`embed model: vocab/weights length mismatch in ${path}`);
|
|
91
|
+
}
|
|
92
|
+
const vocabSize = vocab.length;
|
|
93
|
+
const flat = new Float64Array(vocabSize * dim);
|
|
94
|
+
const vmap = new Map<string, number>();
|
|
95
|
+
for (let i = 0; i < vocabSize; i++) {
|
|
96
|
+
const tok = vocab[i]!;
|
|
97
|
+
if (typeof tok !== "string") throw new Error(`embed model: non-string vocab entry at ${i}`);
|
|
98
|
+
if (!vmap.has(tok)) vmap.set(tok, i); // first occurrence wins (deterministic)
|
|
99
|
+
const row = weights[i]!;
|
|
100
|
+
if (!Array.isArray(row) || row.length !== dim) {
|
|
101
|
+
throw new Error(`embed model: row ${i} has length ${row?.length}, expected ${dim}`);
|
|
102
|
+
}
|
|
103
|
+
for (let d = 0; d < dim; d++) flat[i * dim + d] = Number(row[d]);
|
|
104
|
+
}
|
|
105
|
+
const unk = typeof raw.unk === "string" ? raw.unk : "[UNK]";
|
|
106
|
+
const unkId = vmap.has(unk) ? vmap.get(unk)! : -1;
|
|
107
|
+
return { modelId, dim, unk, unkId, vocabSize, vocab: vmap, weights: flat };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Resolve the URL `embed pull` fetches from. The official asset is not published
|
|
111
|
+
// yet, so there is no built-in default: the user must point CODEINDEX_EMBED_URL
|
|
112
|
+
// at a model.json (or a directory serving one). Returns undefined when unset —
|
|
113
|
+
// the caller turns that into a clean, actionable failure, never a crash.
|
|
114
|
+
export function resolveEmbedPullUrl(): string | undefined {
|
|
115
|
+
const url = process.env.CODEINDEX_EMBED_URL;
|
|
116
|
+
return url && url.trim() ? url.trim() : undefined;
|
|
117
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { RepoScan } from "../scan.js";
|
|
2
|
+
import { rrf } from "../util.js";
|
|
3
|
+
import { byStr } from "../sort.js";
|
|
4
|
+
import { searchIndex, type SearchOptions, type SearchResult } from "../bm25.js";
|
|
5
|
+
import { encode, intDot } from "./encode.js";
|
|
6
|
+
import type { EmbeddingIndex } from "./index.js";
|
|
7
|
+
import type { StaticEmbedModel } from "./model.js";
|
|
8
|
+
|
|
9
|
+
const DEFAULT_LIMIT = 20;
|
|
10
|
+
const RRF_K = 60; // reuse the engine-wide RRF damping constant
|
|
11
|
+
|
|
12
|
+
export interface SemanticSearchOptions extends SearchOptions {
|
|
13
|
+
// The loaded static model, needed to encode the QUERY into the same int8 space
|
|
14
|
+
// as the corpus. Absent → the search degrades to pure lexical (no throw).
|
|
15
|
+
model?: StaticEmbedModel;
|
|
16
|
+
// A pre-encoded int8 query vector — the endpoint tier's escape hatch: it has
|
|
17
|
+
// no local model but has already quantized the endpoint's float query vector
|
|
18
|
+
// through the SAME pipeline as the corpus. Wins over `model` when both are set.
|
|
19
|
+
queryVec?: Int8Array;
|
|
20
|
+
// RRF damping (default 60), exposed for parity with the shared rrf helper.
|
|
21
|
+
rrfK?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// A fused result. Extends the lexical SearchResult additively: `semanticSymbol`
|
|
25
|
+
// is the corpus symbol whose embedding was closest to the query for this file
|
|
26
|
+
// (absent when the file was contributed only by the lexical side, or when the
|
|
27
|
+
// search degraded to lexical).
|
|
28
|
+
export interface SemanticSearchResult extends SearchResult {
|
|
29
|
+
semanticSymbol?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// RRF-fused semantic + lexical search. The two rankings live on incomparable
|
|
33
|
+
// scales (BM25 score vs integer dot product), so we fuse by RANK via the shared
|
|
34
|
+
// `rrf` helper (k=60) rather than any linear score blend. Deterministic end to
|
|
35
|
+
// end: lexical is deterministic, the semantic ranking sorts by exact integer dot
|
|
36
|
+
// (ties broken by path), and the fused list sorts by RRF score (ties by path).
|
|
37
|
+
//
|
|
38
|
+
// DEGRADATION: with no model, no index, or an empty index, this returns the pure
|
|
39
|
+
// lexical ranking unchanged (as SemanticSearchResult[] with no `semanticSymbol`)
|
|
40
|
+
// — the caller stays on exit 0 and simply prints a note.
|
|
41
|
+
export function searchSemantic(
|
|
42
|
+
scan: RepoScan,
|
|
43
|
+
query: string,
|
|
44
|
+
index: EmbeddingIndex | undefined,
|
|
45
|
+
opts: SemanticSearchOptions = {},
|
|
46
|
+
): SemanticSearchResult[] {
|
|
47
|
+
const limit = opts.limit ?? DEFAULT_LIMIT;
|
|
48
|
+
// Pull a deeper lexical list than the final limit so RRF has enough overlap to
|
|
49
|
+
// fuse meaningfully, then trim after fusion.
|
|
50
|
+
const lexical = searchIndex(scan, query, { limit: Math.max(limit, 50), fuzzy: opts.fuzzy });
|
|
51
|
+
|
|
52
|
+
// Resolve the query vector: an already-encoded one (endpoint tier) wins;
|
|
53
|
+
// otherwise encode via the local model. No vector, no index, or an empty index
|
|
54
|
+
// → pure-lexical degradation.
|
|
55
|
+
const q = opts.queryVec ?? (opts.model ? encode(opts.model, query) : undefined);
|
|
56
|
+
if (!q || !index || index.records.length === 0) {
|
|
57
|
+
return lexical.slice(0, limit); // pure-lexical degradation
|
|
58
|
+
}
|
|
59
|
+
// Best (highest integer dot) record per file, remembering its symbol.
|
|
60
|
+
const bestByFile = new Map<string, { score: number; symbol?: string }>();
|
|
61
|
+
for (const r of index.records) {
|
|
62
|
+
const dot = intDot(q, r.vec);
|
|
63
|
+
const prev = bestByFile.get(r.file);
|
|
64
|
+
if (!prev || dot > prev.score) bestByFile.set(r.file, { score: dot, symbol: r.symbol });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Semantic ranked file list: positive similarity only, best-first, ties by path.
|
|
68
|
+
const semList = [...bestByFile.entries()]
|
|
69
|
+
.filter(([, v]) => v.score > 0)
|
|
70
|
+
.sort((a, b) => b[1].score - a[1].score || byStr(a[0], b[0]))
|
|
71
|
+
.map(([file]) => file);
|
|
72
|
+
const lexList = lexical.map((r) => r.file);
|
|
73
|
+
|
|
74
|
+
// Fuse the two ranked lists (identity keyOf — items are already file paths).
|
|
75
|
+
const fused = rrf<string>([lexList, semList], (f) => f, opts.rrfK ?? RRF_K);
|
|
76
|
+
|
|
77
|
+
const lexByFile = new Map(lexical.map((r) => [r.file, r] as const));
|
|
78
|
+
const results: SemanticSearchResult[] = [...fused.entries()]
|
|
79
|
+
.sort((a, b) => b[1] - a[1] || byStr(a[0], b[0]))
|
|
80
|
+
.map(([file, score]) => {
|
|
81
|
+
const lex = lexByFile.get(file);
|
|
82
|
+
const res: SemanticSearchResult = {
|
|
83
|
+
file,
|
|
84
|
+
score: Number(score.toFixed(4)),
|
|
85
|
+
matchedTerms: lex?.matchedTerms ?? [],
|
|
86
|
+
topSymbols: lex?.topSymbols ?? [],
|
|
87
|
+
};
|
|
88
|
+
const sem = bestByFile.get(file);
|
|
89
|
+
if (sem?.symbol) res.semanticSymbol = sem.symbol;
|
|
90
|
+
if (lex?.fuzzyTerms) res.fuzzyTerms = lex.fuzzyTerms;
|
|
91
|
+
return res;
|
|
92
|
+
});
|
|
93
|
+
return results.slice(0, limit);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export { DEFAULT_LIMIT as SEMANTIC_DEFAULT_LIMIT };
|