mini-semantic-search 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,24 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ShakirasHips
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ This licence covers the code. The files in models/ come from others and keep
24
+ their own licences: see models/README.md.
package/README.md CHANGED
@@ -1,3 +1,213 @@
1
- # Temporary Holding Version
1
+ # mini-semantic-search
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Semantic search with [MiniSearch](https://github.com/lucaong/minisearch)'s API. Documents are matched by meaning
4
+ rather than shared words: "stop sending me newsletters" finds "How do I unsubscribe from your emails?", and "the mug
5
+ came smashed" finds "My item arrived damaged or broken". It runs in browsers and Node with no dependencies, no
6
+ server and no API calls, and the package is 2.2 MB with its model (2.0 MB gzipped).
7
+
8
+ ```ts
9
+ import MiniSemanticSearch from 'mini-semantic-search';
10
+
11
+ const search = await MiniSemanticSearch.create({
12
+ fields: ['title', 'text'], // fields to embed and search
13
+ storeFields: ['title'], // fields to return with results
14
+ });
15
+
16
+ search.addAll([
17
+ { id: 1, title: 'Returns', text: 'Start a return under Orders in your account.' },
18
+ { id: 2, title: 'Damaged items', text: 'Send us a photo of the damage and we will replace it.' },
19
+ { id: 3, title: 'Shipping times', text: 'Standard shipping takes 3 to 5 business days.' },
20
+ ]);
21
+
22
+ search.search('the mug came smashed');
23
+ // => [
24
+ // { id: 2, score: 0.29, title: 'Damaged items' },
25
+ // { id: 3, score: 0.16, title: 'Shipping times' },
26
+ // { id: 1, score: 0.02, title: 'Returns' },
27
+ // ]
28
+ ```
29
+
30
+ The embeddings come from [Model2Vec](https://github.com/MinishLab/model2vec), a static embedding model. It keeps one
31
+ vector per token, and a text's vector is the average of its tokens' vectors. There is no neural network to run, so
32
+ embedding a paragraph takes about 15 µs on a CPU.
33
+
34
+ ## Install
35
+
36
+ ```sh
37
+ npm install mini-semantic-search
38
+ ```
39
+
40
+ - **Node 18+:** `import MiniSemanticSearch from 'mini-semantic-search'`. The bundled model loads from the package.
41
+ - **Bundlers (Vite, webpack 5, esbuild, Rollup):** the same import. The model is found with
42
+ `new URL('../models/…', import.meta.url)`, which bundlers copy as an asset. Or pass `model:` your own URL.
43
+ - **Straight from a CDN, no build step:**
44
+
45
+ ```html
46
+ <script type="module">
47
+ import MiniSemanticSearch from 'https://cdn.jsdelivr.net/npm/mini-semantic-search@0.2.0/dist/index.js';
48
+ const search = await MiniSemanticSearch.create({ fields: ['title', 'text'] });
49
+ </script>
50
+ ```
51
+
52
+ The model (2.2 MB) then loads from the CDN too.
53
+
54
+ ## API
55
+
56
+ It follows MiniSearch, so code written for one mostly works with the other. The one difference in setting it up is
57
+ that the model has to load first, so you either `await MiniSemanticSearch.create(options)` or pass a loaded model
58
+ to the constructor:
59
+
60
+ ```ts
61
+ import MiniSemanticSearch, { StaticModel } from 'mini-semantic-search';
62
+
63
+ const model = await StaticModel.load(); // load once, share between indexes
64
+ const search = new MiniSemanticSearch({ model, fields: ['title', 'text'] });
65
+ ```
66
+
67
+ | Option | Default | |
68
+ |---|---|---|
69
+ | `fields` | required | The fields embedded and searched. A field holding an array is embedded item by item, and its closest item counts. |
70
+ | `storeFields` | `[]` | The fields returned with each result. |
71
+ | `idField` | `'id'` | The field holding each document's unique id. |
72
+ | `extractField` | `doc[field]` | `(document, fieldName) => value`, for nested or computed fields. |
73
+ | `searchOptions` | `{}` | Defaults for `search`. |
74
+ | `model` | bundled potion-base-8M | A `StaticModel`, or (in `create`) a URL, path or bytes of a packed model. |
75
+
76
+ | Method | |
77
+ |---|---|
78
+ | `add(doc)`, `addAll(docs)` | Index documents. They can be searched straight away. A duplicate id throws. |
79
+ | `addAllAsync(docs, { chunkSize: 100 })` | The same, yielding between chunks so a page stays responsive. |
80
+ | `remove(doc)`, `discard(id)`, `discardAll(ids)` | Remove documents, by document or by id. |
81
+ | `removeAll(docs?)` | Remove the given documents, or all of them. |
82
+ | `replace(doc)` | Replace the document with the same id. |
83
+ | `has(id)`, `getStoredFields(id)`, `documentCount` | What's indexed. |
84
+ | `search(query, options?)` | Every document, best first, as `{ id, score, ...storeFields }`. |
85
+
86
+ Search options:
87
+
88
+ | Option | |
89
+ |---|---|
90
+ | `fields` | Search only these fields, e.g. `{ fields: ['title'] }`. |
91
+ | `boost` | Weights of fields against each other, e.g. `{ boost: { title: 3 } }`. |
92
+ | `filter` | `(result) => boolean`; the result includes its stored fields. |
93
+ | `minScore` | Leave out results scoring under this. |
94
+
95
+ **How documents are scored.** Each document is embedded once per field and once with all its fields together. Its
96
+ score is half the whole document's cosine similarity to the query, plus half its fields' similarities averaged
97
+ (weighted by `boost`). Scores run from about −0.1 to 1. A document always scores something, so every document comes
98
+ back: `slice` the results, or set `minScore` to drop the ones too far off to count as a match. Where to set it
99
+ depends on your documents' wording. Try your own off-topic questions and put it just above the scores they get.
100
+
101
+ **How it differs from MiniSearch.** Results have no `terms`, `queryTerms` or `match`, because nothing is matched
102
+ word for word. There's no `prefix`, `fuzzy`, `combineWith`, `autoSuggest` or query syntax, and no
103
+ `toJSON`/`loadJSON`: indexing costs about 0.05 ms per document, so you can just index again on load.
104
+
105
+ Indexing takes about 0.05 ms per document with three fields. A search over 10,000 documents takes about 4 ms, or
106
+ about 13 ms when its `fields` or `boost` differ from the last search's. (Options used twice running get each
107
+ document's vectors added up into one, so a search is one dot product per document.)
108
+
109
+ ## Embeddings
110
+
111
+ ```ts
112
+ import { StaticModel, similarity } from 'mini-semantic-search';
113
+
114
+ const model = await StaticModel.load(); // or StaticModel.load(urlOrPathOrBytes)
115
+ const a = model.embed('how do I cancel my order'); // Float32Array(256), unit length
116
+ const b = model.embed('stop my purchase before it ships');
117
+ similarity(a, b); // cosine similarity, 1 = same meaning
118
+ model.embedMany(['refund', 'exchange']); // several at once
119
+ model.tokenize('Café'); // token ids
120
+ ```
121
+
122
+ This matches the Python library. Token ids are identical to Hugging Face's tokenizer, and with a full-precision
123
+ model the vectors match `model2vec` to within 1e-6. The tests check the token ids and the bundled model's vectors
124
+ against fixtures made with `scripts/make-fixture.py`.
125
+
126
+ Any Model2Vec model with a lower-casing BERT tokenizer and no token weights can be packed (most `minishlab/potion-*`
127
+ models). At 4 bits instead of 2, potion-base-8M is 4.1 MB and scores as the full-precision model does:
128
+
129
+ ```sh
130
+ npx mini-semantic-search pack ./potion-base-8M ./potion-base-8M-4bit.bin --bits 4
131
+ ```
132
+
133
+ ```ts
134
+ const search = await MiniSemanticSearch.create({ fields: ['title', 'text'], model: '/potion-base-8M-4bit.bin' });
135
+ ```
136
+
137
+ ## Command line
138
+
139
+ ```sh
140
+ npx mini-semantic-search search docs.json "how do I get a refund" --fields title,text [--k 5]
141
+ npx mini-semantic-search embed "some text" # the vector as JSON
142
+ npx mini-semantic-search pack <model folder> out.bin [--bits 2|4|8|32] [--dims N]
143
+ ```
144
+
145
+ ## How well it works
146
+
147
+ On the example FAQ in `examples/` (a made-up shop, 45 entries searched on `section`, `q` and `a`, and 135 casual
148
+ test questions), with the right entry ranked first or in the top 3:
149
+
150
+ | Model | Size | `boost` | First | Top 3 |
151
+ |---|---|---|---|---|
152
+ | **potion-base-8M at 2 bits (bundled)** | **2.2 MB** | none | **66.7%** | **83.7%** |
153
+ | **potion-base-8M at 2 bits (bundled)** | **2.2 MB** | `{ q: 3 }` | **65.2%** | **85.9%** |
154
+ | potion-base-8M at 4 bits | 4.1 MB | `{ q: 3 }` | 65.2% | 88.1% |
155
+ | potion-base-8M, full precision | 30.5 MB | `{ q: 3 }` | 64.4% | 88.1% |
156
+ | 0.1's model: 128 of the 256 dimensions at 4 bits | 2.2 MB | `{ q: 3 }` | 60.7% | 83.7% |
157
+
158
+ The test questions were written with the entries in view, so expect real questions to do somewhat worse. The set
159
+ is small: one question is 0.7%. Two public benchmarks are bigger. In [Banking77](https://huggingface.co/datasets/mteb/banking77),
160
+ 3,080 customer questions each count as right when the closest of 10,003 other questions has the same intent (of 77).
161
+ In [SciFact](https://huggingface.co/datasets/mteb/scifact), 300 claims look for the abstracts about them among 5,183
162
+ (nDCG@10). `scripts/eval.mjs` runs both:
163
+
164
+ | Model | Size | Banking77 | SciFact |
165
+ |---|---|---|---|
166
+ | **potion-base-8M at 2 bits (bundled)** | **2.2 MB** | **88.3%** | **50.1** |
167
+ | potion-base-8M at 4 bits | 4.1 MB | 88.3% | 50.4 |
168
+ | potion-base-8M, full precision | 30.5 MB | 88.5% | 50.6 |
169
+ | 0.1's model: 128 of the 256 dimensions at 4 bits | 2.2 MB | 87.3% | 45.9 |
170
+
171
+ **How the model is packed.** Each value is one of four levels fitted to the values (Lloyd-Max), times a scale for
172
+ its dimension and one for its token. The scale per dimension matters because Model2Vec's dimensions are PCA
173
+ components whose spread falls about fivefold from the first to the last. At 2 bits a single vector is noisy (cosine
174
+ about 0.95 to the full-precision one), but the noise is spread across 256 dimensions and mostly cancels out when
175
+ vectors are compared, so it loses less than keeping half the dimensions at 4 bits. The model stays packed in memory,
176
+ so it takes about 3 MB of it rather than 30 MB, and loads in about 6 ms.
177
+
178
+ **What it can't do:**
179
+ - Averaging tokens loses word order, so "can I add a shopper" and "can a shopper add me" look alike.
180
+ - It only knows words its training text had. Brand names and jargon ("amex") need other phrasings, such as an
181
+ array field of the ways people ask.
182
+ - Misspelt words break into word pieces that mean something else, so typos hurt.
183
+ - English only.
184
+
185
+ ## Development
186
+
187
+ ```sh
188
+ npm install
189
+ npm test # builds, then runs the tests (Node 21+)
190
+ sh scripts/smoke.sh # packs the package, installs it in an empty project and uses it from there
191
+ python scripts/make-fixture.py <potion-base-8M folder> # rebuilds the reference fixture (needs model2vec)
192
+ node scripts/eval.mjs <data folder> <model.bin> ... # Banking77 and SciFact (see the script for the data)
193
+ ```
194
+
195
+ The bundled model is rebuilt with
196
+ `node dist/cli.js pack <potion-base-8M folder> models/potion-base-8M.bin --bits 2`.
197
+
198
+ CI (`.github/workflows/ci.yml`) builds every push and pull request on Node 18, 20, 22 and 24, runs the tests (22
199
+ and 24) and the install check (all four).
200
+
201
+ To release, bump the version and push the tag: `npm version patch && git push --follow-tags`. The release workflow
202
+ checks that the tag matches `package.json`, tests again, publishes to npm with provenance and creates a GitHub
203
+ release. It publishes with npm's trusted publishing (the package's Settings → Trusted publishing: GitHub Actions,
204
+ repository `ShakirasHips/model2vec-js`, workflow `release.yml`, environment `npm`), so no token is stored anywhere.
205
+
206
+ ## Credits and licence
207
+
208
+ The code is MIT-licensed (`LICENSE`). The bundled model is
209
+ [potion-base-8M](https://huggingface.co/minishlab/potion-base-8M) by MinishLab, MIT, repacked at 2 bits (see
210
+ `models/README.md`). The API follows [MiniSearch](https://github.com/lucaong/minisearch) by Luca Ongaro.
211
+
212
+ This package reimplements Model2Vec's inference and BERT's tokenizer in TypeScript; it contains no code from
213
+ Model2Vec, BERT or MiniSearch.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ import { readFile, writeFile } from 'node:fs/promises';
3
+ import { MiniSemanticSearch, packModel, StaticModel } from './node.js';
4
+ const HELP = `mini-semantic-search: semantic search with Model2Vec static embeddings
5
+
6
+ mini-semantic-search search <docs.json> "<query>" --fields title,text [--id id] [--k 5] [--model m.bin]
7
+ The documents closest in meaning to the query. docs.json is an array of objects.
8
+ mini-semantic-search embed "<text>" [--model m.bin]
9
+ The text's vector as JSON.
10
+ mini-semantic-search pack <model folder> <out.bin> [--bits 2|4|8|32] [--dims N] [--group 64]
11
+ Packs a Model2Vec model from Hugging Face (model.safetensors + tokenizer.json). --group is for 8 bits.
12
+
13
+ --model defaults to the bundled potion-base-8M (256 dimensions at 2 bits).`;
14
+ const argv = process.argv.slice(2);
15
+ const flags = new Map();
16
+ const args = [];
17
+ for (let i = 0; i < argv.length; i++) {
18
+ const a = argv[i];
19
+ if (a.startsWith('--'))
20
+ flags.set(a.slice(2), argv[++i]);
21
+ else
22
+ args.push(a);
23
+ }
24
+ const flag = (name) => flags.get(name);
25
+ const num = (name, def) => (flags.has(name) ? Number(flag(name)) : def);
26
+ const model = () => StaticModel.load(flag('model'));
27
+ async function main() {
28
+ const [cmd, ...rest] = args;
29
+ if (cmd === 'search' && rest.length === 2 && flag('fields')) {
30
+ const fields = flag('fields').split(',');
31
+ const idField = flag('id') ?? 'id';
32
+ const docs = JSON.parse(await readFile(rest[0], 'utf8'));
33
+ const search = await MiniSemanticSearch.create({ fields, storeFields: fields, idField, model: flag('model') });
34
+ search.addAll(docs);
35
+ for (const r of search.search(rest[1]).slice(0, num('k', 5))) {
36
+ console.log(`${r.score.toFixed(3)} ${String(r.id)} ${String(r[fields[0]] ?? '')}`);
37
+ }
38
+ }
39
+ else if (cmd === 'embed' && rest.length === 1) {
40
+ console.log(JSON.stringify(Array.from((await model()).embed(rest[0]), (x) => Number(x.toFixed(6)))));
41
+ }
42
+ else if (cmd === 'pack' && rest.length === 2) {
43
+ const bits = num('bits', 4);
44
+ if (bits !== 2 && bits !== 4 && bits !== 8 && bits !== 32)
45
+ throw new Error('--bits must be 2, 4, 8 or 32');
46
+ const bytes = await packModel(rest[0], { bits, group: num('group', 64), dims: flags.has('dims') ? num('dims', 0) : undefined });
47
+ await writeFile(rest[1], bytes);
48
+ const m = StaticModel.fromBytes(bytes);
49
+ console.log(`${m.tokens.length} tokens × ${m.dims} dims at ${bits} bits -> ${rest[1]} (${(bytes.length / 1e6).toFixed(2)} MB)`);
50
+ }
51
+ else {
52
+ console.log(HELP);
53
+ if (cmd && cmd !== 'help' && cmd !== '--help')
54
+ process.exitCode = 1;
55
+ }
56
+ }
57
+ main().catch((err) => {
58
+ console.error(`mini-semantic-search: ${err.message}`);
59
+ process.exit(1);
60
+ });
@@ -0,0 +1,32 @@
1
+ export interface ModelData {
2
+ tokens: string[];
3
+ dims: number;
4
+ /** tokens.length × dims values, row by row. */
5
+ vectors: Float32Array;
6
+ }
7
+ /** A version 2 model as stored: value (row, d) = levels[code] × rowScales[row] × colScales[d]. */
8
+ export interface PackedModelData {
9
+ tokens: string[];
10
+ dims: number;
11
+ bits: 2 | 4;
12
+ levels: Float32Array;
13
+ colScales: Float32Array;
14
+ rowScales: Float32Array;
15
+ /** dims × bits / 8 bytes per row. */
16
+ codes: Uint8Array;
17
+ }
18
+ export interface EncodeOptions {
19
+ /** 32, 8, 4 or 2 (default 4). 4 and 2 use a learned table of levels; 8 a scale per group. */
20
+ bits?: 32 | 8 | 4 | 2;
21
+ /** At 8 bits, the values sharing one scale (default 64); must divide the number of dimensions. */
22
+ group?: number;
23
+ }
24
+ export declare function fromF16(h: number): number;
25
+ export declare function toF16(x: number): number;
26
+ /** Reads a model file, leaving a version 2 model packed (as `StaticModel` keeps it). */
27
+ export declare function decodePacked(bytes: Uint8Array): ModelData | PackedModelData;
28
+ /** The full table of a packed model. */
29
+ export declare function unpack({ tokens, dims, bits, levels, colScales, rowScales, codes }: PackedModelData): ModelData;
30
+ /** Reads a model file into its full table of vectors. */
31
+ export declare function decodeModel(bytes: Uint8Array): ModelData;
32
+ export declare function encodeModel({ tokens, dims, vectors }: ModelData, options?: EncodeOptions): Uint8Array;
package/dist/format.js ADDED
@@ -0,0 +1,194 @@
1
+ // The packed model file. Little-endian. It starts with seven u32s: magic "FAQM", version, vocabulary size, dimensions,
2
+ // bits, group size (version 1 only, else 0) and the vocabulary's byte length; then the tokens joined by "\n", padded
3
+ // to 4 bytes; then the vectors.
4
+ //
5
+ // Version 2 (2 or 4 bits): each value is a code into a learned table of 2^bits levels, scaled by its column's scale
6
+ // and its row's scale. After the vocabulary come the levels (f32), the column scales (one f32 per dimension), the row
7
+ // scales (one f16 per token, padded to 4 bytes) and the codes, each row in dims × bits / 8 bytes, low bits first.
8
+ // Model2Vec's dimensions are PCA components whose spread falls about fivefold from the first to the last, so a scale
9
+ // per column lets every dimension use all the levels, and a table fitted to the values (Lloyd-Max) puts the levels
10
+ // where the values are.
11
+ //
12
+ // Version 1 (32, 8 or 4 bits), still read: at 32 bits plain f32; at 8 bits each value is an int8, and at 4 bits two
13
+ // values share a byte (low nibble first, each nibble − 8); either way every `group` values share one f16 scale, and
14
+ // all the scales come first, padded to 4 bytes.
15
+ const MAGIC = 0x4d514146;
16
+ export function fromF16(h) {
17
+ const e = (h >> 10) & 31, m = h & 1023, s = h & 0x8000 ? -1 : 1;
18
+ if (e === 0)
19
+ return s * m * 2 ** -24;
20
+ if (e === 31)
21
+ return m ? NaN : s * Infinity;
22
+ return s * (1 + m / 1024) * 2 ** (e - 15);
23
+ }
24
+ export function toF16(x) {
25
+ const u = new Uint32Array(new Float32Array([x]).buffer)[0];
26
+ const s = (u >> 16) & 0x8000, e = ((u >> 23) & 255) - 127 + 15;
27
+ let m = u & 0x7fffff;
28
+ if (e <= 0)
29
+ return s | Math.round((m | 0x800000) / 2 ** (14 - e));
30
+ if (e >= 31)
31
+ return s | 0x7c00;
32
+ m = Math.round(m / 8192);
33
+ return m === 1024 ? s | ((e + 1) << 10) : s | (e << 10) | m;
34
+ }
35
+ const pad4 = (n) => (n + 3) & ~3;
36
+ /** Reads a model file, leaving a version 2 model packed (as `StaticModel` keeps it). */
37
+ export function decodePacked(bytes) {
38
+ const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
39
+ if (bytes.byteLength < 28 || dv.getUint32(0, true) !== MAGIC)
40
+ throw new Error('not a model2vec-js model file');
41
+ const [, version, n, dims, bits, group, vocabLen] = Array.from({ length: 7 }, (_, i) => dv.getUint32(i * 4, true));
42
+ if (version !== 1 && version !== 2)
43
+ throw new Error(`unsupported model file version ${version}; a newer mini-semantic-search may read it`);
44
+ let off = 28;
45
+ const tokens = new TextDecoder().decode(bytes.subarray(off, off + vocabLen)).split('\n');
46
+ if (tokens.length !== n)
47
+ throw new Error('model file is corrupt: vocabulary size');
48
+ off += pad4(vocabLen);
49
+ const count = n * dims;
50
+ if (version === 2) {
51
+ if (bits !== 2 && bits !== 4)
52
+ throw new Error(`unsupported bits: ${bits}`);
53
+ const k = 1 << bits, rowBytes = (dims * bits) / 8;
54
+ if (bytes.byteLength < off + 4 * (k + dims) + pad4(n * 2) + n * rowBytes)
55
+ throw new Error('model file is corrupt: too short');
56
+ const f32 = (len) => {
57
+ const a = new Float32Array(len);
58
+ for (let i = 0; i < len; i++, off += 4)
59
+ a[i] = dv.getFloat32(off, true);
60
+ return a;
61
+ };
62
+ const levels = f32(k), colScales = f32(dims);
63
+ const rowScales = new Float32Array(n);
64
+ for (let i = 0; i < n; i++)
65
+ rowScales[i] = fromF16(dv.getUint16(off + i * 2, true));
66
+ off += pad4(n * 2);
67
+ return { tokens, dims, bits, levels, colScales, rowScales, codes: bytes.subarray(off, off + n * rowBytes) };
68
+ }
69
+ const vectors = new Float32Array(count);
70
+ if (bits === 32) {
71
+ // Copied byte for byte: a Node Buffer's `slice` is a view, so its `.buffer` can hold much more than this.
72
+ new Uint8Array(vectors.buffer).set(bytes.subarray(off, off + count * 4));
73
+ }
74
+ else if (bits === 8 || bits === 4) {
75
+ const groups = count / group;
76
+ const scales = new Float32Array(groups);
77
+ for (let g = 0; g < groups; g++)
78
+ scales[g] = fromF16(dv.getUint16(off + g * 2, true));
79
+ off += pad4(groups * 2);
80
+ for (let i = 0; i < count; i++) {
81
+ const q = bits === 8 ? (bytes[off + i] << 24) >> 24 : ((bytes[off + (i >> 1)] >> ((i & 1) * 4)) & 15) - 8;
82
+ vectors[i] = q * scales[(i / group) | 0];
83
+ }
84
+ }
85
+ else {
86
+ throw new Error(`unsupported bits: ${bits}`);
87
+ }
88
+ return { tokens, dims, vectors };
89
+ }
90
+ /** The full table of a packed model. */
91
+ export function unpack({ tokens, dims, bits, levels, colScales, rowScales, codes }) {
92
+ const vectors = new Float32Array(tokens.length * dims), mask = (1 << bits) - 1;
93
+ for (let i = 0; i < vectors.length; i++) {
94
+ const bit = i * bits, code = (codes[bit >> 3] >> (bit & 7)) & mask;
95
+ vectors[i] = levels[code] * rowScales[(i / dims) | 0] * colScales[i % dims];
96
+ }
97
+ return { tokens, dims, vectors };
98
+ }
99
+ /** Reads a model file into its full table of vectors. */
100
+ export function decodeModel(bytes) {
101
+ const data = decodePacked(bytes);
102
+ return 'vectors' in data ? data : unpack(data);
103
+ }
104
+ /** Lloyd-Max: the `k` levels that minimise the squared error of rounding `values` (sorted) to the nearest one. */
105
+ function fitLevels(values, k, iterations = 30) {
106
+ const levels = Float32Array.from({ length: k }, (_, i) => values[Math.floor(((i + 0.5) / k) * values.length)]);
107
+ for (let it = 0; it < iterations; it++) {
108
+ const sum = new Float64Array(k), count = new Float64Array(k);
109
+ for (let i = 0, j = 0; i < values.length; i++) {
110
+ const x = values[i];
111
+ while (j < k - 1 && Math.abs(x - levels[j + 1]) <= Math.abs(x - levels[j]))
112
+ j++;
113
+ sum[j] += x;
114
+ count[j]++;
115
+ }
116
+ for (let j = 0; j < k; j++)
117
+ if (count[j])
118
+ levels[j] = sum[j] / count[j];
119
+ }
120
+ return levels;
121
+ }
122
+ function encodeBody(n, dims, vectors, { bits = 4, group = 64 }) {
123
+ if (bits === 32)
124
+ return new Uint8Array(new Float32Array(vectors).buffer);
125
+ if (bits === 8) {
126
+ if (dims % group)
127
+ throw new Error(`group (${group}) must divide dims (${dims})`);
128
+ const count = n * dims, groups = count / group, scaleBytes = pad4(groups * 2);
129
+ const body = new Uint8Array(scaleBytes + count), dv = new DataView(body.buffer);
130
+ for (let g = 0; g < groups; g++) {
131
+ let max = 0;
132
+ for (let i = g * group; i < (g + 1) * group; i++)
133
+ max = Math.max(max, Math.abs(vectors[i]));
134
+ const h = toF16(max / 127), scale = fromF16(h) || 1;
135
+ dv.setUint16(g * 2, h, true);
136
+ for (let i = g * group; i < (g + 1) * group; i++)
137
+ dv.setInt8(scaleBytes + i, Math.max(-127, Math.min(127, Math.round(vectors[i] / scale))));
138
+ }
139
+ return body;
140
+ }
141
+ if (bits !== 4 && bits !== 2)
142
+ throw new Error(`unsupported bits: ${bits}`);
143
+ if ((dims * bits) % 8)
144
+ throw new Error(`dims (${dims}) × bits (${bits}) must be a whole number of bytes`);
145
+ const k = 1 << bits, rowBytes = (dims * bits) / 8;
146
+ // Column scales, then row scales over the column-scaled values, as stored (f32, f16).
147
+ const col = new Float64Array(dims);
148
+ for (let i = 0; i < vectors.length; i++)
149
+ col[i % dims] += vectors[i] ** 2;
150
+ const colScales = Float32Array.from(col, (s) => Math.sqrt(s / n) || 1);
151
+ const scaled = Float32Array.from(vectors, (x, i) => x / colScales[i % dims]);
152
+ const rowHalves = new Uint16Array(n), rowScales = new Float32Array(n);
153
+ for (let r = 0; r < n; r++) {
154
+ let s = 0;
155
+ for (let d = 0; d < dims; d++)
156
+ s += scaled[r * dims + d] ** 2;
157
+ rowHalves[r] = toF16(Math.sqrt(s / dims));
158
+ rowScales[r] = fromF16(rowHalves[r]);
159
+ const div = rowScales[r] || 1;
160
+ for (let d = 0; d < dims; d++)
161
+ scaled[r * dims + d] /= div;
162
+ }
163
+ const levels = fitLevels(scaled.slice().sort(), k);
164
+ const bounds = levels.subarray(1).map((l, j) => (levels[j] + l) / 2);
165
+ const body = new Uint8Array(4 * (k + dims) + pad4(n * 2) + n * rowBytes), dv = new DataView(body.buffer);
166
+ let off = 0;
167
+ for (const x of [...levels, ...colScales])
168
+ dv.setFloat32((off += 4) - 4, x, true);
169
+ rowHalves.forEach((h, r) => dv.setUint16(off + r * 2, h, true));
170
+ off += pad4(n * 2);
171
+ for (let i = 0; i < scaled.length; i++) {
172
+ let code = 0;
173
+ while (code < k - 1 && scaled[i] > bounds[code])
174
+ code++;
175
+ body[off + ((i * bits) >> 3)] |= code << ((i * bits) & 7);
176
+ }
177
+ return body;
178
+ }
179
+ export function encodeModel({ tokens, dims, vectors }, options = {}) {
180
+ const n = tokens.length, bits = options.bits ?? 4;
181
+ if (vectors.length !== n * dims)
182
+ throw new Error('vectors must hold tokens.length × dims values');
183
+ if (tokens.some((t) => t.includes('\n')))
184
+ throw new Error('tokens must not contain newlines');
185
+ const vocab = new TextEncoder().encode(tokens.join('\n'));
186
+ const body = encodeBody(n, dims, vectors, { ...options, bits });
187
+ const version = bits === 4 || bits === 2 ? 2 : 1, group = bits === 8 ? options.group ?? 64 : 0;
188
+ const out = new Uint8Array(28 + pad4(vocab.length) + body.length);
189
+ const dv = new DataView(out.buffer);
190
+ [MAGIC, version, n, dims, bits, group, vocab.length].forEach((v, i) => dv.setUint32(i * 4, v, true));
191
+ out.set(vocab, 28);
192
+ out.set(body, 28 + pad4(vocab.length));
193
+ return out;
194
+ }
@@ -0,0 +1,7 @@
1
+ import { MiniSemanticSearch } from './search.js';
2
+ export default MiniSemanticSearch;
3
+ export { MiniSemanticSearch, type Options, type SearchOptions, type SearchResult } from './search.js';
4
+ export { StaticModel, similarity } from './model.js';
5
+ export { normalize, preTokenize, wordPiece } from './tokenizer.js';
6
+ export { decodeModel, encodeModel, type ModelData, type EncodeOptions } from './format.js';
7
+ export { loadBytes, DEFAULT_MODEL, type Source } from './load.js';
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ import { MiniSemanticSearch } from './search.js';
2
+ export default MiniSemanticSearch;
3
+ export { MiniSemanticSearch } from './search.js';
4
+ export { StaticModel, similarity } from './model.js';
5
+ export { normalize, preTokenize, wordPiece } from './tokenizer.js';
6
+ export { decodeModel, encodeModel } from './format.js';
7
+ export { loadBytes, DEFAULT_MODEL } from './load.js';
package/dist/load.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ /** Where to load a file from: a URL, a path (Node), or the bytes themselves. */
2
+ export type Source = string | URL | ArrayBuffer | ArrayBufferView;
3
+ type FileReader = (path: string | URL) => Promise<Uint8Array>;
4
+ /** @internal Used by the Node entry point. */
5
+ export declare function setFileReader(reader: FileReader): void;
6
+ /** The bundled model: potion-base-8M, all 256 dimensions packed at 2 bits. */
7
+ export declare const DEFAULT_MODEL: URL;
8
+ export declare function loadBytes(src: Source): Promise<Uint8Array>;
9
+ export {};
package/dist/load.js ADDED
@@ -0,0 +1,23 @@
1
+ // Fetching files: URLs everywhere, and local paths in Node (the Node entry point registers a file reader, so the
2
+ // browser build never imports node:fs).
3
+ let readFile = null;
4
+ /** @internal Used by the Node entry point. */
5
+ export function setFileReader(reader) {
6
+ readFile = reader;
7
+ }
8
+ /** The bundled model: potion-base-8M, all 256 dimensions packed at 2 bits. */
9
+ export const DEFAULT_MODEL = new URL('../models/potion-base-8M.bin', import.meta.url);
10
+ export async function loadBytes(src) {
11
+ if (src instanceof ArrayBuffer)
12
+ return new Uint8Array(src);
13
+ if (ArrayBuffer.isView(src))
14
+ return new Uint8Array(src.buffer, src.byteOffset, src.byteLength);
15
+ const url = src instanceof URL ? src : null;
16
+ if (readFile && (url ? url.protocol === 'file:' : !/^[a-z][a-z0-9+.-]*:\/\//i.test(src))) {
17
+ return readFile(url ?? src);
18
+ }
19
+ const res = await fetch(src);
20
+ if (!res.ok)
21
+ throw new Error(`${String(src)}: HTTP ${res.status}`);
22
+ return new Uint8Array(await res.arrayBuffer());
23
+ }
@@ -0,0 +1,37 @@
1
+ import { type ModelData, type PackedModelData } from './format.js';
2
+ import { type Source } from './load.js';
3
+ /**
4
+ * A Model2Vec static embedding model: one vector per token, and a sentence's vector is the mean of its tokens'
5
+ * vectors, normalised to length 1. No network runs, so embedding takes microseconds. Long texts are cut as the
6
+ * Python library cuts them: to `maxTokens` × the median token length in characters, then to `maxTokens` tokens,
7
+ * before unknown tokens are left out.
8
+ *
9
+ * A packed model (2 or 4 bits) stays packed in memory: about 2 MB for the bundled one instead of 30 MB.
10
+ */
11
+ export declare class StaticModel {
12
+ readonly tokens: string[];
13
+ readonly dims: number;
14
+ readonly unk: number;
15
+ /** Tokens read per text; Model2Vec's default is 512. */
16
+ maxTokens: number;
17
+ private readonly vocab;
18
+ private readonly medianTokenLength;
19
+ private readonly longestPiece;
20
+ private table;
21
+ private readonly packed;
22
+ constructor(data: ModelData | PackedModelData);
23
+ /** Loads a model file; by default the bundled potion-base-8M (256 dimensions at 2 bits, 2.2 MB). */
24
+ static load(src?: Source): Promise<StaticModel>;
25
+ static fromBytes(bytes: Uint8Array): StaticModel;
26
+ /** The table of token vectors, tokens.length × dims, row by row (unpacked on first use for a packed model). */
27
+ get vectors(): Float32Array;
28
+ /** Token ids, as Hugging Face's tokenizer gives them without special tokens (unknown words included as UNK). */
29
+ tokenize(text: string): number[];
30
+ /** Whether `token` (e.g. "voucher" or "##ing") is in the vocabulary. */
31
+ has(token: string): boolean;
32
+ /** The text's vector: unit length, or all zeros if it has no known tokens. */
33
+ embed(text: string): Float32Array;
34
+ embedMany(texts: string[]): Float32Array[];
35
+ }
36
+ /** Dot product: the cosine similarity of two unit vectors, such as `embed` returns (1 = same meaning). */
37
+ export declare function similarity(a: Float32Array, b: Float32Array): number;