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 +42 -21
- package/dist/cli.js +5 -5
- package/dist/format.d.ts +20 -4
- package/dist/format.js +131 -38
- package/dist/load.d.ts +1 -1
- package/dist/load.js +2 -2
- package/dist/model.d.ts +10 -4
- package/dist/model.js +84 -15
- package/dist/search.d.ts +10 -1
- package/dist/search.js +168 -27
- package/dist/tokenizer.d.ts +5 -2
- package/dist/tokenizer.js +16 -7
- package/models/README.md +4 -3
- package/models/potion-base-8M.bin +0 -0
- package/package.json +1 -1
- package/models/potion-base-8M-128d.bin +0 -0
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 (
|
|
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.
|
|
25
|
-
// { id: 3, score: 0.
|
|
26
|
-
// { id: 1, score: 0.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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(
|
|
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).
|
|
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] [--
|
|
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
|
|
151
|
-
| **potion-base-8M
|
|
152
|
-
|
|
|
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%.
|
|
156
|
-
|
|
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
|
|
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
|
|
189
|
-
|
|
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] [--
|
|
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 (
|
|
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
|
|
9
|
-
bits?: 32 | 8 | 4;
|
|
10
|
-
/**
|
|
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,
|
|
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,
|
|
2
|
-
// group size and the vocabulary's byte length; then the tokens joined by "\n", padded
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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 /
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
9
|
-
export const DEFAULT_MODEL = new URL('../models/potion-base-8M
|
|
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
|
-
|
|
19
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
|
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(
|
|
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,
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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.
|
|
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 {
|
|
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.
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/tokenizer.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
6
|
-
|
|
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
|
-
|
|
6
|
-
||
|
|
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
|
-
/**
|
|
48
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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`
|
|
13
|
-
|
|
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.
|
|
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
|