mini-semantic-search 0.1.0 → 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/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  Semantic search with [MiniSearch](https://github.com/lucaong/minisearch)'s API. Documents are matched by meaning
4
4
  rather than shared words: "stop sending me newsletters" finds "How do I unsubscribe from your emails?", and "the mug
5
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).
6
+ server and no API calls, and the package is 2.2 MB with its model (2.0 MB gzipped).
7
7
 
8
8
  ```ts
9
9
  import MiniSemanticSearch from 'mini-semantic-search';
@@ -21,15 +21,15 @@ search.addAll([
21
21
 
22
22
  search.search('the mug came smashed');
23
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' },
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
27
  // ]
28
28
  ```
29
29
 
30
30
  The embeddings come from [Model2Vec](https://github.com/MinishLab/model2vec), a static embedding model. It keeps one
31
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.
32
+ embedding a paragraph takes about 15 µs on a CPU.
33
33
 
34
34
  ## Install
35
35
 
@@ -44,7 +44,7 @@ npm install mini-semantic-search
44
44
 
45
45
  ```html
46
46
  <script type="module">
47
- import MiniSemanticSearch from 'https://cdn.jsdelivr.net/npm/mini-semantic-search@0.1.0/dist/index.js';
47
+ import MiniSemanticSearch from 'https://cdn.jsdelivr.net/npm/mini-semantic-search@0.2.0/dist/index.js';
48
48
  const search = await MiniSemanticSearch.create({ fields: ['title', 'text'] });
49
49
  </script>
50
50
  ```
@@ -100,9 +100,11 @@ depends on your documents' wording. Try your own off-topic questions and put it
100
100
 
101
101
  **How it differs from MiniSearch.** Results have no `terms`, `queryTerms` or `match`, because nothing is matched
102
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.
103
+ `toJSON`/`loadJSON`: indexing costs about 0.05 ms per document, so you can just index again on load.
104
104
 
105
- Indexing takes 0.05–0.2 ms per document. A search over 10,000 documents takes about 8 ms.
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.)
106
108
 
107
109
  ## Embeddings
108
110
 
@@ -110,7 +112,7 @@ Indexing takes 0.05–0.2 ms per document. A search over 10,000 documents takes
110
112
  import { StaticModel, similarity } from 'mini-semantic-search';
111
113
 
112
114
  const model = await StaticModel.load(); // or StaticModel.load(urlOrPathOrBytes)
113
- const a = model.embed('how do I cancel my order'); // Float32Array(128), unit length
115
+ const a = model.embed('how do I cancel my order'); // Float32Array(256), unit length
114
116
  const b = model.embed('stop my purchase before it ships');
115
117
  similarity(a, b); // cosine similarity, 1 = same meaning
116
118
  model.embedMany(['refund', 'exchange']); // several at once
@@ -122,14 +124,14 @@ model the vectors match `model2vec` to within 1e-6. The tests check the token id
122
124
  against fixtures made with `scripts/make-fixture.py`.
123
125
 
124
126
  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:
127
+ models). At 4 bits instead of 2, potion-base-8M is 4.1 MB and scores as the full-precision model does:
126
128
 
127
129
  ```sh
128
- npx mini-semantic-search pack ./potion-base-8M ./potion-base-8M.bin --bits 4
130
+ npx mini-semantic-search pack ./potion-base-8M ./potion-base-8M-4bit.bin --bits 4
129
131
  ```
130
132
 
131
133
  ```ts
132
- const search = await MiniSemanticSearch.create({ fields: ['title', 'text'], model: '/potion-base-8M.bin' });
134
+ const search = await MiniSemanticSearch.create({ fields: ['title', 'text'], model: '/potion-base-8M-4bit.bin' });
133
135
  ```
134
136
 
135
137
  ## Command line
@@ -137,7 +139,7 @@ const search = await MiniSemanticSearch.create({ fields: ['title', 'text'], mode
137
139
  ```sh
138
140
  npx mini-semantic-search search docs.json "how do I get a refund" --fields title,text [--k 5]
139
141
  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]
142
+ npx mini-semantic-search pack <model folder> out.bin [--bits 2|4|8|32] [--dims N]
141
143
  ```
142
144
 
143
145
  ## How well it works
@@ -147,13 +149,31 @@ test questions), with the right entry ranked first or in the top 3:
147
149
 
148
150
  | Model | Size | `boost` | First | Top 3 |
149
151
  |---|---|---|---|---|
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%** |
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% |
153
157
 
154
158
  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.
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.
157
177
 
158
178
  **What it can't do:**
159
179
  - Averaging tokens loses word order, so "can I add a shopper" and "can a shopper add me" look alike.
@@ -169,10 +189,11 @@ npm install
169
189
  npm test # builds, then runs the tests (Node 21+)
170
190
  sh scripts/smoke.sh # packs the package, installs it in an empty project and uses it from there
171
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)
172
193
  ```
173
194
 
174
195
  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`.
