mini-semantic-search 0.0.0-stage → 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/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,192 @@
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 (1.8 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.39, title: 'Damaged items' },
25
+ // { id: 3, score: 0.15, title: 'Shipping times' },
26
+ // { id: 1, score: 0.08, 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 document takes about 0.1 ms 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.1.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.1 ms per document, so you can just index again on load.
104
+
105
+ Indexing takes 0.05–0.2 ms per document. A search over 10,000 documents takes about 8 ms.
106
+
107
+ ## Embeddings
108
+
109
+ ```ts
110
+ import { StaticModel, similarity } from 'mini-semantic-search';
111
+
112
+ const model = await StaticModel.load(); // or StaticModel.load(urlOrPathOrBytes)
113
+ const a = model.embed('how do I cancel my order'); // Float32Array(128), unit length
114
+ const b = model.embed('stop my purchase before it ships');
115
+ similarity(a, b); // cosine similarity, 1 = same meaning
116
+ model.embedMany(['refund', 'exchange']); // several at once
117
+ model.tokenize('Café'); // token ids
118
+ ```
119
+
120
+ This matches the Python library. Token ids are identical to Hugging Face's tokenizer, and with a full-precision
121
+ model the vectors match `model2vec` to within 1e-6. The tests check the token ids and the bundled model's vectors
122
+ against fixtures made with `scripts/make-fixture.py`.
123
+
124
+ Any Model2Vec model with a lower-casing BERT tokenizer and no token weights can be packed (most `minishlab/potion-*`
125
+ models). A bigger model is more accurate. The full 256 dimensions of potion-base-8M at 4 bits are 4.2 MB:
126
+
127
+ ```sh
128
+ npx mini-semantic-search pack ./potion-base-8M ./potion-base-8M.bin --bits 4
129
+ ```
130
+
131
+ ```ts
132
+ const search = await MiniSemanticSearch.create({ fields: ['title', 'text'], model: '/potion-base-8M.bin' });
133
+ ```
134
+
135
+ ## Command line
136
+
137
+ ```sh
138
+ npx mini-semantic-search search docs.json "how do I get a refund" --fields title,text [--k 5]
139
+ npx mini-semantic-search embed "some text" # the vector as JSON
140
+ npx mini-semantic-search pack <model folder> out.bin [--bits 4|8|32] [--group 64] [--dims N]
141
+ ```
142
+
143
+ ## How well it works
144
+
145
+ On the example FAQ in `examples/` (a made-up shop, 45 entries searched on `section`, `q` and `a`, and 135 casual
146
+ test questions), with the right entry ranked first or in the top 3:
147
+
148
+ | Model | Size | `boost` | First | Top 3 |
149
+ |---|---|---|---|---|
150
+ | potion-base-8M, 256 dims at 4 bits | 4.2 MB | `{ q: 3 }` | 63.0% | 87.4% |
151
+ | **potion-base-8M, 128 dims at 4 bits (bundled)** | **2.2 MB** | none | **59.3%** | **80.0%** |
152
+ | **potion-base-8M, 128 dims at 4 bits (bundled)** | **2.2 MB** | `{ q: 3 }` | **60.7%** | **83.7%** |
153
+
154
+ The test questions were written with the entries in view, so expect real questions to do somewhat worse. The set
155
+ is small: one question is 0.7%. The bundled model keeps potion-base-8M's first 128 dimensions (its PCA components
156
+ with the most variance), and its vectors are within cosine 0.98 of the full-precision ones cut to 128.
157
+
158
+ **What it can't do:**
159
+ - Averaging tokens loses word order, so "can I add a shopper" and "can a shopper add me" look alike.
160
+ - It only knows words its training text had. Brand names and jargon ("amex") need other phrasings, such as an
161
+ array field of the ways people ask.
162
+ - Misspelt words break into word pieces that mean something else, so typos hurt.
163
+ - English only.
164
+
165
+ ## Development
166
+
167
+ ```sh
168
+ npm install
169
+ npm test # builds, then runs the tests (Node 21+)
170
+ sh scripts/smoke.sh # packs the package, installs it in an empty project and uses it from there
171
+ python scripts/make-fixture.py <potion-base-8M folder> # rebuilds the reference fixture (needs model2vec)
172
+ ```
173
+
174
+ The bundled model is rebuilt with
175
+ `node dist/cli.js pack <potion-base-8M folder> models/potion-base-8M-128d.bin --bits 4 --dims 128 --group 128`.
176
+
177
+ CI (`.github/workflows/ci.yml`) builds every push and pull request on Node 18, 20, 22 and 24, runs the tests (22
178
+ and 24) and the install check (all four).
179
+
180
+ To release, bump the version and push the tag: `npm version patch && git push --follow-tags`. The release workflow
181
+ checks that the tag matches `package.json`, tests again, publishes to npm with provenance and creates a GitHub
182
+ release. It publishes with npm's trusted publishing (the package's Settings → Trusted publishing: GitHub Actions,
183
+ repository `ShakirasHips/model2vec-js`, workflow `release.yml`, environment `npm`), so no token is stored anywhere.
184
+
185
+ ## Credits and licence
186
+
187
+ The code is MIT-licensed (`LICENSE`). The bundled model is
188
+ [potion-base-8M](https://huggingface.co/minishlab/potion-base-8M) by MinishLab, MIT, repacked at 128 dimensions and
189
+ 4 bits (see `models/README.md`). The API follows [MiniSearch](https://github.com/lucaong/minisearch) by Luca Ongaro.
190
+
191
+ This package reimplements Model2Vec's inference and BERT's tokenizer in TypeScript; it contains no code from
192
+ 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 4|8|32] [--group 64] [--dims N]
11
+ Packs a Model2Vec model from Hugging Face (model.safetensors + tokenizer.json).
12
+
13
+ --model defaults to the bundled potion-base-8M (128 dimensions at 4 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 !== 4 && bits !== 8 && bits !== 32)
45
+ throw new Error('--bits must be 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,16 @@
1
+ export interface ModelData {
2
+ tokens: string[];
3
+ dims: number;
4
+ /** tokens.length × dims values, row by row. */
5
+ vectors: Float32Array;
6
+ }
7
+ export interface EncodeOptions {
8
+ /** 32, 8 or 4 (default 4). */
9
+ bits?: 32 | 8 | 4;
10
+ /** Values sharing one scale (default 64); must divide the number of dimensions. */
11
+ group?: number;
12
+ }
13
+ export declare function fromF16(h: number): number;
14
+ export declare function toF16(x: number): number;
15
+ export declare function decodeModel(bytes: Uint8Array): ModelData;
16
+ export declare function encodeModel({ tokens, dims, vectors }: ModelData, { bits, group }?: EncodeOptions): Uint8Array;
package/dist/format.js ADDED
@@ -0,0 +1,101 @@
1
+ // The packed model file. Little-endian: magic "FAQM", version, vocabulary size, dimensions, bits (32, 8 or 4),
2
+ // group size and the vocabulary's byte length; then the tokens joined by "\n", padded to 4 bytes; then the
3
+ // vectors. At 32 bits they are plain f32. At 8 bits each value is an int8, and at 4 bits two values share a byte
4
+ // (low nibble first, each nibble − 8); either way every `group` values share one f16 scale, and all the scales
5
+ // come first, padded to 4 bytes.
6
+ const MAGIC = 0x4d514146;
7
+ const VERSION = 1;
8
+ export function fromF16(h) {
9
+ const e = (h >> 10) & 31, m = h & 1023, s = h & 0x8000 ? -1 : 1;
10
+ if (e === 0)
11
+ return s * m * 2 ** -24;
12
+ if (e === 31)
13
+ return m ? NaN : s * Infinity;
14
+ return s * (1 + m / 1024) * 2 ** (e - 15);
15
+ }
16
+ export function toF16(x) {
17
+ const u = new Uint32Array(new Float32Array([x]).buffer)[0];
18
+ const s = (u >> 16) & 0x8000, e = ((u >> 23) & 255) - 127 + 15;
19
+ let m = u & 0x7fffff;
20
+ if (e <= 0)
21
+ return s | Math.round((m | 0x800000) / 2 ** (14 - e));
22
+ if (e >= 31)
23
+ return s | 0x7c00;
24
+ m = Math.round(m / 8192);
25
+ return m === 1024 ? s | ((e + 1) << 10) : s | (e << 10) | m;
26
+ }
27
+ const pad4 = (n) => (n + 3) & ~3;
28
+ export function decodeModel(bytes) {
29
+ const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
30
+ if (bytes.byteLength < 28 || dv.getUint32(0, true) !== MAGIC)
31
+ throw new Error('not a model2vec-js model file');
32
+ const header = Array.from({ length: 7 }, (_, i) => dv.getUint32(i * 4, true));
33
+ const [, version, n, dims, bits, group, vocabLen] = header;
34
+ if (version !== VERSION)
35
+ throw new Error(`unsupported model file version ${version}`);
36
+ let off = 28;
37
+ const tokens = new TextDecoder().decode(bytes.subarray(off, off + vocabLen)).split('\n');
38
+ if (tokens.length !== n)
39
+ throw new Error('model file is corrupt: vocabulary size');
40
+ off += pad4(vocabLen);
41
+ const count = n * dims;
42
+ const vectors = new Float32Array(count);
43
+ if (bits === 32) {
44
+ vectors.set(new Float32Array(bytes.slice(off, off + count * 4).buffer));
45
+ }
46
+ else if (bits === 8 || bits === 4) {
47
+ const groups = count / group;
48
+ const scales = new Float32Array(groups);
49
+ for (let g = 0; g < groups; g++)
50
+ scales[g] = fromF16(dv.getUint16(off + g * 2, true));
51
+ off += pad4(groups * 2);
52
+ for (let i = 0; i < count; i++) {
53
+ const q = bits === 8 ? (bytes[off + i] << 24) >> 24 : ((bytes[off + (i >> 1)] >> ((i & 1) * 4)) & 15) - 8;
54
+ vectors[i] = q * scales[(i / group) | 0];
55
+ }
56
+ }
57
+ else {
58
+ throw new Error(`unsupported bits: ${bits}`);
59
+ }
60
+ return { tokens, dims, vectors };
61
+ }
62
+ export function encodeModel({ tokens, dims, vectors }, { bits = 4, group = 64 } = {}) {
63
+ const n = tokens.length, count = n * dims;
64
+ if (vectors.length !== count)
65
+ throw new Error('vectors must hold tokens.length × dims values');
66
+ if (tokens.some((t) => t.includes('\n')))
67
+ throw new Error('tokens must not contain newlines');
68
+ if (bits !== 32 && dims % group)
69
+ throw new Error(`group (${group}) must divide dims (${dims})`);
70
+ const vocab = new TextEncoder().encode(tokens.join('\n'));
71
+ let body;
72
+ if (bits === 32) {
73
+ body = new Uint8Array(new Float32Array(vectors).buffer);
74
+ }
75
+ else {
76
+ const levels = bits === 8 ? 127 : 7, groups = count / group;
77
+ const scaleBytes = pad4(groups * 2);
78
+ body = new Uint8Array(scaleBytes + (bits === 8 ? count : Math.ceil(count / 2)));
79
+ const dv = new DataView(body.buffer);
80
+ for (let g = 0; g < groups; g++) {
81
+ let max = 0;
82
+ for (let i = g * group; i < (g + 1) * group; i++)
83
+ max = Math.max(max, Math.abs(vectors[i]));
84
+ const h = toF16(max / levels), scale = fromF16(h) || 1;
85
+ dv.setUint16(g * 2, h, true);
86
+ for (let i = g * group; i < (g + 1) * group; i++) {
87
+ const v = Math.max(-levels, Math.min(levels, Math.round(vectors[i] / scale)));
88
+ if (bits === 8)
89
+ dv.setInt8(scaleBytes + i, v);
90
+ else
91
+ body[scaleBytes + (i >> 1)] |= (v + 8) << ((i & 1) * 4);
92
+ }
93
+ }
94
+ }
95
+ const out = new Uint8Array(28 + pad4(vocab.length) + body.length);
96
+ const dv = new DataView(out.buffer);
97
+ [MAGIC, VERSION, n, dims, bits, group, vocab.length].forEach((v, i) => dv.setUint32(i * 4, v, true));
98
+ out.set(vocab, 28);
99
+ out.set(body, 28 + pad4(vocab.length));
100
+ return out;
101
+ }
@@ -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's first 128 dimensions, packed at 4 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's first 128 dimensions, packed at 4 bits. */
9
+ export const DEFAULT_MODEL = new URL('../models/potion-base-8M-128d.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,31 @@
1
+ import { type ModelData } 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
+ export declare class StaticModel {
10
+ readonly tokens: string[];
11
+ readonly dims: number;
12
+ readonly vectors: Float32Array;
13
+ readonly unk: number;
14
+ /** Tokens read per text; Model2Vec's default is 512. */
15
+ maxTokens: number;
16
+ private readonly vocab;
17
+ private readonly medianTokenLength;
18
+ constructor({ tokens, dims, vectors }: ModelData);
19
+ /** Loads a packed model file; by default the bundled potion-base-8M (128 dimensions at 4 bits, 2.2 MB). */
20
+ static load(src?: Source): Promise<StaticModel>;
21
+ static fromBytes(bytes: Uint8Array): StaticModel;
22
+ /** Token ids, as Hugging Face's tokenizer gives them without special tokens (unknown words included as UNK). */
23
+ tokenize(text: string): number[];
24
+ /** Whether `token` (e.g. "voucher" or "##ing") is in the vocabulary. */
25
+ has(token: string): boolean;
26
+ /** The text's vector: unit length, or all zeros if it has no known tokens. */
27
+ embed(text: string): Float32Array;
28
+ embedMany(texts: string[]): Float32Array[];
29
+ }
30
+ /** Dot product: the cosine similarity of two unit vectors, such as `embed` returns (1 = same meaning). */
31
+ export declare function similarity(a: Float32Array, b: Float32Array): number;
package/dist/model.js ADDED
@@ -0,0 +1,74 @@
1
+ import { decodeModel } from './format.js';
2
+ import { DEFAULT_MODEL, loadBytes } from './load.js';
3
+ import { wordPiece } from './tokenizer.js';
4
+ /**
5
+ * A Model2Vec static embedding model: one vector per token, and a sentence's vector is the mean of its tokens'
6
+ * vectors, normalised to length 1. No network runs, so embedding takes microseconds. Long texts are cut as the
7
+ * Python library cuts them: to `maxTokens` × the median token length in characters, then to `maxTokens` tokens,
8
+ * before unknown tokens are left out.
9
+ */
10
+ export class StaticModel {
11
+ tokens;
12
+ dims;
13
+ vectors;
14
+ unk;
15
+ /** Tokens read per text; Model2Vec's default is 512. */
16
+ maxTokens = 512;
17
+ vocab;
18
+ medianTokenLength;
19
+ constructor({ tokens, dims, vectors }) {
20
+ this.tokens = tokens;
21
+ this.dims = dims;
22
+ this.vectors = vectors;
23
+ this.vocab = new Map(tokens.map((t, i) => [t, i]));
24
+ this.unk = this.vocab.get('[UNK]') ?? -1;
25
+ const lengths = tokens.map((t) => Array.from(t).length).sort((a, b) => a - b), mid = lengths.length >> 1;
26
+ this.medianTokenLength = Math.trunc(lengths.length % 2 ? lengths[mid] : (lengths[mid - 1] + lengths[mid]) / 2);
27
+ }
28
+ /** Loads a packed model file; by default the bundled potion-base-8M (128 dimensions at 4 bits, 2.2 MB). */
29
+ static async load(src = DEFAULT_MODEL) {
30
+ return StaticModel.fromBytes(await loadBytes(src));
31
+ }
32
+ static fromBytes(bytes) {
33
+ return new StaticModel(decodeModel(bytes));
34
+ }
35
+ /** Token ids, as Hugging Face's tokenizer gives them without special tokens (unknown words included as UNK). */
36
+ tokenize(text) {
37
+ return wordPiece(this.vocab, this.unk, text);
38
+ }
39
+ /** Whether `token` (e.g. "voucher" or "##ing") is in the vocabulary. */
40
+ has(token) {
41
+ return this.vocab.has(token);
42
+ }
43
+ /** The text's vector: unit length, or all zeros if it has no known tokens. */
44
+ embed(text) {
45
+ const { dims, vectors } = this;
46
+ const v = new Float32Array(dims);
47
+ const chars = this.maxTokens * this.medianTokenLength;
48
+ if (text.length > chars)
49
+ text = Array.from(text).slice(0, chars).join('');
50
+ for (const id of this.tokenize(text).slice(0, this.maxTokens)) {
51
+ if (id === this.unk)
52
+ continue;
53
+ for (let d = 0, o = id * dims; d < dims; d++)
54
+ v[d] += vectors[o + d];
55
+ }
56
+ let norm = 0;
57
+ for (let d = 0; d < dims; d++)
58
+ norm += v[d] * v[d];
59
+ norm = Math.sqrt(norm) || 1;
60
+ for (let d = 0; d < dims; d++)
61
+ v[d] /= norm;
62
+ return v;
63
+ }
64
+ embedMany(texts) {
65
+ return texts.map((t) => this.embed(t));
66
+ }
67
+ }
68
+ /** Dot product: the cosine similarity of two unit vectors, such as `embed` returns (1 = same meaning). */
69
+ export function similarity(a, b) {
70
+ let s = 0;
71
+ for (let i = 0; i < a.length; i++)
72
+ s += a[i] * b[i];
73
+ return s;
74
+ }
package/dist/node.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ import { MiniSemanticSearch } from './index.js';
2
+ export default MiniSemanticSearch;
3
+ export * from './index.js';
4
+ export { packModel, type PackOptions } from './pack.js';
package/dist/node.js ADDED
@@ -0,0 +1,8 @@
1
+ // The Node entry point: everything in the browser one, plus loading from local paths and packing models.
2
+ import { readFile } from 'node:fs/promises';
3
+ import { setFileReader } from './load.js';
4
+ import { MiniSemanticSearch } from './index.js';
5
+ setFileReader(async (path) => new Uint8Array(await readFile(path)));
6
+ export default MiniSemanticSearch;
7
+ export * from './index.js';
8
+ export { packModel } from './pack.js';
package/dist/pack.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ import { type EncodeOptions } from './format.js';
2
+ export interface PackOptions extends EncodeOptions {
3
+ /** Keep only the leading dimensions (Model2Vec's are PCA components, so they hold the most variance). */
4
+ dims?: number;
5
+ }
6
+ /**
7
+ * Packs a Model2Vec model folder (model.safetensors and tokenizer.json, as on Hugging Face, e.g.
8
+ * minishlab/potion-base-8M) into the file `StaticModel.load` reads.
9
+ */
10
+ export declare function packModel(dir: string, { dims, ...encode }?: PackOptions): Promise<Uint8Array>;
package/dist/pack.js ADDED
@@ -0,0 +1,37 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { encodeModel } from './format.js';
4
+ /**
5
+ * Packs a Model2Vec model folder (model.safetensors and tokenizer.json, as on Hugging Face, e.g.
6
+ * minishlab/potion-base-8M) into the file `StaticModel.load` reads.
7
+ */
8
+ export async function packModel(dir, { dims, ...encode } = {}) {
9
+ const st = await readFile(join(dir, 'model.safetensors'));
10
+ const headerLen = Number(st.readBigUInt64LE(0));
11
+ const header = JSON.parse(st.subarray(8, 8 + headerLen).toString());
12
+ const others = Object.keys(header).filter((k) => k !== 'embeddings' && k !== '__metadata__');
13
+ if (others.length)
14
+ throw new Error(`unsupported tensors: ${others.join(', ')} (token weights or mappings)`);
15
+ const info = header.embeddings;
16
+ if (!info || info.dtype !== 'F32')
17
+ throw new Error('expected an F32 "embeddings" tensor');
18
+ const [n, full] = info.shape;
19
+ const keep = Math.min(dims ?? full, full);
20
+ const data = st.subarray(8 + headerLen + info.data_offsets[0], 8 + headerLen + info.data_offsets[1]);
21
+ const src = new Float32Array(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength));
22
+ const tok = JSON.parse(await readFile(join(dir, 'tokenizer.json'), 'utf8'));
23
+ if (tok.model?.type !== 'WordPiece' || tok.normalizer?.type !== 'BertNormalizer' || tok.normalizer.lowercase === false) {
24
+ throw new Error('only lower-casing BERT (WordPiece) tokenizers are supported');
25
+ }
26
+ const tokens = [];
27
+ for (const [t, id] of Object.entries(tok.model.vocab))
28
+ tokens[id] = t;
29
+ for (const a of tok.added_tokens ?? [])
30
+ tokens[a.id] = a.content;
31
+ if (tokens.length !== n || tokens.includes(undefined))
32
+ throw new Error('vocabulary does not match the table');
33
+ const vectors = new Float32Array(n * keep);
34
+ for (let i = 0; i < n; i++)
35
+ vectors.set(src.subarray(i * full, i * full + keep), i * keep);
36
+ return encodeModel({ tokens, dims: keep, vectors }, encode);
37
+ }
@@ -0,0 +1,75 @@
1
+ import { type Source } from './load.js';
2
+ import { StaticModel } from './model.js';
3
+ type AnyDocument = any;
4
+ type DocumentId = any;
5
+ export interface SearchOptions {
6
+ /** Only these fields count (default: all of `fields`). */
7
+ fields?: string[];
8
+ /** Weights of fields against each other, e.g. `{ title: 2 }` (default 1 each). */
9
+ boost?: Record<string, number>;
10
+ /** Only results for which this is true. */
11
+ filter?: (result: SearchResult) => boolean;
12
+ /** Leave out results scoring under this (default: none). */
13
+ minScore?: number;
14
+ }
15
+ export interface Options<T = AnyDocument> {
16
+ /** The fields embedded and searched. */
17
+ fields: string[];
18
+ /** The fields returned with each result (default: none). */
19
+ storeFields?: string[];
20
+ /** The field holding each document's unique id (default "id"). */
21
+ idField?: string;
22
+ /** Reads a field from a document (default: `document[fieldName]`). Arrays are embedded item by item. */
23
+ extractField?: (document: T, fieldName: string) => unknown;
24
+ /** Defaults for `search`. */
25
+ searchOptions?: SearchOptions;
26
+ /** A loaded model, or where to load one from in `create` (default: the bundled potion-base-8M). */
27
+ model?: StaticModel | Source;
28
+ }
29
+ export type SearchResult = {
30
+ id: DocumentId;
31
+ /** About −0.1 to 1: how close in meaning the document is to the query. */
32
+ score: number;
33
+ [storedField: string]: unknown;
34
+ };
35
+ /**
36
+ * Semantic search with MiniSearch's API: documents are found by meaning rather than shared words, so "stop sending me
37
+ * newsletters" finds "How do I unsubscribe from your emails?". Each document is embedded once per field and once
38
+ * whole. It scores half its whole vector's cosine similarity to the query, plus half its fields' (weighted by
39
+ * `boost`; an array field counts its closest item). Searching takes no network and no server.
40
+ */
41
+ export declare class MiniSemanticSearch<T = AnyDocument> {
42
+ readonly model: StaticModel;
43
+ private readonly options;
44
+ private readonly index;
45
+ /** Loads the model (the bundled one unless `options.model` says otherwise), then makes the index. */
46
+ static create<T = AnyDocument>(options: Options<T>): Promise<MiniSemanticSearch<T>>;
47
+ /** For a model you have loaded already (`StaticModel.load()`); `create` loads it for you. */
48
+ constructor(options: Options<T> & {
49
+ model: StaticModel;
50
+ });
51
+ /** How many documents are indexed. */
52
+ get documentCount(): number;
53
+ /** Adds a document; it can be searched straight away. Embedding takes about 0.1 ms. */
54
+ add(document: T): void;
55
+ addAll(documents: readonly T[]): void;
56
+ /** Adds documents `chunkSize` at a time, yielding between chunks so a page stays responsive. */
57
+ addAllAsync(documents: readonly T[], { chunkSize }?: {
58
+ chunkSize?: number;
59
+ }): Promise<void>;
60
+ /** Removes a document, found by its id. */
61
+ remove(document: T): void;
62
+ /** Removes the document with this id. */
63
+ discard(id: DocumentId): void;
64
+ discardAll(ids: readonly DocumentId[]): void;
65
+ /** Removes the given documents, or every document if none are given. */
66
+ removeAll(documents?: readonly T[]): void;
67
+ /** Replaces the document that has the same id. */
68
+ replace(document: T): void;
69
+ has(id: DocumentId): boolean;
70
+ /** The document's `storeFields`, or undefined if it isn't indexed. */
71
+ getStoredFields(id: DocumentId): Record<string, unknown> | undefined;
72
+ /** Every document, best first, with its id, score and stored fields. */
73
+ search(query: string, searchOptions?: SearchOptions): SearchResult[];
74
+ }
75
+ export {};
package/dist/search.js ADDED
@@ -0,0 +1,129 @@
1
+ import { DEFAULT_MODEL } from './load.js';
2
+ import { similarity, StaticModel } from './model.js';
3
+ const texts = (value) => {
4
+ if (value === undefined || value === null)
5
+ return [];
6
+ return (Array.isArray(value) ? value : [value]).map(String).filter((s) => s.trim());
7
+ };
8
+ /**
9
+ * Semantic search with MiniSearch's API: documents are found by meaning rather than shared words, so "stop sending me
10
+ * newsletters" finds "How do I unsubscribe from your emails?". Each document is embedded once per field and once
11
+ * whole. It scores half its whole vector's cosine similarity to the query, plus half its fields' (weighted by
12
+ * `boost`; an array field counts its closest item). Searching takes no network and no server.
13
+ */
14
+ export class MiniSemanticSearch {
15
+ model;
16
+ options;
17
+ index = new Map();
18
+ /** Loads the model (the bundled one unless `options.model` says otherwise), then makes the index. */
19
+ static async create(options) {
20
+ const { model = DEFAULT_MODEL } = options;
21
+ return new MiniSemanticSearch({ ...options, model: model instanceof StaticModel ? model : await StaticModel.load(model) });
22
+ }
23
+ /** For a model you have loaded already (`StaticModel.load()`); `create` loads it for you. */
24
+ constructor(options) {
25
+ const { model, fields, storeFields = [], idField = 'id', extractField = (d, f) => d[f], searchOptions = {} } = options;
26
+ if (!(model instanceof StaticModel))
27
+ throw new Error('MiniSemanticSearch: the constructor needs a loaded model; use MiniSemanticSearch.create to load one');
28
+ if (!fields?.length)
29
+ throw new Error('MiniSemanticSearch: option "fields" must list at least one field');
30
+ this.model = model;
31
+ this.options = { fields, storeFields, idField, extractField, searchOptions };
32
+ }
33
+ /** How many documents are indexed. */
34
+ get documentCount() {
35
+ return this.index.size;
36
+ }
37
+ /** Adds a document; it can be searched straight away. Embedding takes about 0.1 ms. */
38
+ add(document) {
39
+ const { fields, storeFields, idField, extractField } = this.options;
40
+ const id = extractField(document, idField);
41
+ if (id === undefined)
42
+ throw new Error(`MiniSemanticSearch: document does not have ID field "${idField}"`);
43
+ if (this.index.has(id))
44
+ throw new Error(`MiniSemanticSearch: duplicate ID ${id}`);
45
+ const values = fields.map((f) => texts(extractField(document, f)));
46
+ const all = values.flat().join(' ');
47
+ this.index.set(id, {
48
+ fields: values.map((v) => v.map((s) => this.model.embed(s))),
49
+ whole: all ? this.model.embed(all) : null,
50
+ stored: Object.fromEntries(storeFields.map((f) => [f, extractField(document, f)])),
51
+ });
52
+ }
53
+ addAll(documents) {
54
+ for (const d of documents)
55
+ this.add(d);
56
+ }
57
+ /** Adds documents `chunkSize` at a time, yielding between chunks so a page stays responsive. */
58
+ async addAllAsync(documents, { chunkSize = 100 } = {}) {
59
+ for (let i = 0; i < documents.length; i += chunkSize) {
60
+ this.addAll(documents.slice(i, i + chunkSize));
61
+ await new Promise((resolve) => setTimeout(resolve, 0));
62
+ }
63
+ }
64
+ /** Removes a document, found by its id. */
65
+ remove(document) {
66
+ this.discard(this.options.extractField(document, this.options.idField));
67
+ }
68
+ /** Removes the document with this id. */
69
+ discard(id) {
70
+ if (!this.index.delete(id))
71
+ throw new Error(`MiniSemanticSearch: cannot discard document with ID ${id}: it is not in the index`);
72
+ }
73
+ discardAll(ids) {
74
+ for (const id of ids)
75
+ this.discard(id);
76
+ }
77
+ /** Removes the given documents, or every document if none are given. */
78
+ removeAll(documents) {
79
+ if (documents === undefined)
80
+ this.index.clear();
81
+ else
82
+ for (const d of documents)
83
+ this.remove(d);
84
+ }
85
+ /** Replaces the document that has the same id. */
86
+ replace(document) {
87
+ this.remove(document);
88
+ this.add(document);
89
+ }
90
+ has(id) {
91
+ return this.index.has(id);
92
+ }
93
+ /** The document's `storeFields`, or undefined if it isn't indexed. */
94
+ getStoredFields(id) {
95
+ return this.index.get(id)?.stored;
96
+ }
97
+ /** Every document, best first, with its id, score and stored fields. */
98
+ search(query, searchOptions = {}) {
99
+ const { fields: only, boost = {}, filter, minScore = -Infinity } = { ...this.options.searchOptions, ...searchOptions };
100
+ const { fields } = this.options;
101
+ const use = fields.map((f) => !only || only.includes(f));
102
+ const subset = use.some((u) => !u);
103
+ const q = this.model.embed(query);
104
+ const results = [];
105
+ for (const [id, doc] of this.index) {
106
+ let sum = 0, weight = 0;
107
+ doc.fields.forEach((vs, i) => {
108
+ if (!use[i] || !vs.length)
109
+ return;
110
+ const w = boost[fields[i]] ?? 1;
111
+ let best = -Infinity;
112
+ for (const v of vs)
113
+ best = Math.max(best, similarity(q, v));
114
+ sum += w * best;
115
+ weight += w;
116
+ });
117
+ if (!weight)
118
+ continue;
119
+ // The whole-document vector covers every field, so it only counts when every field does.
120
+ const score = subset || !doc.whole ? sum / weight : 0.5 * similarity(q, doc.whole) + 0.5 * (sum / weight);
121
+ if (score < minScore)
122
+ continue;
123
+ const result = { id, score, ...doc.stored }; // stored fields come last, as in MiniSearch
124
+ if (!filter || filter(result))
125
+ results.push(result);
126
+ }
127
+ return results.sort((a, b) => b.score - a.score);
128
+ }
129
+ }
@@ -0,0 +1,6 @@
1
+ /** Drops control characters, spaces out CJK characters, strips accents and lower-cases. */
2
+ export declare function normalize(text: string): string;
3
+ /** Splits normalised text into words and single punctuation characters. */
4
+ export declare function preTokenize(text: string): string[];
5
+ /** Token ids for text: each word as the longest known pieces, left to right, or `unk` if it can't be spelt. */
6
+ export declare function wordPiece(vocab: Map<string, number>, unk: number, text: string): number[];
@@ -0,0 +1,76 @@
1
+ // BERT's tokenizer, as Hugging Face's `tokenizers` runs it for Model2Vec models (BertNormalizer with lowercasing,
2
+ // BertPreTokenizer, WordPiece with "##" continuations). Checked token for token against it in the tests.
3
+ const isPunct = (c) => {
4
+ const cp = c.codePointAt(0);
5
+ return (cp >= 33 && cp <= 47) || (cp >= 58 && cp <= 64) || (cp >= 91 && cp <= 96) || (cp >= 123 && cp <= 126)
6
+ || /\p{P}/u.test(c);
7
+ };
8
+ const isCjk = (cp) => (cp >= 0x4e00 && cp <= 0x9fff) || (cp >= 0x3400 && cp <= 0x4dbf)
9
+ || (cp >= 0x20000 && cp <= 0x2a6df) || (cp >= 0x2a700 && cp <= 0x2b73f) || (cp >= 0x2b740 && cp <= 0x2b81f)
10
+ || (cp >= 0x2b820 && cp <= 0x2ceaf) || (cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0x2f800 && cp <= 0x2fa1f);
11
+ /** Drops control characters, spaces out CJK characters, strips accents and lower-cases. */
12
+ export function normalize(text) {
13
+ let out = '';
14
+ for (const c of text) {
15
+ const cp = c.codePointAt(0);
16
+ if (cp === 0 || cp === 0xfffd || (/\p{C}/u.test(c) && !/[\t\n\r]/.test(c)))
17
+ continue;
18
+ if (/\s/u.test(c))
19
+ out += ' ';
20
+ else if (isCjk(cp))
21
+ out += ` ${c} `;
22
+ else
23
+ out += c;
24
+ }
25
+ return out.normalize('NFD').replace(/\p{Mn}/gu, '').toLowerCase();
26
+ }
27
+ /** Splits normalised text into words and single punctuation characters. */
28
+ export function preTokenize(text) {
29
+ const words = [];
30
+ for (const chunk of text.split(' ')) {
31
+ let word = '';
32
+ for (const c of chunk) {
33
+ if (isPunct(c)) {
34
+ if (word)
35
+ words.push(word);
36
+ words.push(c);
37
+ word = '';
38
+ }
39
+ else
40
+ word += c;
41
+ }
42
+ if (word)
43
+ words.push(word);
44
+ }
45
+ return words;
46
+ }
47
+ /** Token ids for text: each word as the longest known pieces, left to right, or `unk` if it can't be spelt. */
48
+ export function wordPiece(vocab, unk, text) {
49
+ const ids = [];
50
+ for (const w of preTokenize(normalize(text))) {
51
+ const chars = Array.from(w);
52
+ if (chars.length > 100) {
53
+ ids.push(unk);
54
+ continue;
55
+ }
56
+ const pieces = [];
57
+ for (let start = 0; start < chars.length;) {
58
+ let end = chars.length;
59
+ let id;
60
+ for (; end > start; end--) {
61
+ id = vocab.get((start ? '##' : '') + chars.slice(start, end).join(''));
62
+ if (id !== undefined)
63
+ break;
64
+ }
65
+ if (end === start) {
66
+ pieces.length = 0;
67
+ pieces.push(unk);
68
+ break;
69
+ }
70
+ pieces.push(id);
71
+ start = end;
72
+ }
73
+ ids.push(...pieces);
74
+ }
75
+ return ids;
76
+ }
@@ -0,0 +1,13 @@
1
+ # Bundled data
2
+
3
+ This file is not covered by the package's MIT licence for its code; it keeps its source's terms.
4
+
5
+ ## potion-base-8M-128d.bin
6
+
7
+ [minishlab/potion-base-8M](https://huggingface.co/minishlab/potion-base-8M) by MinishLab, a
8
+ [Model2Vec](https://github.com/MinishLab/model2vec) model distilled from
9
+ [BAAI/bge-base-en-v1.5](https://huggingface.co/BAAI/bge-base-en-v1.5), released under the MIT licence. Its
10
+ vocabulary is bge-base-en-v1.5's (BERT's, uncased).
11
+
12
+ Modified: repacked by `mini-semantic-search pack` to its first 128 of 256 dimensions at 4 bits per value (each
13
+ token's 128 values sharing an f16 scale), 2.2 MB instead of 30.5 MB. All 29,528 tokens are kept.
Binary file
package/package.json CHANGED
@@ -1,6 +1,63 @@
1
1
  {
2
2
  "name": "mini-semantic-search",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Semantic search with a MiniSearch-style API, using Model2Vec static embeddings. Runs in browsers and Node; no dependencies; 2.2 MB with the model.",
5
+ "keywords": [
6
+ "semantic-search",
7
+ "search",
8
+ "minisearch",
9
+ "model2vec",
10
+ "embeddings",
11
+ "static-embeddings",
12
+ "sentence-embeddings",
13
+ "browser",
14
+ "offline"
15
+ ],
16
+ "license": "MIT",
17
+ "author": "ShakirasHips",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/ShakirasHips/model2vec-js.git"
21
+ },
22
+ "homepage": "https://github.com/ShakirasHips/model2vec-js#readme",
23
+ "bugs": "https://github.com/ShakirasHips/model2vec-js/issues",
24
+ "type": "module",
25
+ "exports": {
26
+ ".": {
27
+ "node": {
28
+ "types": "./dist/node.d.ts",
29
+ "default": "./dist/node.js"
30
+ },
31
+ "default": {
32
+ "types": "./dist/index.d.ts",
33
+ "default": "./dist/index.js"
34
+ }
35
+ },
36
+ "./models/*": "./models/*"
37
+ },
38
+ "types": "./dist/index.d.ts",
39
+ "bin": {
40
+ "mini-semantic-search": "dist/cli.js"
41
+ },
42
+ "files": [
43
+ "dist",
44
+ "models",
45
+ "!dist/**/*.test.*"
46
+ ],
47
+ "sideEffects": [
48
+ "./dist/node.js",
49
+ "./dist/cli.js"
50
+ ],
51
+ "engines": {
52
+ "node": ">=18"
53
+ },
54
+ "scripts": {
55
+ "build": "tsc",
56
+ "test": "tsc && node --test \"test/*.test.mjs\"",
57
+ "prepublishOnly": "npm test"
58
+ },
59
+ "devDependencies": {
60
+ "@types/node": "^20.19.0",
61
+ "typescript": "^5.9.0"
62
+ }
63
+ }