mini-semantic-search 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/model.js ADDED
@@ -0,0 +1,143 @@
1
+ import { decodePacked, unpack } from './format.js';
2
+ import { DEFAULT_MODEL, loadBytes } from './load.js';
3
+ import { wordPiece } from './tokenizer.js';
4
+ /**
5
+ * A Model2Vec static embedding model: one vector per token, and a sentence's vector is the mean of its tokens'
6
+ * vectors, normalised to length 1. No network runs, so embedding takes microseconds. Long texts are cut as the
7
+ * Python library cuts them: to `maxTokens` × the median token length in characters, then to `maxTokens` tokens,
8
+ * before unknown tokens are left out.
9
+ *
10
+ * A packed model (2 or 4 bits) stays packed in memory: about 2 MB for the bundled one instead of 30 MB.
11
+ */
12
+ export class StaticModel {
13
+ tokens;
14
+ dims;
15
+ unk;
16
+ /** Tokens read per text; Model2Vec's default is 512. */
17
+ maxTokens = 512;
18
+ vocab;
19
+ medianTokenLength;
20
+ longestPiece;
21
+ table;
22
+ packed;
23
+ constructor(data) {
24
+ const { tokens, dims } = data;
25
+ this.tokens = tokens;
26
+ this.dims = dims;
27
+ this.vocab = new Map(tokens.map((t, i) => [t, i]));
28
+ this.unk = this.vocab.get('[UNK]') ?? -1;
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
+ }
59
+ }
60
+ /** Loads a model file; by default the bundled potion-base-8M (256 dimensions at 2 bits, 2.2 MB). */
61
+ static async load(src = DEFAULT_MODEL) {
62
+ return StaticModel.fromBytes(await loadBytes(src));
63
+ }
64
+ static fromBytes(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);
70
+ }
71
+ /** Token ids, as Hugging Face's tokenizer gives them without special tokens (unknown words included as UNK). */
72
+ tokenize(text) {
73
+ return wordPiece(this.vocab, this.unk, text, this.longestPiece);
74
+ }
75
+ /** Whether `token` (e.g. "voucher" or "##ing") is in the vocabulary. */
76
+ has(token) {
77
+ return this.vocab.has(token);
78
+ }
79
+ /** The text's vector: unit length, or all zeros if it has no known tokens. */
80
+ embed(text) {
81
+ const { dims, packed } = this;
82
+ const v = new Float32Array(dims);
83
+ const chars = this.maxTokens * this.medianTokenLength;
84
+ if (text.length > chars)
85
+ text = Array.from(text).slice(0, chars).join('');
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
+ }
124
+ }
125
+ let norm = 0;
126
+ for (let d = 0; d < dims; d++)
127
+ norm += v[d] * v[d];
128
+ norm = Math.sqrt(norm) || 1;
129
+ for (let d = 0; d < dims; d++)
130
+ v[d] /= norm;
131
+ return v;
132
+ }
133
+ embedMany(texts) {
134
+ return texts.map((t) => this.embed(t));
135
+ }
136
+ }
137
+ /** Dot product: the cosine similarity of two unit vectors, such as `embed` returns (1 = same meaning). */
138
+ export function similarity(a, b) {
139
+ let s = 0;
140
+ for (let i = 0; i < a.length; i++)
141
+ s += a[i] * b[i];
142
+ return s;
143
+ }
package/dist/node.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ import { MiniSemanticSearch } from './index.js';
2
+ export default MiniSemanticSearch;
3
+ export * from './index.js';
4
+ export { packModel, type PackOptions } from './pack.js';
package/dist/node.js ADDED
@@ -0,0 +1,8 @@
1
+ // The Node entry point: everything in the browser one, plus loading from local paths and packing models.
2
+ import { readFile } from 'node:fs/promises';
3
+ import { setFileReader } from './load.js';
4
+ import { MiniSemanticSearch } from './index.js';
5
+ setFileReader(async (path) => new Uint8Array(await readFile(path)));
6
+ export default MiniSemanticSearch;
7
+ export * from './index.js';
8
+ export { packModel } from './pack.js';
package/dist/pack.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ import { type EncodeOptions } from './format.js';
2
+ export interface PackOptions extends EncodeOptions {
3
+ /** Keep only the leading dimensions (Model2Vec's are PCA components, so they hold the most variance). */
4
+ dims?: number;
5
+ }
6
+ /**
7
+ * Packs a Model2Vec model folder (model.safetensors and tokenizer.json, as on Hugging Face, e.g.
8
+ * minishlab/potion-base-8M) into the file `StaticModel.load` reads.
9
+ */
10
+ export declare function packModel(dir: string, { dims, ...encode }?: PackOptions): Promise<Uint8Array>;
package/dist/pack.js ADDED
@@ -0,0 +1,37 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { encodeModel } from './format.js';
4
+ /**
5
+ * Packs a Model2Vec model folder (model.safetensors and tokenizer.json, as on Hugging Face, e.g.
6
+ * minishlab/potion-base-8M) into the file `StaticModel.load` reads.
7
+ */
8
+ export async function packModel(dir, { dims, ...encode } = {}) {
9
+ const st = await readFile(join(dir, 'model.safetensors'));
10
+ const headerLen = Number(st.readBigUInt64LE(0));
11
+ const header = JSON.parse(st.subarray(8, 8 + headerLen).toString());
12
+ const others = Object.keys(header).filter((k) => k !== 'embeddings' && k !== '__metadata__');
13
+ if (others.length)
14
+ throw new Error(`unsupported tensors: ${others.join(', ')} (token weights or mappings)`);
15
+ const info = header.embeddings;
16
+ if (!info || info.dtype !== 'F32')
17
+ throw new Error('expected an F32 "embeddings" tensor');
18
+ const [n, full] = info.shape;
19
+ const keep = Math.min(dims ?? full, full);
20
+ const data = st.subarray(8 + headerLen + info.data_offsets[0], 8 + headerLen + info.data_offsets[1]);
21
+ const src = new Float32Array(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength));
22
+ const tok = JSON.parse(await readFile(join(dir, 'tokenizer.json'), 'utf8'));
23
+ if (tok.model?.type !== 'WordPiece' || tok.normalizer?.type !== 'BertNormalizer' || tok.normalizer.lowercase === false) {
24
+ throw new Error('only lower-casing BERT (WordPiece) tokenizers are supported');
25
+ }
26
+ const tokens = [];
27
+ for (const [t, id] of Object.entries(tok.model.vocab))
28
+ tokens[id] = t;
29
+ for (const a of tok.added_tokens ?? [])
30
+ tokens[a.id] = a.content;
31
+ if (tokens.length !== n || tokens.includes(undefined))
32
+ throw new Error('vocabulary does not match the table');
33
+ const vectors = new Float32Array(n * keep);
34
+ for (let i = 0; i < n; i++)
35
+ vectors.set(src.subarray(i * full, i * full + keep), i * keep);
36
+ return encodeModel({ tokens, dims: keep, vectors }, encode);
37
+ }
@@ -0,0 +1,84 @@
1
+ import { type Source } from './load.js';
2
+ import { StaticModel } from './model.js';
3
+ type AnyDocument = any;
4
+ type DocumentId = any;
5
+ export interface SearchOptions {
6
+ /** Only these fields count (default: all of `fields`). */
7
+ fields?: string[];
8
+ /** Weights of fields against each other, e.g. `{ title: 2 }` (default 1 each). */
9
+ boost?: Record<string, number>;
10
+ /** Only results for which this is true. */
11
+ filter?: (result: SearchResult) => boolean;
12
+ /** Leave out results scoring under this (default: none). */
13
+ minScore?: number;
14
+ }
15
+ export interface Options<T = AnyDocument> {
16
+ /** The fields embedded and searched. */
17
+ fields: string[];
18
+ /** The fields returned with each result (default: none). */
19
+ storeFields?: string[];
20
+ /** The field holding each document's unique id (default "id"). */
21
+ idField?: string;
22
+ /** Reads a field from a document (default: `document[fieldName]`). Arrays are embedded item by item. */
23
+ extractField?: (document: T, fieldName: string) => unknown;
24
+ /** Defaults for `search`. */
25
+ searchOptions?: SearchOptions;
26
+ /** A loaded model, or where to load one from in `create` (default: the bundled potion-base-8M). */
27
+ model?: StaticModel | Source;
28
+ }
29
+ export type SearchResult = {
30
+ id: DocumentId;
31
+ /** About −0.1 to 1: how close in meaning the document is to the query. */
32
+ score: number;
33
+ [storedField: string]: unknown;
34
+ };
35
+ /**
36
+ * Semantic search with MiniSearch's API: documents are found by meaning rather than shared words, so "stop sending me
37
+ * newsletters" finds "How do I unsubscribe from your emails?". Each document is embedded once per field and once
38
+ * whole. It scores half its whole vector's cosine similarity to the query, plus half its fields' (weighted by
39
+ * `boost`; an array field counts its closest item). Searching takes no network and no server.
40
+ */
41
+ export declare class MiniSemanticSearch<T = AnyDocument> {
42
+ readonly model: StaticModel;
43
+ private readonly options;
44
+ private readonly index;
45
+ 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;
48
+ /** Loads the model (the bundled one unless `options.model` says otherwise), then makes the index. */
49
+ static create<T = AnyDocument>(options: Options<T>): Promise<MiniSemanticSearch<T>>;
50
+ /** For a model you have loaded already (`StaticModel.load()`); `create` loads it for you. */
51
+ constructor(options: Options<T> & {
52
+ model: StaticModel;
53
+ });
54
+ /** How many documents are indexed. */
55
+ get documentCount(): number;
56
+ /** Adds a document; it can be searched straight away. Embedding takes about 0.05 ms. */
57
+ add(document: T): void;
58
+ addAll(documents: readonly T[]): void;
59
+ /** Adds documents `chunkSize` at a time, yielding between chunks so a page stays responsive. */
60
+ addAllAsync(documents: readonly T[], { chunkSize }?: {
61
+ chunkSize?: number;
62
+ }): Promise<void>;
63
+ /** Removes a document, found by its id. */
64
+ remove(document: T): void;
65
+ /** Removes the document with this id. */
66
+ discard(id: DocumentId): void;
67
+ discardAll(ids: readonly DocumentId[]): void;
68
+ /** Removes the given documents, or every document if none are given. */
69
+ removeAll(documents?: readonly T[]): void;
70
+ /** Replaces the document that has the same id. */
71
+ replace(document: T): void;
72
+ has(id: DocumentId): boolean;
73
+ /** The document's `storeFields`, or undefined if it isn't indexed. */
74
+ getStoredFields(id: DocumentId): Record<string, unknown> | undefined;
75
+ /** Every document, best first, with its id, score and stored fields. */
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;
83
+ }
84
+ export {};
package/dist/search.js ADDED
@@ -0,0 +1,270 @@
1
+ import { DEFAULT_MODEL } from './load.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
+ }
58
+ const texts = (value) => {
59
+ if (value === undefined || value === null)
60
+ return [];
61
+ return (Array.isArray(value) ? value : [value]).map(String).filter((s) => s.trim());
62
+ };
63
+ /**
64
+ * Semantic search with MiniSearch's API: documents are found by meaning rather than shared words, so "stop sending me
65
+ * newsletters" finds "How do I unsubscribe from your emails?". Each document is embedded once per field and once
66
+ * whole. It scores half its whole vector's cosine similarity to the query, plus half its fields' (weighted by
67
+ * `boost`; an array field counts its closest item). Searching takes no network and no server.
68
+ */
69
+ export class MiniSemanticSearch {
70
+ model;
71
+ options;
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 = '';
76
+ /** Loads the model (the bundled one unless `options.model` says otherwise), then makes the index. */
77
+ static async create(options) {
78
+ const { model = DEFAULT_MODEL } = options;
79
+ return new MiniSemanticSearch({ ...options, model: model instanceof StaticModel ? model : await StaticModel.load(model) });
80
+ }
81
+ /** For a model you have loaded already (`StaticModel.load()`); `create` loads it for you. */
82
+ constructor(options) {
83
+ const { model, fields, storeFields = [], idField = 'id', extractField = (d, f) => d[f], searchOptions = {} } = options;
84
+ if (!(model instanceof StaticModel))
85
+ throw new Error('MiniSemanticSearch: the constructor needs a loaded model; use MiniSemanticSearch.create to load one');
86
+ if (!fields?.length)
87
+ throw new Error('MiniSemanticSearch: option "fields" must list at least one field');
88
+ this.model = model;
89
+ this.options = { fields, storeFields, idField, extractField, searchOptions };
90
+ }
91
+ /** How many documents are indexed. */
92
+ get documentCount() {
93
+ return this.index.size;
94
+ }
95
+ /** Adds a document; it can be searched straight away. Embedding takes about 0.05 ms. */
96
+ add(document) {
97
+ const { fields, storeFields, idField, extractField } = this.options;
98
+ const id = extractField(document, idField);
99
+ if (id === undefined)
100
+ throw new Error(`MiniSemanticSearch: document does not have ID field "${idField}"`);
101
+ if (this.index.has(id))
102
+ throw new Error(`MiniSemanticSearch: duplicate ID ${id}`);
103
+ const values = fields.map((f) => texts(extractField(document, f)));
104
+ const all = values.flat().join(' ');
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,
109
+ stored: Object.fromEntries(storeFields.map((f) => [f, extractField(document, f)])),
110
+ row: -1,
111
+ };
112
+ this.index.set(id, doc);
113
+ if (this.plan)
114
+ this.place(this.plan, doc);
115
+ }
116
+ addAll(documents) {
117
+ for (const d of documents)
118
+ this.add(d);
119
+ }
120
+ /** Adds documents `chunkSize` at a time, yielding between chunks so a page stays responsive. */
121
+ async addAllAsync(documents, { chunkSize = 100 } = {}) {
122
+ for (let i = 0; i < documents.length; i += chunkSize) {
123
+ this.addAll(documents.slice(i, i + chunkSize));
124
+ await new Promise((resolve) => setTimeout(resolve, 0));
125
+ }
126
+ }
127
+ /** Removes a document, found by its id. */
128
+ remove(document) {
129
+ this.discard(this.options.extractField(document, this.options.idField));
130
+ }
131
+ /** Removes the document with this id. */
132
+ discard(id) {
133
+ const doc = this.index.get(id);
134
+ if (!doc)
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);
138
+ }
139
+ discardAll(ids) {
140
+ for (const id of ids)
141
+ this.discard(id);
142
+ }
143
+ /** Removes the given documents, or every document if none are given. */
144
+ removeAll(documents) {
145
+ if (documents === undefined) {
146
+ this.index.clear();
147
+ this.plan = null;
148
+ }
149
+ else
150
+ for (const d of documents)
151
+ this.remove(d);
152
+ }
153
+ /** Replaces the document that has the same id. */
154
+ replace(document) {
155
+ this.remove(document);
156
+ this.add(document);
157
+ }
158
+ has(id) {
159
+ return this.index.has(id);
160
+ }
161
+ /** The document's `storeFields`, or undefined if it isn't indexed. */
162
+ getStoredFields(id) {
163
+ return this.index.get(id)?.stored;
164
+ }
165
+ /** Every document, best first, with its id, score and stored fields. */
166
+ search(query, searchOptions = {}) {
167
+ const { fields: only, boost = {}, filter, minScore = -Infinity } = { ...this.options.searchOptions, ...searchOptions };
168
+ const { fields } = this.options;
169
+ const use = fields.map((f) => !only || only.includes(f));
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) };
172
+ const q = this.model.embed(query);
173
+ const results = [];
174
+ const emit = (doc, score) => {
175
+ if (score < minScore)
176
+ return;
177
+ const result = { id: doc.id, score, ...doc.stored }; // stored fields come last, as in MiniSearch
178
+ if (!filter || filter(result))
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
+ }
209
+ }
210
+ return results.sort((a, b) => b.score - a.score);
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
+ }
270
+ }
@@ -0,0 +1,9 @@
1
+ /** Drops control characters, spaces out CJK characters, strips accents and lower-cases. */
2
+ export declare function normalize(text: string): string;
3
+ /** Splits normalised text into words and single punctuation characters. */
4
+ export declare function preTokenize(text: string): string[];
5
+ /**
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[];
@@ -0,0 +1,85 @@
1
+ // BERT's tokenizer, as Hugging Face's `tokenizers` runs it for Model2Vec models (BertNormalizer with lowercasing,
2
+ // BertPreTokenizer, WordPiece with "##" continuations). Checked token for token against it in the tests.
3
+ const isPunct = (c) => {
4
+ const cp = c.codePointAt(0);
5
+ 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);
8
+ };
9
+ const isCjk = (cp) => (cp >= 0x4e00 && cp <= 0x9fff) || (cp >= 0x3400 && cp <= 0x4dbf)
10
+ || (cp >= 0x20000 && cp <= 0x2a6df) || (cp >= 0x2a700 && cp <= 0x2b73f) || (cp >= 0x2b740 && cp <= 0x2b81f)
11
+ || (cp >= 0x2b820 && cp <= 0x2ceaf) || (cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0x2f800 && cp <= 0x2fa1f);
12
+ /** Drops control characters, spaces out CJK characters, strips accents and lower-cases. */
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();
17
+ let out = '';
18
+ for (const c of text) {
19
+ const cp = c.codePointAt(0);
20
+ if (cp === 0 || cp === 0xfffd || (/\p{C}/u.test(c) && !/[\t\n\r]/.test(c)))
21
+ continue;
22
+ if (/\s/u.test(c))
23
+ out += ' ';
24
+ else if (isCjk(cp))
25
+ out += ` ${c} `;
26
+ else
27
+ out += c;
28
+ }
29
+ return out.normalize('NFD').replace(/\p{Mn}/gu, '').toLowerCase();
30
+ }
31
+ /** Splits normalised text into words and single punctuation characters. */
32
+ export function preTokenize(text) {
33
+ const words = [];
34
+ for (const chunk of text.split(' ')) {
35
+ let word = '';
36
+ for (const c of chunk) {
37
+ if (isPunct(c)) {
38
+ if (word)
39
+ words.push(word);
40
+ words.push(c);
41
+ word = '';
42
+ }
43
+ else
44
+ word += c;
45
+ }
46
+ if (word)
47
+ words.push(word);
48
+ }
49
+ return words;
50
+ }
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) {
56
+ const ids = [];
57
+ for (const w of preTokenize(normalize(text))) {
58
+ const chars = Array.from(w);
59
+ if (chars.length > 100) {
60
+ ids.push(unk);
61
+ continue;
62
+ }
63
+ const piece = chars.length === w.length ? (s, e) => w.slice(s, e) : (s, e) => chars.slice(s, e).join('');
64
+ const pieces = [];
65
+ for (let start = 0; start < chars.length;) {
66
+ let end = Math.min(chars.length, start + longest);
67
+ let id;
68
+ for (; end > start; end--) {
69
+ id = vocab.get(start ? '##' + piece(start, end) : piece(start, end));
70
+ if (id !== undefined)
71
+ break;
72
+ }
73
+ if (end === start) {
74
+ pieces.length = 0;
75
+ pieces.push(unk);
76
+ break;
77
+ }
78
+ pieces.push(id);
79
+ start = end;
80
+ }
81
+ for (const id of pieces)
82
+ ids.push(id);
83
+ }
84
+ return ids;
85
+ }
@@ -0,0 +1,14 @@
1
+ # Bundled data
2
+
3
+ This file is not covered by the package's MIT licence for its code; it keeps its source's terms.
4
+
5
+ ## potion-base-8M.bin
6
+
7
+ [minishlab/potion-base-8M](https://huggingface.co/minishlab/potion-base-8M) by MinishLab, a
8
+ [Model2Vec](https://github.com/MinishLab/model2vec) model distilled from
9
+ [BAAI/bge-base-en-v1.5](https://huggingface.co/BAAI/bge-base-en-v1.5), released under the MIT licence. Its
10
+ vocabulary is bge-base-en-v1.5's (BERT's, uncased).
11
+
12
+ Modified: repacked by `mini-semantic-search pack` 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