196
+ `node dist/cli.js pack <potion-base-8M folder> models/potion-base-8M.bin --bits 2`.
176
197
 
177
198
  CI (`.github/workflows/ci.yml`) builds every push and pull request on Node 18, 20, 22 and 24, runs the tests (22
178
199
  and 24) and the install check (all four).
@@ -185,8 +206,8 @@ repository `ShakirasHips/model2vec-js`, workflow `release.yml`, environment `npm
185
206
  ## Credits and licence
186
207
 
187
208
  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.
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.
190
211
 
191
212
  This package reimplements Model2Vec's inference and BERT's tokenizer in TypeScript; it contains no code from
192
213
  Model2Vec, BERT or MiniSearch.
package/dist/cli.js CHANGED
@@ -7,10 +7,10 @@ const HELP = `mini-semantic-search: semantic search with Model2Vec static embedd
7
7
  The documents closest in meaning to the query. docs.json is an array of objects.
8
8
  mini-semantic-search embed "<text>" [--model m.bin]
9
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).
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
12
 
13
- --model defaults to the bundled potion-base-8M (128 dimensions at 4 bits).`;
13
+ --model defaults to the bundled potion-base-8M (256 dimensions at 2 bits).`;
14
14
  const argv = process.argv.slice(2);
15
15
  const flags = new Map();
16
16
  const args = [];
@@ -41,8 +41,8 @@ async function main() {
41
41
  }
42
42
  else if (cmd === 'pack' && rest.length === 2) {
43
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');
44
+ if (bits !== 2 && bits !== 4 && bits !== 8 && bits !== 32)
45
+ throw new Error('--bits must be 2, 4, 8 or 32');
46
46
  const bytes = await packModel(rest[0], { bits, group: num('group', 64), dims: flags.has('dims') ? num('dims', 0) : undefined });
47
47
  await writeFile(rest[1], bytes);
48
48
  const m = StaticModel.fromBytes(bytes);
package/dist/format.d.ts CHANGED
@@ -4,13 +4,29 @@ export interface ModelData {
4
4
  /** tokens.length × dims values, row by row. */
5
5
  vectors: Float32Array;
6
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
+ }
7
18
  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. */
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. */
11
22
  group?: number;
12
23
  }
13
24
  export declare function fromF16(h: number): number;
14
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. */
15
31
  export declare function decodeModel(bytes: Uint8Array): ModelData;
16
- export declare function encodeModel({ tokens, dims, vectors }: ModelData, { bits, group }?: EncodeOptions): Uint8Array;
32
+ export declare function encodeModel({ tokens, dims, vectors }: ModelData, options?: EncodeOptions): Uint8Array;
package/dist/format.js CHANGED
@@ -1,10 +1,18 @@
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.
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.
6
15
  const MAGIC = 0x4d514146;
7
- const VERSION = 1;
8
16
  export function fromF16(h) {
9
17
  const e = (h >> 10) & 31, m = h & 1023, s = h & 0x8000 ? -1 : 1;
10
18
  if (e === 0)
@@ -25,23 +33,43 @@ export function toF16(x) {
25
33
  return m === 1024 ? s | ((e + 1) << 10) : s | (e << 10) | m;
26
34
  }
27
35
  const pad4 = (n) => (n + 3) & ~3;
28
- export function decodeModel(bytes) {
36
+ /** Reads a model file, leaving a version 2 model packed (as `StaticModel` keeps it). */
37
+ export function decodePacked(bytes) {
29
38
  const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
30
39
  if (bytes.byteLength < 28 || dv.getUint32(0, true) !== MAGIC)
31
40
  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}`);
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`);
36
44
  let off = 28;
37
45
  const tokens = new TextDecoder().decode(bytes.subarray(off, off + vocabLen)).split('\n');
38
46
  if (tokens.length !== n)
39
47
  throw new Error('model file is corrupt: vocabulary size');
40
48
  off += pad4(vocabLen);
41
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
+ }
42
69
  const vectors = new Float32Array(count);
43
70
  if (bits === 32) {
44
- vectors.set(new Float32Array(bytes.slice(off, off + count * 4).buffer));
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));
45
73
  }
