graph-indexer 1.2.0 → 2.1.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/CHANGELOG.md +854 -0
- package/README.md +258 -595
- package/SECURITY.md +85 -0
- package/TRADEMARK.md +31 -0
- package/cli-ui.mjs +78 -0
- package/colbert.mjs +168 -0
- package/config.mjs +408 -32
- package/daemon-ctl.mjs +175 -0
- package/daemon-lock.mjs +69 -0
- package/embedders/python/mlx_embed_server.py +61 -0
- package/embedders/python/requirements-mlx.txt +5 -0
- package/embedders/setup-mlx.mjs +110 -0
- package/embeddings.mjs +571 -0
- package/engine/binary.mjs +437 -0
- package/{core-engine.mjs → engine/memory.mjs} +321 -441
- package/{sqlite-store.mjs → engine/sqlite.mjs} +413 -45
- package/enrichment.mjs +206 -32
- package/git-signals.mjs +248 -0
- package/indexer.mjs +399 -45
- package/init.mjs +1626 -171
- package/layout.mjs +123 -0
- package/mcp/centrality.mjs +117 -0
- package/mcp/format.mjs +61 -0
- package/mcp/resolver.mjs +116 -0
- package/mcp/symbolgraph.mjs +137 -0
- package/mcp/taint.mjs +225 -0
- package/mcp/tools.mjs +1582 -0
- package/mcp/topology.mjs +676 -0
- package/mcp-server.mjs +80 -25
- package/package.json +66 -27
- package/parse/extractor.mjs +628 -0
- package/parse/imports.mjs +365 -0
- package/parse/interprocedural.mjs +134 -0
- package/parse/languages.mjs +253 -0
- package/parse/metadata.mjs +701 -0
- package/parse/routes.mjs +327 -0
- package/parse/scip.mjs +391 -0
- package/parse/taint-patterns.mjs +119 -0
- package/prompts/CORE.md +91 -0
- package/prompts/DOMAIN_TEMPLATE.md +46 -0
- package/prompts/INTEGRATION.md +123 -0
- package/prompts/frameworks/ANDROID.md +17 -0
- package/prompts/frameworks/ASPNET_CORE.md +19 -0
- package/prompts/frameworks/FASTAPI_DJANGO.md +20 -0
- package/prompts/frameworks/LARAVEL_SYMFONY.md +22 -0
- package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +19 -0
- package/prompts/frameworks/RAILS.md +20 -0
- package/prompts/frameworks/REACT.md +20 -0
- package/prompts/frameworks/SPRING_BOOT.md +20 -0
- package/prompts/languages/BASH.md +22 -0
- package/prompts/languages/C.md +21 -0
- package/prompts/languages/CSHARP.md +24 -0
- package/prompts/languages/CSS_SCSS.md +22 -0
- package/prompts/languages/GO.md +24 -0
- package/prompts/languages/JAVA.md +23 -0
- package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +24 -0
- package/prompts/languages/KOTLIN.md +24 -0
- package/prompts/languages/PHP.md +23 -0
- package/prompts/languages/PYTHON.md +23 -0
- package/prompts/languages/RUBY.md +23 -0
- package/prompts/languages/RUST.md +21 -0
- package/prompts/languages/SWIFT.md +21 -0
- package/seal.mjs +400 -0
- package/search-core.mjs +405 -44
- package/search-sparse.mjs +183 -0
- package/storage.mjs +33 -3
- package/watch-daemon.mjs +75 -24
- package/PROMPT.md +0 -182
- package/mcp-tools.mjs +0 -545
- package/parser-utils.mjs +0 -1170
package/SECURITY.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Reporting a vulnerability
|
|
4
|
+
|
|
5
|
+
We take the security and privacy of this project seriously. If you discover a
|
|
6
|
+
security vulnerability, please do **not** open a public issue. Instead, report it
|
|
7
|
+
privately by emailing the maintainer at **nicolopezdelerma@gmail.com**.
|
|
8
|
+
|
|
9
|
+
Please include:
|
|
10
|
+
|
|
11
|
+
- A description of the vulnerability and its impact.
|
|
12
|
+
- Steps to reproduce (a minimal proof of concept if possible).
|
|
13
|
+
- The affected version (`graph-indexer --version` or the `version` field in `package.json`).
|
|
14
|
+
|
|
15
|
+
You can expect an initial acknowledgement within **5 business days**. Coordinated
|
|
16
|
+
disclosure is appreciated — we will work with you on a fix and credit before any
|
|
17
|
+
public discussion.
|
|
18
|
+
|
|
19
|
+
## Supported versions
|
|
20
|
+
|
|
21
|
+
Security fixes are applied to the latest published `2.x` release. Older versions
|
|
22
|
+
are not maintained; please upgrade to the latest version.
|
|
23
|
+
|
|
24
|
+
## Security posture
|
|
25
|
+
|
|
26
|
+
graph-indexer is designed to run locally as a developer tool. Its threat model is
|
|
27
|
+
shaped by a few deliberate choices:
|
|
28
|
+
|
|
29
|
+
- **Air-gapped by default.** In normal operation the only outbound network call is to
|
|
30
|
+
a *local* Ollama endpoint (`OLLAMA_HOST`, default `http://localhost:11434`) for
|
|
31
|
+
embeddings. With `INDEXER_EMBEDDINGS=off`, even that call is skipped and the tool is
|
|
32
|
+
fully offline. There are no analytics, telemetry, crash reporters, or third-party API
|
|
33
|
+
calls. **One exception:** the optional in-process embedding provider
|
|
34
|
+
(`embedProvider: "local"`, or `"auto"` when no Ollama is found) downloads its model
|
|
35
|
+
weights from the Hugging Face CDN **once** on first index, then runs fully offline
|
|
36
|
+
from the local cache. Your source code is never sent anywhere — only model weights
|
|
37
|
+
are fetched. For strictly air-gapped installs, set `embedProvider` to `"ollama"` or
|
|
38
|
+
`"off"`, or pre-populate the model cache on a connected machine.
|
|
39
|
+
- **Sealed mode (opt-in, fail-closed enforcement).** For regulated or strictly
|
|
40
|
+
air-gapped installs, `--sealed strict` (`INDEXER_SEALED`) enforces **zero** network
|
|
41
|
+
egress (lexical-only), and `--sealed local` permits **loopback only** (a local
|
|
42
|
+
Ollama/MLX on this box is allowed, nothing leaves it). It is *fail-closed*: the build
|
|
43
|
+
refuses to start if any enabled feature (embeddings/enrichment/rerank/HyDE) would
|
|
44
|
+
egress beyond the tier, and it installs a **deny-by-default runtime egress guard** on
|
|
45
|
+
the in-process network paths (`net`/`tls` sockets, `http(s)`, `fetch`) so even an
|
|
46
|
+
accidental call is blocked — turning "we don't call out" into "we *can't*."
|
|
47
|
+
`idx-index --attest` prints a deterministic attestation manifest (optionally
|
|
48
|
+
Ed25519/RSA-**signed** and CI-verifiable) of the egress posture. See `seal.mjs`.
|
|
49
|
+
- **No code execution.** Source files are parsed into ASTs with Tree-sitter. The
|
|
50
|
+
indexer never imports, evaluates, or runs the code it indexes. The one subprocess
|
|
51
|
+
it spawns is a local, read-only `git log` (see "git signals" below) — never the
|
|
52
|
+
indexed code.
|
|
53
|
+
- **Local git signals.** At index time the indexer optionally reads the repository's
|
|
54
|
+
*local* commit log (`git log` on `MCP_PROJECT_ROOT`) to derive churn / recency /
|
|
55
|
+
co-change ranking hints. No remote is contacted and no source content leaves the
|
|
56
|
+
machine — only commit metadata (paths, timestamps) is read locally. Disable it
|
|
57
|
+
entirely with `INDEXER_GIT_SIGNALS=off`, `--no-git-signals`, or
|
|
58
|
+
`"gitSignals": false`.
|
|
59
|
+
- **Path-traversal guard.** The `get_file_skeleton` MCP tool resolves and normalizes
|
|
60
|
+
the requested path and rejects anything outside `MCP_PROJECT_ROOT`. Beyond the
|
|
61
|
+
textual check it also resolves symlinks with `realpath` on both the root and the
|
|
62
|
+
target and re-verifies containment, so a symlink *inside* the project that points
|
|
63
|
+
outside it cannot be used to read arbitrary files on the host.
|
|
64
|
+
- **Local artifacts only.** The index (`code-index.json`) and embeddings
|
|
65
|
+
(`code-index.embeddings.bin`) are written inside the project and are git-ignored by
|
|
66
|
+
`init`. They contain snippets of your source code — treat them with the same
|
|
67
|
+
sensitivity as the repository itself and do not commit or share them.
|
|
68
|
+
- **MCP transport.** The server communicates over stdio with the local MCP client
|
|
69
|
+
(your IDE/agent). It does not open a network socket.
|
|
70
|
+
- **Taint analysis is advisory, not a guarantee.** The opt-in `trace_taint` /
|
|
71
|
+
`find_tainted_sinks` MCP tools statically trace untrusted sources to dangerous sinks
|
|
72
|
+
over the call graph using pure regex catalogs + graph traversal (no model, no network,
|
|
73
|
+
read-only). They are a **finder, not a verifier**: they favour precision and miss flows
|
|
74
|
+
through dynamic dispatch, reflection, ORM/query-builder indirection, and untyped
|
|
75
|
+
collections — **"0 findings" is not proof of safety.** Use them for orientation, not as
|
|
76
|
+
a security gate.
|
|
77
|
+
|
|
78
|
+
## Operational guidance
|
|
79
|
+
|
|
80
|
+
- Point `MCP_PROJECT_ROOT` only at repositories you trust and intend to index.
|
|
81
|
+
- If you set `OLLAMA_HOST` to a remote host, that host will receive code snippets as
|
|
82
|
+
embedding input — only do so on a network and host you control.
|
|
83
|
+
- The background watch daemon respects `.gitignore` and skips `node_modules`, build
|
|
84
|
+
output, and dot-directories, so secrets in ignored paths are not indexed. Keep
|
|
85
|
+
sensitive files git-ignored.
|
package/TRADEMARK.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Trademark Policy
|
|
2
|
+
|
|
3
|
+
This document outlines the trademark policy for the **graph-indexer** project and the **MaquinaTech** brand.
|
|
4
|
+
|
|
5
|
+
While the software source code is freely available under the [MIT License](LICENSE), the trademarks, service marks, and logos associated with the project are the intellectual property of MaquinaTech. The open-source license does not grant you the right to use these trademarks in a way that suggests endorsement, affiliation, or origin.
|
|
6
|
+
|
|
7
|
+
## Our Trademarks
|
|
8
|
+
|
|
9
|
+
The following are trademarks of MaquinaTech:
|
|
10
|
+
* **graph-indexer™**
|
|
11
|
+
* **Graph Indexer™**
|
|
12
|
+
* **MaquinaTech™**
|
|
13
|
+
|
|
14
|
+
## Permitted Uses
|
|
15
|
+
|
|
16
|
+
You may use our trademarks without prior written permission in the following cases:
|
|
17
|
+
* **Descriptive Use:** To truthfully describe the origin of the software. For example, stating "This product uses graph-indexer."
|
|
18
|
+
* **Compatibility:** To indicate compatibility with our project. For example, "Custom tool for graph-indexer," provided it does not confuse users into thinking it is an official tool created by us.
|
|
19
|
+
* **Community Discussion:** In blog posts, tutorials, videos, and discussions about the project, provided the context is accurate and not misleading.
|
|
20
|
+
|
|
21
|
+
## Restricted Uses
|
|
22
|
+
|
|
23
|
+
You **may not** use our trademarks without explicit, prior written permission in the following ways:
|
|
24
|
+
* **Product Naming:** You cannot name your product, commercial service, or company "graph-indexer", "Graph Indexer", or anything confusingly similar (e.g., "Graph Indexer Enterprise", "Graph-Indexer Pro", or "graph-indexer-cloud").
|
|
25
|
+
* **Domain Names:** You cannot use our trademarks in a domain name (e.g., `graph-indexer-hosting.com` or `maquinatech-solutions.net`).
|
|
26
|
+
* **False Endorsement:** You cannot use our trademarks in any way that implies an official affiliation, partnership, sponsorship, or endorsement by MaquinaTech.
|
|
27
|
+
* **Modified Versions (Forks):** If you fork the repository and make modifications to the source code to distribute it as your own product, you must rename the project. You cannot distribute your modified version under the name "graph-indexer".
|
|
28
|
+
|
|
29
|
+
## Contact and Permissions
|
|
30
|
+
|
|
31
|
+
If you wish to use our trademarks in a way that is not explicitly permitted by this policy, or if you are unsure whether your intended commercial use is permitted, please contact us for written authorization.
|
package/cli-ui.mjs
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file cli-ui.mjs
|
|
3
|
+
* @description Tiny, dependency-free console styling shared by the `graph-indexer`
|
|
4
|
+
* CLIs (init + daemon control). ANSI colour with graceful degradation
|
|
5
|
+
* when stdout is not a TTY or NO_COLOR is set, plus a few drawing
|
|
6
|
+
* helpers (boxed banner, rules, status glyphs) for a consistent look.
|
|
7
|
+
* @author MaquinaTech <https://github.com/MaquinaTech>
|
|
8
|
+
* @copyright (c) 2026 MaquinaTech. All rights reserved.
|
|
9
|
+
* @license MIT
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
13
|
+
|
|
14
|
+
const paint = (code) => (s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : `${s}`);
|
|
15
|
+
|
|
16
|
+
export const c = {
|
|
17
|
+
bold: paint('1'),
|
|
18
|
+
dim: paint('2'),
|
|
19
|
+
red: paint('31'),
|
|
20
|
+
green: paint('32'),
|
|
21
|
+
yellow: paint('33'),
|
|
22
|
+
blue: paint('34'),
|
|
23
|
+
magenta: paint('35'),
|
|
24
|
+
cyan: paint('36'),
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const glyph = {
|
|
28
|
+
ok: c.green('✓'),
|
|
29
|
+
upd: c.cyan('↻'),
|
|
30
|
+
keep: c.dim('•'),
|
|
31
|
+
move: c.yellow('↪'),
|
|
32
|
+
skip: c.dim('–'),
|
|
33
|
+
warn: c.yellow('⚠'),
|
|
34
|
+
err: c.red('✗'),
|
|
35
|
+
run: c.green('●'),
|
|
36
|
+
stop: c.dim('○'),
|
|
37
|
+
arrow: c.cyan('→'),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const log = (msg = '') => process.stdout.write(msg + '\n');
|
|
41
|
+
|
|
42
|
+
/** A horizontal rule, capped to the terminal width so it never wraps. */
|
|
43
|
+
export const rule = (width = Math.min(60, (process.stdout.columns || 80))) => c.dim('─'.repeat(Math.max(1, width)));
|
|
44
|
+
|
|
45
|
+
const visibleLen = (s) => s.replace(/\x1b\[[0-9;]*m/g, '').length;
|
|
46
|
+
|
|
47
|
+
/** ANSI-aware truncate to `limit` visible columns; closes any open colour + adds an ellipsis. */
|
|
48
|
+
function clipVisible(s, limit) {
|
|
49
|
+
if (limit < 1) return '';
|
|
50
|
+
if (visibleLen(s) <= limit) return s;
|
|
51
|
+
let out = '', w = 0, i = 0, ansi = false;
|
|
52
|
+
while (i < s.length && w < limit - 1) {
|
|
53
|
+
if (s[i] === '\x1b') {
|
|
54
|
+
const m = s.slice(i).match(/^\x1b\[[0-9;]*m/);
|
|
55
|
+
if (m) { out += m[0]; i += m[0].length; ansi = true; continue; }
|
|
56
|
+
}
|
|
57
|
+
out += s[i++]; w++;
|
|
58
|
+
}
|
|
59
|
+
return out + (ansi ? '\x1b[0m' : '') + '…';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Draws a bordered box. ANSI escapes are stripped before measuring width so colour
|
|
64
|
+
* never breaks alignment, and the box (plus each line) is capped to the terminal
|
|
65
|
+
* width so the border stays intact on narrow terminals instead of wrapping.
|
|
66
|
+
*/
|
|
67
|
+
export function box(lines, { pad = 1, color = c.cyan } = {}) {
|
|
68
|
+
const maxInner = Math.max(1, (process.stdout.columns || 80) - 2); // 2 border columns
|
|
69
|
+
const shown = lines.map((line) => clipVisible(line, maxInner - pad * 2));
|
|
70
|
+
const inner = Math.min(maxInner, Math.max(...shown.map(visibleLen)) + pad * 2);
|
|
71
|
+
const top = color('╭' + '─'.repeat(inner) + '╮');
|
|
72
|
+
const bot = color('╰' + '─'.repeat(inner) + '╯');
|
|
73
|
+
const body = shown.map((line) => {
|
|
74
|
+
const gap = ' '.repeat(Math.max(0, inner - visibleLen(line) - pad));
|
|
75
|
+
return color('│') + ' '.repeat(pad) + line + gap + color('│');
|
|
76
|
+
});
|
|
77
|
+
return [top, ...body, bot].join('\n');
|
|
78
|
+
}
|
package/colbert.mjs
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file colbert.mjs
|
|
3
|
+
* @description B4 — ColBERT-style LATE-INTERACTION reranker (multi-vector MaxSim). A third,
|
|
4
|
+
* opt-in reranker provider (rerank.provider === 'late-interaction'), sibling to the
|
|
5
|
+
* generative judge and the cross-encoder (B2). Where a single-vector dense channel
|
|
6
|
+
* pools the query and the document each into ONE vector (losing which part matched
|
|
7
|
+
* where), late interaction keeps MULTIPLE vectors per side and scores them with MaxSim:
|
|
8
|
+
*
|
|
9
|
+
* score(q, d) = Σ_{qi} max_{dj} cos(qi, dj)
|
|
10
|
+
*
|
|
11
|
+
* i.e. every query sub-vector finds its best-matching document sub-vector and the hits
|
|
12
|
+
* sum — the compositional-query win ColBERT is known for. It reuses the document
|
|
13
|
+
* sub-vectors the engine ALREADY stores (the base + `|s` summary + `|wN` window vectors
|
|
14
|
+
* of each chunk), so there is **no storage blow-up** — the canonical ColBERT cost. It is
|
|
15
|
+
* deployed as a RERANKER over the over-fetched candidate pool (the standard
|
|
16
|
+
* retrieve-then-late-interact pattern), so it is a post-retrieval re-order — the default
|
|
17
|
+
* ranking is untouched and parity is preserved (both backends read the SAME bin → the
|
|
18
|
+
* SAME doc vectors → the SAME MaxSim → the SAME order).
|
|
19
|
+
*
|
|
20
|
+
* AIR-GAPPED / HONEST SCOPE: the query multi-vectors are produced by the SAME local
|
|
21
|
+
* embedder the dense channel already uses (no new model, no network). A true ColBERT
|
|
22
|
+
* uses a token-level CONTEXTUAL encoder; a general sentence embedder over query
|
|
23
|
+
* sub-units only APPROXIMATES that — so this is "late interaction over the existing
|
|
24
|
+
* encoder," with a real token-level ColBERT model a clean future drop-in behind
|
|
25
|
+
* `encodeMultiVector`. Requires --embeddings (the doc vectors + the embedder); a no-op
|
|
26
|
+
* (returns the original order) when either is absent.
|
|
27
|
+
* @author MaquinaTech <https://github.com/MaquinaTech>
|
|
28
|
+
* @copyright (c) 2026 MaquinaTech. All rights reserved.
|
|
29
|
+
* @license MIT
|
|
30
|
+
*/
|
|
31
|
+
import { collectVectorsByKey } from './engine/binary.mjs';
|
|
32
|
+
import { embeddingKeyFor, SUMMARY_VEC_SUFFIX, WINDOW_VEC_SUFFIX, EMBEDDING_MAX_WINDOWS } from './search-core.mjs';
|
|
33
|
+
|
|
34
|
+
// Query sub-unit extraction is intentionally light (no stemming — surface forms, like the dense
|
|
35
|
+
// channel). A tiny stopword guard keeps function words from spending a query vector slot.
|
|
36
|
+
const STOP = new Set([
|
|
37
|
+
'the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'for', 'on', 'with', 'at', 'by', 'from',
|
|
38
|
+
'that', 'this', 'is', 'are', 'was', 'were', 'be', 'how', 'what', 'when', 'where', 'which',
|
|
39
|
+
'who', 'why', 'does', 'do', 'it', 'its', 'into', 'if', 'then', 'there', 'all', 'any',
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/** Cosine similarity of two equal-length vectors (0 when either is degenerate). */
|
|
43
|
+
export function cosine(a, b) {
|
|
44
|
+
if (!a || !b || a.length !== b.length) return 0;
|
|
45
|
+
let dot = 0, na = 0, nb = 0;
|
|
46
|
+
for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
|
|
47
|
+
if (na === 0 || nb === 0) return 0;
|
|
48
|
+
return dot / (Math.sqrt(na) * Math.sqrt(nb));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* MaxSim late-interaction score: Σ over query vectors of the max cosine to any document vector.
|
|
53
|
+
* Order-independent (a sum of maxes) → no parity dependence on vector ordering.
|
|
54
|
+
* @param {Float32Array[]} qVecs query sub-vectors
|
|
55
|
+
* @param {Float32Array[]} dVecs document sub-vectors
|
|
56
|
+
* @returns {number} 0 when either side is empty
|
|
57
|
+
*/
|
|
58
|
+
export function maxSimScore(qVecs, dVecs) {
|
|
59
|
+
if (!qVecs?.length || !dVecs?.length) return 0;
|
|
60
|
+
let total = 0;
|
|
61
|
+
for (const q of qVecs) {
|
|
62
|
+
let best = -Infinity;
|
|
63
|
+
for (const d of dVecs) { const s = cosine(q, d); if (s > best) best = s; }
|
|
64
|
+
if (best > -Infinity) total += best;
|
|
65
|
+
}
|
|
66
|
+
return total;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The embedding keys that hold a chunk's stored sub-vectors (base + summary + windows). */
|
|
70
|
+
function chunkVectorKeys(chunk) {
|
|
71
|
+
const vecKey = embeddingKeyFor(chunk);
|
|
72
|
+
const keys = [vecKey, chunk.content_hash, vecKey + SUMMARY_VEC_SUFFIX];
|
|
73
|
+
for (let i = 1; i < EMBEDDING_MAX_WINDOWS; i++) keys.push(vecKey + WINDOW_VEC_SUFFIX + i);
|
|
74
|
+
return { vecKey, keys };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Load each chunk's stored sub-vectors from the shared `.embeddings.bin` in a SINGLE pass (one bin
|
|
79
|
+
* read per rerank, not per candidate). Identical for both backends (same file, same keys) →
|
|
80
|
+
* parity-by-construction. @returns {Map<chunkId, Float32Array[]>}
|
|
81
|
+
*/
|
|
82
|
+
export function loadChunkVectors(binPath, chunks) {
|
|
83
|
+
const meta = new Map(); // chunkId → { vecKey, hash }
|
|
84
|
+
const allKeys = new Set();
|
|
85
|
+
for (const c of chunks) {
|
|
86
|
+
if (!c || !c.content_hash) { meta.set(c?.id, null); continue; }
|
|
87
|
+
const { vecKey, keys } = chunkVectorKeys(c);
|
|
88
|
+
meta.set(c.id, { vecKey, hash: c.content_hash });
|
|
89
|
+
for (const k of keys) allKeys.add(k);
|
|
90
|
+
}
|
|
91
|
+
const found = collectVectorsByKey(binPath, allKeys);
|
|
92
|
+
const out = new Map();
|
|
93
|
+
for (const [id, m] of meta) {
|
|
94
|
+
if (!m) { out.set(id, []); continue; }
|
|
95
|
+
const vecs = [];
|
|
96
|
+
const base = found.get(m.vecKey) || found.get(m.hash); // deterministic order: base, summary, windows
|
|
97
|
+
if (base) vecs.push(base);
|
|
98
|
+
const s = found.get(m.vecKey + SUMMARY_VEC_SUFFIX); if (s) vecs.push(s);
|
|
99
|
+
for (let i = 1; i < EMBEDDING_MAX_WINDOWS; i++) { const w = found.get(m.vecKey + WINDOW_VEC_SUFFIX + i); if (w) vecs.push(w); }
|
|
100
|
+
out.set(id, vecs);
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Encode a query into MULTIPLE vectors (the whole query plus its content sub-units) with the SAME
|
|
107
|
+
* local embedder the dense channel uses. Best-effort: returns [] on any failure / no embedder.
|
|
108
|
+
* @param {{embedDocuments?:Function, embedQuery?:Function}} embedder
|
|
109
|
+
* @param {string} text
|
|
110
|
+
* @param {object} [opts]
|
|
111
|
+
* @returns {Promise<Float32Array[]>}
|
|
112
|
+
*/
|
|
113
|
+
export async function encodeMultiVector(embedder, text, { maxVecs = 8 } = {}) {
|
|
114
|
+
if (!embedder || typeof embedder.embedDocuments !== 'function') return [];
|
|
115
|
+
const words = String(text).toLowerCase().split(/[\s\W_]+/).filter(Boolean);
|
|
116
|
+
const units = [];
|
|
117
|
+
const seen = new Set();
|
|
118
|
+
for (const w of words) {
|
|
119
|
+
if (w.length < 3 || STOP.has(w) || seen.has(w)) continue;
|
|
120
|
+
seen.add(w); units.push(w);
|
|
121
|
+
if (units.length >= maxVecs - 1) break;
|
|
122
|
+
}
|
|
123
|
+
units.unshift(String(text)); // the holistic query vector always leads
|
|
124
|
+
let vecs;
|
|
125
|
+
try { vecs = await embedder.embedDocuments(units); }
|
|
126
|
+
catch { return []; }
|
|
127
|
+
return (vecs || []).filter(v => v && v.length).map(v => v instanceof Float32Array ? v : new Float32Array(v));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Late-interaction RERANK of the over-fetched pool. Re-orders the top-`topM` by MaxSim of the query
|
|
132
|
+
* multi-vectors against each candidate's stored doc sub-vectors; the tail is preserved. Best-effort
|
|
133
|
+
* and never throws: any shortfall (no embedder, <2 candidates with vectors, empty query vectors)
|
|
134
|
+
* returns the ORIGINAL order, so the channel can only help, never corrupt. Never mutates `r.score`
|
|
135
|
+
* (protects the downstream git-boost / formatting), exactly like rerankCrossEncoder.
|
|
136
|
+
*
|
|
137
|
+
* @param {Array<{chunk:object, score:number}>} results
|
|
138
|
+
* @param {object} opts
|
|
139
|
+
* @param {Float32Array[]} opts.qVecs query multi-vectors (from encodeMultiVector)
|
|
140
|
+
* @param {string} opts.binPath the `.embeddings.bin` path (db.embeddingBinPath())
|
|
141
|
+
* @param {number} [opts.topM]
|
|
142
|
+
* @returns {Array}
|
|
143
|
+
*/
|
|
144
|
+
export function rerankLateInteraction(results, { qVecs, binPath, topM = 12 } = {}) {
|
|
145
|
+
const head = results.slice(0, topM);
|
|
146
|
+
if (head.length < 2 || !qVecs?.length || !binPath) return results;
|
|
147
|
+
const vecsById = loadChunkVectors(binPath, head.map(r => r.chunk));
|
|
148
|
+
const scored = head.map((r, i) => {
|
|
149
|
+
const dVecs = vecsById.get(r.chunk.id) || [];
|
|
150
|
+
return { i, id: r.chunk.id, s: dVecs.length ? maxSimScore(qVecs, dVecs) : NaN };
|
|
151
|
+
});
|
|
152
|
+
// "Can only help, never corrupt": treat no-signal as a shortfall. Re-order only when at least two
|
|
153
|
+
// candidates carry a FINITE and DISTINCT MaxSim — so all-zero scores (e.g. a dim mismatch making
|
|
154
|
+
// every cosine 0) or undifferentiated ties fall through to the original order instead of being
|
|
155
|
+
// scrambled into id-lexicographic order by the tie-break.
|
|
156
|
+
const finite = scored.filter(x => Number.isFinite(x.s));
|
|
157
|
+
if (finite.length < 2 || new Set(finite.map(x => x.s)).size < 2) return results;
|
|
158
|
+
const order = scored
|
|
159
|
+
// finite MaxSim first (desc); candidates without vectors sink but keep a stable id order
|
|
160
|
+
.sort((a, b) => {
|
|
161
|
+
const af = Number.isFinite(a.s), bf = Number.isFinite(b.s);
|
|
162
|
+
if (af && bf) return (b.s - a.s) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
163
|
+
if (af) return -1; if (bf) return 1;
|
|
164
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
165
|
+
})
|
|
166
|
+
.map(x => x.i);
|
|
167
|
+
return [...order.map(i => head[i]), ...results.slice(topM)];
|
|
168
|
+
}
|