@mrplex/embedder 0.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/README.md +98 -0
- package/embedder.mjs +190 -0
- package/package.json +45 -0
package/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# @mrplex/embedder
|
|
2
|
+
|
|
3
|
+
A ready-to-use local embedding provider for [mrplex](https://github.com/usergenic/mrplex)'s
|
|
4
|
+
`--embed-cmd` hook. It runs `bge-small-en-v1.5` (384-dim) on CPU via ONNX — no GPU, no
|
|
5
|
+
separate service. mrplex spawns it once and keeps it resident, so the model loads a single
|
|
6
|
+
time and each batch is one JSON line in / one JSON line out.
|
|
7
|
+
|
|
8
|
+
## Why a separate package
|
|
9
|
+
|
|
10
|
+
This lives in its own package with its own `package.json` and lockfile so that
|
|
11
|
+
its dependency — `fastembed`, and `fastembed`'s transitive `tar` — never enters
|
|
12
|
+
mrplex's core dependency graph. A plain `npm install` (or `npm ci`) at the
|
|
13
|
+
mrplex root stays clean; you only pull these deps if you opt in to local
|
|
14
|
+
embeddings.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install -g @mrplex/embedder
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The model (~130 MB) auto-downloads and caches on first run (typically under
|
|
23
|
+
`~/.cache/huggingface` or fastembed's cache directory).
|
|
24
|
+
|
|
25
|
+
**From the mrplex monorepo** (development):
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
cd packages/embedder && npm install
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Use with mrplex
|
|
32
|
+
|
|
33
|
+
After a global install, point mrplex at the `mrplex-embedder` binary:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# Serve with the hook — every write auto-embeds.
|
|
37
|
+
mrplex serve --unsafe --embed-cmd "mrplex-embedder --stdio"
|
|
38
|
+
|
|
39
|
+
# One-off backfill of an existing repo.
|
|
40
|
+
mrplex embed backfill -r notes --embed-cmd "mrplex-embedder --stdio"
|
|
41
|
+
|
|
42
|
+
# Or make it ambient.
|
|
43
|
+
export MRPLEX_EMBED_CMD="mrplex-embedder --stdio"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**Monorepo / local checkout** (no global install):
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
mrplex serve --unsafe \
|
|
50
|
+
--embed-cmd "node packages/embedder/embedder.mjs --stdio"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Different models
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
# Stronger, slower (768-dim).
|
|
57
|
+
export MRPLEX_EMBED_CMD="mrplex-embedder --stdio --model fast-bge-base-en-v1.5"
|
|
58
|
+
|
|
59
|
+
# Matryoshka truncation (re-embed after changing --dim).
|
|
60
|
+
export MRPLEX_EMBED_CMD="mrplex-embedder --stdio --dim 256"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
List supported model keys:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
mrplex-embedder --list-models
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Flags
|
|
70
|
+
|
|
71
|
+
- `--stdio` — required transport (one JSON line per batch over stdin/stdout).
|
|
72
|
+
- `--model KEY` — any `fastembed` model key. Default `fast-bge-small-en-v1.5`.
|
|
73
|
+
Others include `fast-all-MiniLM-L6-v2`, `fast-bge-base-en-v1.5`,
|
|
74
|
+
`fast-multilingual-e5-large`. Run `--list-models` for the full set.
|
|
75
|
+
- `--dim N` — truncate + re-normalize each vector to `N` dims (for Matryoshka
|
|
76
|
+
models). The truncation is encoded into the reported model string
|
|
77
|
+
(`<key>@<N>`) so mrplex treats a dim change as a model change and re-embeds.
|
|
78
|
+
- `--list-models` — print supported `--model` keys and exit.
|
|
79
|
+
- `--help`, `--version` — usage and version.
|
|
80
|
+
|
|
81
|
+
## Protocol
|
|
82
|
+
|
|
83
|
+
Reads `{ "chunks": ["…", …] }` lines, writes
|
|
84
|
+
`{ "vectors": [[…], …], "model": "…", "dim": N }` lines. Vectors are
|
|
85
|
+
mean-pooled and L2-normalized (unit length) so mrplex's brute-force cosine
|
|
86
|
+
ranking is stable. A malformed input line still gets one `{ "error": "…" }`
|
|
87
|
+
line back so the caller never hangs.
|
|
88
|
+
|
|
89
|
+
## Security note
|
|
90
|
+
|
|
91
|
+
`fastembed` depends on `tar` (used to extract the model archive it downloads
|
|
92
|
+
from HuggingFace on first run). The pinned `tar` carries published advisories
|
|
93
|
+
(`GHSA-*`, path-traversal / DoS classes). The exposure here is low: this is
|
|
94
|
+
opt-in developer tooling — not part of mrplex's shipped runtime — and `tar`
|
|
95
|
+
only ever processes archives fetched from HuggingFace, not attacker-supplied
|
|
96
|
+
input. `npm audit` will flag this; `npm audit fix --force` would downgrade
|
|
97
|
+
`fastembed` (breaking). Revisit if a `fastembed` release adopts a patched
|
|
98
|
+
`tar`.
|
package/embedder.mjs
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Local embedding provider for mrplex's `--embed-cmd` hook.
|
|
4
|
+
*
|
|
5
|
+
* Speaks the subprocess contract (design §5.3): one JSON line in on stdin
|
|
6
|
+
* `{ chunks: string[] }`, one JSON line out on stdout
|
|
7
|
+
* `{ vectors: number[][], model: string, dim: number }`. mrplex spawns this
|
|
8
|
+
* ONCE and reuses it for every batch (src/embed/cmd-hook.ts), so the model
|
|
9
|
+
* loads a single time and stays resident — no per-call cold start.
|
|
10
|
+
*
|
|
11
|
+
* mrplex serve --unsafe --embed-cmd "mrplex-embedder --stdio"
|
|
12
|
+
* mrplex embed backfill -r notes --embed-cmd "mrplex-embedder --stdio"
|
|
13
|
+
*
|
|
14
|
+
* Model: bge-small-en-v1.5 (384-dim) by default — strong retrieval quality for
|
|
15
|
+
* its size, and small vectors keep mrplex's brute-force cosine scan fast. Runs
|
|
16
|
+
* on CPU via ONNX; no GPU or separate service required.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
|
+
import { createInterface } from "node:readline";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
|
|
24
|
+
const pkg = JSON.parse(
|
|
25
|
+
readFileSync(join(dirname(fileURLToPath(import.meta.url)), "package.json"), "utf8"),
|
|
26
|
+
);
|
|
27
|
+
const VERSION = pkg.version;
|
|
28
|
+
|
|
29
|
+
const argv = process.argv.slice(2);
|
|
30
|
+
const flag = (name, def) => {
|
|
31
|
+
const i = argv.indexOf(name);
|
|
32
|
+
return i >= 0 ? argv[i + 1] : def;
|
|
33
|
+
};
|
|
34
|
+
const has = (name) => argv.includes(name);
|
|
35
|
+
|
|
36
|
+
function printHelp() {
|
|
37
|
+
console.error(`mrplex-embedder ${VERSION}
|
|
38
|
+
Local CPU embedding provider for mrplex's --embed-cmd hook.
|
|
39
|
+
|
|
40
|
+
Usage:
|
|
41
|
+
mrplex-embedder --stdio [--model KEY] [--dim N]
|
|
42
|
+
|
|
43
|
+
Options:
|
|
44
|
+
--stdio required transport (one JSON line per batch over stdin/stdout)
|
|
45
|
+
--model KEY fastembed model key (default: fast-bge-small-en-v1.5)
|
|
46
|
+
--dim N truncate + re-normalize to N dims (Matryoshka models only)
|
|
47
|
+
--list-models print supported --model keys and exit
|
|
48
|
+
--help, -h show this help
|
|
49
|
+
--version, -V print version
|
|
50
|
+
|
|
51
|
+
Install: npm install -g @mrplex/embedder
|
|
52
|
+
Docs: https://github.com/usergenic/mrplex/tree/main/packages/embedder`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (has("--help") || has("-h")) {
|
|
56
|
+
printHelp();
|
|
57
|
+
process.exit(0);
|
|
58
|
+
}
|
|
59
|
+
if (has("--version") || has("-V")) {
|
|
60
|
+
console.log(VERSION);
|
|
61
|
+
process.exit(0);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const modelKey = flag("--model", "fast-bge-small-en-v1.5");
|
|
65
|
+
const truncateDim = argv.includes("--dim") ? Number.parseInt(flag("--dim", ""), 10) : null;
|
|
66
|
+
|
|
67
|
+
if (truncateDim !== null && (!Number.isInteger(truncateDim) || truncateDim <= 0)) {
|
|
68
|
+
console.error(`embedder: --dim must be a positive integer (got ${flag("--dim", "")})`);
|
|
69
|
+
process.exit(2);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Import lazily so a missing dependency yields a clear message, not a stack
|
|
73
|
+
// trace before the usage check above has run.
|
|
74
|
+
let FlagEmbedding;
|
|
75
|
+
let EmbeddingModel;
|
|
76
|
+
try {
|
|
77
|
+
({ FlagEmbedding, EmbeddingModel } = await import("fastembed"));
|
|
78
|
+
} catch {
|
|
79
|
+
console.error(
|
|
80
|
+
"embedder: missing dependency 'fastembed'.\n install it with: npm install fastembed",
|
|
81
|
+
);
|
|
82
|
+
process.exit(3);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (has("--list-models")) {
|
|
86
|
+
for (const key of Object.values(EmbeddingModel)) {
|
|
87
|
+
console.log(key);
|
|
88
|
+
}
|
|
89
|
+
process.exit(0);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!has("--stdio")) {
|
|
93
|
+
printHelp();
|
|
94
|
+
process.exit(2);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// fastembed keys its models by an enum whose values are the string keys above.
|
|
98
|
+
const model = Object.values(EmbeddingModel).find((v) => v === modelKey);
|
|
99
|
+
if (!model) {
|
|
100
|
+
console.error(
|
|
101
|
+
`embedder: unknown --model '${modelKey}'. known: ${Object.values(EmbeddingModel).join(", ")}`,
|
|
102
|
+
);
|
|
103
|
+
process.exit(2);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// maxLength covers the chunker's 2000-char cap (~512 tokens); bge tops out at 512.
|
|
107
|
+
const embedder = await FlagEmbedding.init({ model, maxLength: 512 });
|
|
108
|
+
|
|
109
|
+
/** Truncate a unit vector to `n` dims and re-normalize (Matryoshka). */
|
|
110
|
+
function truncate(vec, n) {
|
|
111
|
+
const head = vec.slice(0, n);
|
|
112
|
+
let sum = 0;
|
|
113
|
+
for (const x of head) sum += x * x;
|
|
114
|
+
const norm = Math.sqrt(sum) || 1;
|
|
115
|
+
return head.map((x) => x / norm);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function embedAll(chunks) {
|
|
119
|
+
// `embed` yields batches; collect them back into one aligned array.
|
|
120
|
+
const vectors = [];
|
|
121
|
+
for await (const batch of embedder.embed(chunks, chunks.length)) {
|
|
122
|
+
for (const v of batch) vectors.push(Array.from(v));
|
|
123
|
+
}
|
|
124
|
+
return vectors;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function reportedModelName() {
|
|
128
|
+
return truncateDim !== null ? `${modelKey}@${truncateDim}` : modelKey;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function handleBatch(chunks) {
|
|
132
|
+
const modelName = reportedModelName();
|
|
133
|
+
if (chunks.length === 0) {
|
|
134
|
+
return { vectors: [], model: modelName, dim: nativeDim };
|
|
135
|
+
}
|
|
136
|
+
let vectors = await embedAll(chunks);
|
|
137
|
+
if (truncateDim !== null) {
|
|
138
|
+
vectors = vectors.map((v) => truncate(v, truncateDim));
|
|
139
|
+
}
|
|
140
|
+
const dim = vectors[0]?.length ?? truncateDim ?? nativeDim;
|
|
141
|
+
return { vectors, model: modelName, dim };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function parseChunks(line) {
|
|
145
|
+
let body;
|
|
146
|
+
try {
|
|
147
|
+
body = JSON.parse(line);
|
|
148
|
+
} catch {
|
|
149
|
+
throw new Error("invalid JSON");
|
|
150
|
+
}
|
|
151
|
+
if (typeof body !== "object" || body === null || !Array.isArray(body.chunks)) {
|
|
152
|
+
throw new Error('request must be { "chunks": string[] }');
|
|
153
|
+
}
|
|
154
|
+
for (let i = 0; i < body.chunks.length; i++) {
|
|
155
|
+
if (typeof body.chunks[i] !== "string") {
|
|
156
|
+
throw new Error(`chunks[${i}] must be a string`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return body.chunks;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Warm up so the first real batch isn't disproportionately slow.
|
|
163
|
+
const warmup = await handleBatch(["warmup"]);
|
|
164
|
+
const nativeDim = truncateDim ?? warmup.dim;
|
|
165
|
+
|
|
166
|
+
async function respond(line) {
|
|
167
|
+
try {
|
|
168
|
+
const chunks = parseChunks(line);
|
|
169
|
+
const result = await handleBatch(chunks);
|
|
170
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
171
|
+
} catch (err) {
|
|
172
|
+
// Framing convention (stub-embedder.mjs): a failed batch still emits one
|
|
173
|
+
// line so the peer never hangs waiting for a response.
|
|
174
|
+
process.stdout.write(`${JSON.stringify({ error: String(err) })}\n`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Serialize batches so output lines stay in input order even if several lines
|
|
179
|
+
// arrive buffered together — the async handler would otherwise race. (mrplex's
|
|
180
|
+
// cmd-hook is one-in-flight, but this keeps the script correct on its own.)
|
|
181
|
+
const rl = createInterface({ input: process.stdin });
|
|
182
|
+
let queue = Promise.resolve();
|
|
183
|
+
rl.on("line", (line) => {
|
|
184
|
+
if (line.trim().length === 0) return;
|
|
185
|
+
queue = queue.then(() => respond(line));
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
console.error(
|
|
189
|
+
`embedder stdio model=${modelKey}${truncateDim !== null ? ` dim=${truncateDim}` : ""}`,
|
|
190
|
+
);
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mrplex/embedder",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local CPU embedding provider for mrplex's --embed-cmd hook (bge-small-en-v1.5 via fastembed). Isolated from mrplex core so fastembed/tar stay out of the core dependency graph.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Brendan Baldwin <brendan@usergenic.com>",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/usergenic/mrplex.git",
|
|
11
|
+
"directory": "packages/embedder"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/usergenic/mrplex/tree/main/packages/embedder",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/usergenic/mrplex/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"mrplex",
|
|
19
|
+
"embeddings",
|
|
20
|
+
"semantic-search",
|
|
21
|
+
"fastembed",
|
|
22
|
+
"onnx",
|
|
23
|
+
"bge"
|
|
24
|
+
],
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"bin": {
|
|
29
|
+
"mrplex-embedder": "embedder.mjs"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"embedder.mjs",
|
|
33
|
+
"README.md"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"start": "node embedder.mjs --stdio",
|
|
37
|
+
"test": "node --test test/*.test.mjs"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=20.11.0"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"fastembed": "^2.1.0"
|
|
44
|
+
}
|
|
45
|
+
}
|