46
74
  else if (bits === 8 || bits === 4) {
47
75
  const groups = count / group;
@@ -59,42 +87,107 @@ export function decodeModel(bytes) {
59
87
  }
60
88
  return { tokens, dims, vectors };
61
89
  }
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);
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];
74
96
  }
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);
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);
80
130
  for (let g = 0; g < groups; g++) {
81
131
  let max = 0;
82
132
  for (let i = g * group; i < (g + 1) * group; i++)
83
133
  max = Math.max(max, Math.abs(vectors[i]));
84
- const h = toF16(max / levels), scale = fromF16(h) || 1;
134
+ const h = toF16(max / 127), scale = fromF16(h) || 1;
85
135
  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
- }
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))));
93
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;
94
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;
95
188
  const out = new Uint8Array(28 + pad4(vocab.length) + body.length);
96
189
  const dv = new DataView(out.buffer);
97
- [MAGIC, VERSION, n, dims, bits, group, vocab.length].forEach((v, i) => dv.setUint32(i * 4, v, true));
190
+ [MAGIC, version, n, dims, bits, group, vocab.length].forEach((v, i) => dv.setUint32(i * 4, v, true));
98
191
  out.set(vocab, 28);
99
192
  out.set(body, 28 + pad4(vocab.length));
100
193
  return out;
package/dist/load.d.ts CHANGED
@@ -3,7 +3,7 @@ export type Source = string | URL | ArrayBuffer | ArrayBufferView;
3
3
  type FileReader = (path: string | URL) => Promise<Uint8Array>;
4
4
  /** @internal Used by the Node entry point. */
5
5
  export declare function setFileReader(reader: FileReader): void;
6
- /** The bundled model: potion-base-8M's first 128 dimensions, packed at 4 bits. */
6
+ /** The bundled model: potion-base-8M, all 256 dimensions packed at 2 bits. */
7
7
  export declare const DEFAULT_MODEL: URL;
8
8
  export declare function loadBytes(src: Source): Promise<Uint8Array>;
9
9
  export {};
package/dist/load.js CHANGED
@@ -5,8 +5,8 @@ let readFile = null;
5
5
  export function setFileReader(reader) {
6
6
  readFile = reader;
7
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);
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
10
  export async function loadBytes(src) {
11
11
  if (src instanceof ArrayBuffer)
12
12
  return new Uint8Array(src);
package/dist/model.d.ts CHANGED
@@ -1,24 +1,30 @@
1
- import { type ModelData } from './format.js';
1
+ import { type ModelData, type PackedModelData } from './format.js';
2
2
  import { type Source } from './load.js';
3
3
  /**
4
4
  * A Model2Vec static embedding model: one vector per token, and a sentence's vector is the mean of its tokens'
5
5
  * vectors, normalised to length 1. No network runs, so embedding takes microseconds. Long texts are cut as the
6
6
  * Python library cuts them: to `maxTokens` × the median token length in characters, then to `maxTokens` tokens,
7
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.
8
10
  */
9
11
  export declare class StaticModel {
10
12
  readonly tokens: string[];
11
13
  readonly dims: number;
12
- readonly vectors: Float32Array;
13
14
  readonly unk: number;
14
15
  /** Tokens read per text; Model2Vec's default is 512. */
15
16
  maxTokens: number;
16
17
  private readonly vocab;
17
18
  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). */
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). */
20
24
  static load(src?: Source): Promise<StaticModel>;
21
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;
22
28
  /** Token ids, as Hugging Face's tokenizer gives them without special tokens (unknown words included as UNK). */
23
29
  tokenize(text: string): number[];
24
30
  /** Whether `token` (e.g. "voucher" or "##ing") is in the vocabulary. */
package/dist/model.js CHANGED
@@ -1,4 +1,4 @@
1
- import { decodeModel } from './format.js';
1
+ import { decodePacked, unpack } from './format.js';
2
2
  import { DEFAULT_MODEL, loadBytes } from './load.js';
3
3
  import { wordPiece } from './tokenizer.js';
4
4
  /**
@@ -6,35 +6,71 @@ import { wordPiece } from './tokenizer.js';
6
6
  * vectors, normalised to length 1. No network runs, so embedding takes microseconds. Long texts are cut as the
7
7
  * Python library cuts them: to `maxTokens` × the median token length in characters, then to `maxTokens` tokens,
8
8
  * before unknown tokens are left out.
9
+ *
10
+ * A packed model (2 or 4 bits) stays packed in memory: about 2 MB for the bundled one instead of 30 MB.
9
11
  */
10
12
  export class StaticModel {
11
13
  tokens;
12
14
  dims;
13
- vectors;
14
15
  unk;
15
16
  /** Tokens read per text; Model2Vec's default is 512. */
16
17
  maxTokens = 512;
17
18
  vocab;
18
19
  medianTokenLength;
19
- constructor({ tokens, dims, vectors }) {
20
+ longestPiece;
21
+ table;
22
+ packed;
23
+ constructor(data) {
24
+ const { tokens, dims } = data;
20
25
  this.tokens = tokens;
21
26
  this.dims = dims;
22
- this.vectors = vectors;
23
27
  this.vocab = new Map(tokens.map((t, i) => [t, i]));
24
28
  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);
29
+ // Token lengths in characters, counted (they are short), for the median and the longest word piece.
30
+ const counts = [];
31
+ let longest = 0;
32
+ for (const t of tokens) {
33
+ const len = t.length === 1 || !/[\uD800-\uDFFF]/.test(t) ? t.length : Array.from(t).length;
34
+ counts[len] = (counts[len] ?? 0) + 1;
35
+ longest = Math.max(longest, len);
36
+ }
37
+ const nth = (k) => {
38
+ for (let len = 0, seen = 0;; len++)
39
+ if ((seen += counts[len] ?? 0) > k)
40
+ return len;
41
+ };
42
+ const mid = tokens.length >> 1;
43
+ this.medianTokenLength = Math.trunc(tokens.length % 2 ? nth(mid) : (nth(mid - 1) + nth(mid)) / 2);
44
+ this.longestPiece = longest;
45
+ if ('vectors' in data) {
46
+ this.table = data.vectors;
47
+ this.packed = null;
48
+ }
49
+ else {
50
+ // Each code byte holds `perByte` values; `lookup` gives their levels for every possible byte.
51
+ const perByte = 8 / data.bits, mask = (1 << data.bits) - 1;
52
+ const lookup = new Float32Array(256 * perByte);
53
+ for (let b = 0; b < 256; b++)
54
+ for (let j = 0; j < perByte; j++)
55
+ lookup[b * perByte + j] = data.levels[(b >> (j * data.bits)) & mask];
56
+ this.table = null;
57
+ this.packed = { ...data, lookup, perByte, rowBytes: (dims * data.bits) / 8 };
58
+ }
27
59
  }
28
- /** Loads a packed model file; by default the bundled potion-base-8M (128 dimensions at 4 bits, 2.2 MB). */
60
+ /** Loads a model file; by default the bundled potion-base-8M (256 dimensions at 2 bits, 2.2 MB). */
29
61
  static async load(src = DEFAULT_MODEL) {
30
62
  return StaticModel.fromBytes(await loadBytes(src));
31
63
  }
32
64
  static fromBytes(bytes) {
33
- return new StaticModel(decodeModel(bytes));
65
+ return new StaticModel(decodePacked(bytes));
66
+ }
67
+ /** The table of token vectors, tokens.length × dims, row by row (unpacked on first use for a packed model). */
68
+ get vectors() {
69
+ return (this.table ??= unpack(this.packed).vectors);
34
70
  }
35
71
  /** Token ids, as Hugging Face's tokenizer gives them without special tokens (unknown words included as UNK). */
36
72
  tokenize(text) {
37
- return wordPiece(this.vocab, this.unk, text);
73
+ return wordPiece(this.vocab, this.unk, text, this.longestPiece);
38
74
  }
39
75
  /** Whether `token` (e.g. "voucher" or "##ing") is in the vocabulary. */
40
76
  has(token) {
@@ -42,16 +78,49 @@ export class StaticModel {
42
78
  }
43
79
  /** The text's vector: unit length, or all zeros if it has no known tokens. */
44
80
  embed(text) {
45
- const { dims, vectors } = this;
81
+ const { dims, packed } = this;
46
82
  const v = new Float32Array(dims);
47
83
  const chars = this.maxTokens * this.medianTokenLength;
48
84
  if (text.length > chars)
49
85
  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];
86
+ const ids = this.tokenize(text);
87
+ const end = Math.min(ids.length, this.maxTokens);
88
+ if (packed) {
89
+ const { codes, rowScales, lookup, perByte, rowBytes, colScales } = packed;
90
+ for (let t = 0; t < end; t++) {
91
+ const id = ids[t];
92
+ if (id === this.unk)
93
+ continue;
94
+ const r = rowScales[id];
95
+ if (perByte === 4) {
96
+ for (let p = id * rowBytes, d = 0; d < dims; p++, d += 4) {
97
+ const o = codes[p] << 2;
98
+ v[d] += lookup[o] * r;
99
+ v[d + 1] += lookup[o + 1] * r;
100
+ v[d + 2] += lookup[o + 2] * r;
101
+ v[d + 3] += lookup[o + 3] * r;
102
+ }
103
+ }
104
+ else {
105
+ for (let p = id * rowBytes, d = 0; d < dims; p++, d += 2) {
106
+ const o = codes[p] << 1;
107
+ v[d] += lookup[o] * r;
108
+ v[d + 1] += lookup[o + 1] * r;
109
+ }
110
+ }
111
+ }
112
+ for (let d = 0; d < dims; d++)
113
+ v[d] *= colScales[d];
114
+ }
115
+ else {
116
+ const table = this.table;
117
+ for (let t = 0; t < end; t++) {
118
+ const id = ids[t];
119
+ if (id === this.unk)
120
+ continue;
121
+ for (let d = 0, o = id * dims; d < dims; d++)
122
+ v[d] += table[o + d];
123
+ }
55
124
  }
56
125
  let norm = 0;
57
126
  for (let d = 0; d < dims; d++)
package/dist/search.d.ts CHANGED
@@ -42,6 +42,9 @@ export declare class MiniSemanticSearch<T = AnyDocument> {
42
42
  readonly model: StaticModel;
43
43
  private readonly options;
44
44
  private readonly index;
45
+ private plan;
46
+ /** The weighting of the last search that had no plan: a plan is made when one is used twice running. */
47
+ private lastKey;
45
48
  /** Loads the model (the bundled one unless `options.model` says otherwise), then makes the index. */
46
49
  static create<T = AnyDocument>(options: Options<T>): Promise<MiniSemanticSearch<T>>;
47
50
  /** For a model you have loaded already (`StaticModel.load()`); `create` loads it for you. */
@@ -50,7 +53,7 @@ export declare class MiniSemanticSearch<T = AnyDocument> {
50
53
  });
51
54
  /** How many documents are indexed. */
52
55
  get documentCount(): number;
53
- /** Adds a document; it can be searched straight away. Embedding takes about 0.1 ms. */
56
+ /** Adds a document; it can be searched straight away. Embedding takes about 0.05 ms. */
54
57
  add(document: T): void;
55
58
  addAll(documents: readonly T[]): void;
56
59
  /** Adds documents `chunkSize` at a time, yielding between chunks so a page stays responsive. */
@@ -71,5 +74,11 @@ export declare class MiniSemanticSearch<T = AnyDocument> {
71
74
  getStoredFields(id: DocumentId): Record<string, unknown> | undefined;
72
75
  /** Every document, best first, with its id, score and stored fields. */
73
76
  search(query: string, searchOptions?: SearchOptions): SearchResult[];
77
+ /** The plan for this weighting: the current one, a new one if it was also the last search's, or else none. */
78
+ private planFor;
79
+ /** Adds the document's row to the plan. */
80
+ private place;
81
+ /** Takes the document's row out of the plan, moving the last row into its place. */
82
+ private unplace;
74
83
  }
75
84
  export {};
package/dist/search.js CHANGED
@@ -1,5 +1,60 @@
1
1
  import { DEFAULT_MODEL } from './load.js';
2
- import { similarity, StaticModel } from './model.js';
2
+ import { StaticModel } from './model.js';
3
+ const pack8 = (v) => {
4
+ let max = 0;
5
+ for (let i = 0; i < v.length; i++)
6
+ max = Math.max(max, Math.abs(v[i]));
7
+ const s = max / 127 || 1, q = new Int8Array(v.length);
8
+ for (let i = 0; i < v.length; i++)
9
+ q[i] = Math.round(v[i] / s);
10
+ return { q, s };
11
+ };
12
+ /** q · row `o` of m. */
13
+ const dot = (q, m, o) => {
14
+ let a = 0, b = 0, c = 0, d = 0, i = 0;
15
+ for (; i + 3 < q.length; i += 4) {
16
+ a += q[i] * m[o + i];
17
+ b += q[i + 1] * m[o + i + 1];
18
+ c += q[i + 2] * m[o + i + 2];
19
+ d += q[i + 3] * m[o + i + 3];
20
+ }
21
+ for (; i < q.length; i++)
22
+ a += q[i] * m[o + i];
23
+ return a + b + c + d;
24
+ };
25
+ const dot8 = (q, { q: v, s }) => {
26
+ let a = 0, b = 0, c = 0, d = 0, i = 0;
27
+ for (; i + 3 < q.length; i += 4) {
28
+ a += q[i] * v[i];
29
+ b += q[i + 1] * v[i + 1];
30
+ c += q[i + 2] * v[i + 2];
31
+ d += q[i + 3] * v[i + 3];
32
+ }
33
+ for (; i < q.length; i++)
34
+ a += q[i] * v[i];
35
+ return (a + b + c + d) * s;
36
+ };
37
+ /**
38
+ * A document's score as terms, each a weight times the best similarity among some vectors (a single-valued field has
39
+ * one): half the whole vector's similarity plus half the fields' similarities averaged by weight, or just the fields'
40
+ * average when only some fields count. Null if no field with a value counts.
41
+ */
42
+ function terms(doc, { use, weights, subset }) {
43
+ let weight = 0;
44
+ for (let i = 0; i < doc.fields.length; i++)
45
+ if (use[i] && doc.fields[i].length)
46
+ weight += weights[i];
47
+ if (!weight)
48
+ return null;
49
+ // The whole-document vector covers every field, so it only counts when every field does.
50
+ const whole = subset || !doc.whole ? 0 : 0.5;
51
+ const out = whole ? [[whole, [doc.whole]]] : [];
52
+ for (let i = 0; i < doc.fields.length; i++) {
53
+ if (use[i] && doc.fields[i].length)
54
+ out.push([((1 - whole) * weights[i]) / weight, doc.fields[i]]);
55
+ }
56
+ return out;
57
+ }
3
58
  const texts = (value) => {
4
59
  if (value === undefined || value === null)
5
60
  return [];
@@ -15,6 +70,9 @@ export class MiniSemanticSearch {
15
70
  model;
16
71
  options;
17
72
  index = new Map();
73
+ plan = null;
74
+ /** The weighting of the last search that had no plan: a plan is made when one is used twice running. */
75
+ lastKey = '';
18
76
  /** Loads the model (the bundled one unless `options.model` says otherwise), then makes the index. */
19
77
  static async create(options) {
20
78
  const { model = DEFAULT_MODEL } = options;
@@ -34,7 +92,7 @@ export class MiniSemanticSearch {
34
92
  get documentCount() {
35
93
  return this.index.size;
36
94
  }
37
- /** Adds a document; it can be searched straight away. Embedding takes about 0.1 ms. */
95
+ /** Adds a document; it can be searched straight away. Embedding takes about 0.05 ms. */
38
96
  add(document) {
39
97
  const { fields, storeFields, idField, extractField } = this.options;
40
98
  const id = extractField(document, idField);
@@ -44,11 +102,16 @@ export class MiniSemanticSearch {
44
102
  throw new Error(`MiniSemanticSearch: duplicate ID ${id}`);
45
103
  const values = fields.map((f) => texts(extractField(document, f)));
46
104
  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,
105
+ const doc = {
106
+ id,
107
+ fields: values.map((v) => v.map((s) => pack8(this.model.embed(s)))),
108
+ whole: all ? pack8(this.model.embed(all)) : null,
50
109
  stored: Object.fromEntries(storeFields.map((f) => [f, extractField(document, f)])),
51
- });
110
+ row: -1,
111
+ };
112
+ this.index.set(id, doc);
113
+ if (this.plan)
114
+ this.place(this.plan, doc);
52
115
  }
53
116
  addAll(documents) {
54
117
  for (const d of documents)
@@ -67,8 +130,11 @@ export class MiniSemanticSearch {
67
130
  }
68
131
  /** Removes the document with this id. */
69
132
  discard(id) {
70
- if (!this.index.delete(id))
133
+ const doc = this.index.get(id);
134
+ if (!doc)
71
135
  throw new Error(`MiniSemanticSearch: cannot discard document with ID ${id}: it is not in the index`);
136
+ this.unplace(doc);
137
+ this.index.delete(id);
72
138
  }
73
139
  discardAll(ids) {
74
140
  for (const id of ids)
@@ -76,8 +142,10 @@ export class MiniSemanticSearch {
76
142
  }
77
143
  /** Removes the given documents, or every document if none are given. */
78
144
  removeAll(documents) {
79
- if (documents === undefined)
145
+ if (documents === undefined) {
80
146
  this.index.clear();
147
+ this.plan = null;
148
+ }
81
149
  else
82
150
  for (const d of documents)
83
151
  this.remove(d);
@@ -99,31 +167,104 @@ export class MiniSemanticSearch {
99
167
  const { fields: only, boost = {}, filter, minScore = -Infinity } = { ...this.options.searchOptions, ...searchOptions };
100
168
  const { fields } = this.options;
101
169
  const use = fields.map((f) => !only || only.includes(f));
102
- const subset = use.some((u) => !u);
170
+ const weights = fields.map((f, i) => (use[i] ? boost[f] ?? 1 : 0));
171
+ const weighting = { key: JSON.stringify([use, weights]), use, weights, subset: use.some((u) => !u) };
103
172
  const q = this.model.embed(query);
104
173
  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);
174
+ const emit = (doc, score) => {
121
175
  if (score < minScore)
122
- continue;
123
- const result = { id, score, ...doc.stored }; // stored fields come last, as in MiniSearch
176
+ return;
177
+ const result = { id: doc.id, score, ...doc.stored }; // stored fields come last, as in MiniSearch
124
178
  if (!filter || filter(result))
125
179
  results.push(result);
180
+ };
181
+ const best = (items) => {
182
+ let b = -Infinity;
183
+ for (const v of items)
184
+ b = Math.max(b, dot8(q, v));
185
+ return b;
186
+ };
187
+ const plan = this.planFor(weighting);
188
+ if (plan) {
189
+ const { rows, docs, multi } = plan;
190
+ for (let r = 0, o = 0; r < docs.length; r++, o += q.length) {
191
+ let score = dot(q, rows, o);
192
+ const m = multi[r];
193
+ if (m)
194
+ for (const [w, items] of m)
195
+ score += w * best(items);
196
+ emit(docs[r], score);
197
+ }
198
+ }
199
+ else {
200
+ for (const doc of this.index.values()) {
201
+ const t = terms(doc, weighting);
202
+ if (!t)
203
+ continue;
204
+ let score = 0;
205
+ for (const [w, items] of t)
206
+ score += w * best(items);
207
+ emit(doc, score);
208
+ }
126
209
  }
127
210
  return results.sort((a, b) => b.score - a.score);
128
211
  }
212
+ /** The plan for this weighting: the current one, a new one if it was also the last search's, or else none. */
213
+ planFor(weighting) {
214
+ if (this.plan?.key === weighting.key)
215
+ return this.plan;
216
+ if (this.lastKey !== weighting.key) {
217
+ this.lastKey = weighting.key;
218
+ return null;
219
+ }
220
+ const plan = { ...weighting, rows: new Float32Array(Math.max(this.index.size, 16) * this.model.dims), docs: [], multi: [] };
221
+ this.plan = plan;
222
+ for (const doc of this.index.values())
223
+ this.place(plan, doc);
224
+ return plan;
225
+ }
226
+ /** Adds the document's row to the plan. */
227
+ place(plan, doc) {
228
+ doc.row = -1;
229
+ const t = terms(doc, plan);
230
+ if (!t)
231
+ return;
232
+ const dims = this.model.dims, n = plan.docs.length;
233
+ if ((n + 1) * dims > plan.rows.length) {
234
+ const grown = new Float32Array(plan.rows.length * 2);
235
+ grown.set(plan.rows);
236
+ plan.rows = grown;
237
+ }
238
+ const row = plan.rows.subarray(n * dims, (n + 1) * dims).fill(0);
239
+ let multi = null;
240
+ for (const [w, items] of t) {
241
+ if (items.length > 1) {
242
+ (multi ??= []).push([w, items]);
243
+ continue;
244
+ }
245
+ const { q, s } = items[0], k = s * w;
246
+ for (let i = 0; i < dims; i++)
247
+ row[i] += q[i] * k;
248
+ }
249
+ plan.docs.push(doc);
250
+ plan.multi.push(multi);
251
+ doc.row = n;
252
+ }
253
+ /** Takes the document's row out of the plan, moving the last row into its place. */
254
+ unplace(doc) {
255
+ const plan = this.plan;
256
+ if (!plan || doc.row < 0)
257
+ return;
258
+ const dims = this.model.dims, last = plan.docs.length - 1;
259
+ if (doc.row !== last) {
260
+ plan.rows.copyWithin(doc.row * dims, last * dims, (last + 1) * dims);
261
+ const moved = plan.docs[last];
262
+ plan.docs[doc.row] = moved;
263
+ plan.multi[doc.row] = plan.multi[last];
264
+ moved.row = doc.row;
265
+ }
266
+ plan.docs.pop();
267
+ plan.multi.pop();
268
+ doc.row = -1;
269
+ }
129
270
  }
@@ -2,5 +2,8 @@
2
2
  export declare function normalize(text: string): string;
3
3
  /** Splits normalised text into words and single punctuation characters. */
4
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[];
5
+ /**
6
+ * Token ids for text: each word as the longest known pieces, left to right, or `unk` if it can't be spelt.
7
+ * `longest` (the vocabulary's longest token, in characters) only saves looking up pieces too long to be known.
8
+ */
9
+ export declare function wordPiece(vocab: Map<string, number>, unk: number, text: string, longest?: number): number[];
package/dist/tokenizer.js CHANGED
@@ -2,14 +2,18 @@
2
2
  // BertPreTokenizer, WordPiece with "##" continuations). Checked token for token against it in the tests.
3
3
  const isPunct = (c) => {
4
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);
5
+ if (cp < 128)
6
+ return (cp >= 33 && cp <= 47) || (cp >= 58 && cp <= 64) || (cp >= 91 && cp <= 96) || (cp >= 123 && cp <= 126);
7
+ return /\p{P}/u.test(c);
7
8
  };
8
9
  const isCjk = (cp) => (cp >= 0x4e00 && cp <= 0x9fff) || (cp >= 0x3400 && cp <= 0x4dbf)
9
10
  || (cp >= 0x20000 && cp <= 0x2a6df) || (cp >= 0x2a700 && cp <= 0x2b73f) || (cp >= 0x2b740 && cp <= 0x2b81f)
10
11
  || (cp >= 0x2b820 && cp <= 0x2ceaf) || (cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0x2f800 && cp <= 0x2fa1f);
11
12
  /** Drops control characters, spaces out CJK characters, strips accents and lower-cases. */
12
13
  export function normalize(text) {
14
+ // Printable ASCII, tabs and newlines: nothing to drop, space out or strip.
15
+ if (/^[\t\n\r\x20-\x7e]*$/.test(text))
16
+ return text.replace(/[\t\n\r]/g, ' ').toLowerCase();
13
17
  let out = '';
14
18
  for (const c of text) {
15
19
  const cp = c.codePointAt(0);
@@ -44,8 +48,11 @@ export function preTokenize(text) {
44
48
  }
45
49
  return words;
46
50
  }
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) {
51
+ /**
52
+ * Token ids for text: each word as the longest known pieces, left to right, or `unk` if it can't be spelt.
53
+ * `longest` (the vocabulary's longest token, in characters) only saves looking up pieces too long to be known.
54
+ */
55
+ export function wordPiece(vocab, unk, text, longest = Infinity) {
49
56
  const ids = [];
50
57
  for (const w of preTokenize(normalize(text))) {
51
58
  const chars = Array.from(w);
@@ -53,12 +60,13 @@ export function wordPiece(vocab, unk, text) {
53
60
  ids.push(unk);
54
61
  continue;
55
62
  }
63
+ const piece = chars.length === w.length ? (s, e) => w.slice(s, e) : (s, e) => chars.slice(s, e).join('');
56
64
  const pieces = [];
57
65
  for (let start = 0; start < chars.length;) {
58
- let end = chars.length;
66
+ let end = Math.min(chars.length, start + longest);
59
67
  let id;
60
68
  for (; end > start; end--) {
61
- id = vocab.get((start ? '##' : '') + chars.slice(start, end).join(''));
69
+ id = vocab.get(start ? '##' + piece(start, end) : piece(start, end));
62
70
  if (id !== undefined)
63
71
  break;
64
72
  }
@@ -70,7 +78,8 @@ export function wordPiece(vocab, unk, text) {
70
78
  pieces.push(id);
71
79
  start = end;
72
80
  }
73
- ids.push(...pieces);
81
+ for (const id of pieces)
82
+ ids.push(id);
74
83
  }
75
84
  return ids;
76
85
  }
package/models/README.md CHANGED
@@ -2,12 +2,13 @@
2
2
 
3
3
  This file is not covered by the package's MIT licence for its code; it keeps its source's terms.
4
4
 
5
- ## potion-base-8M-128d.bin
5
+ ## potion-base-8M.bin
6
6
 
7
7
  [minishlab/potion-base-8M](https://huggingface.co/minishlab/potion-base-8M) by MinishLab, a
8
8
  [Model2Vec](https://github.com/MinishLab/model2vec) model distilled from
9
9
  [BAAI/bge-base-en-v1.5](https://huggingface.co/BAAI/bge-base-en-v1.5), released under the MIT licence. Its
10
10
  vocabulary is bge-base-en-v1.5's (BERT's, uncased).
11
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.
12
+ Modified: repacked by `mini-semantic-search pack` at 2 bits per value (each value one of four fitted levels, times
13
+ an f32 scale for its dimension and an f16 scale for its token), 2.2 MB instead of 30.5 MB. All 29,528 tokens and all
14
+ 256 dimensions are kept.
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mini-semantic-search",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
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
5
  "keywords": [
6
6
  "semantic-search",
Binary file