@4fu/arbor-search-zvec 0.0.0-stage → 2026.1107.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +134 -2
- package/embedder.d.ts +45 -0
- package/embedder.js +108 -0
- package/index.d.ts +14 -0
- package/index.js +12 -0
- package/package.json +44 -4
- package/zvec-index.d.ts +57 -0
- package/zvec-index.js +329 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 4fuu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,135 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @4fu/arbor-search-zvec
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The optional semantic search channel: text embedded and searched by
|
|
4
|
+
meaning, with [zvec](https://github.com/alibaba/zvec), an embedded vector
|
|
5
|
+
database.
|
|
6
|
+
|
|
7
|
+
`@4fu/arbor-journal-sqlite` and `@4fu/arbor-notes-sqlite` take this package as
|
|
8
|
+
an optional peer. Installed, each indexes the text it searches and fuses what it
|
|
9
|
+
finds by meaning with its lexical indexes. Absent - or when zvec's prebuilt
|
|
10
|
+
binary does not load on the platform, or when no embedding model is available -
|
|
11
|
+
search is exactly what it is without it, and nothing else changes.
|
|
12
|
+
|
|
13
|
+
## Installing it
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install @4fu/arbor-search-zvec
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
zvec is a Node-API addon published per platform as optional dependencies
|
|
20
|
+
(`@zvec/bindings-linux-x64`, `@zvec/bindings-darwin-arm64`, ...), so installing
|
|
21
|
+
it needs no compiler and no build step. Linux (x86_64, arm64), macOS (x86_64,
|
|
22
|
+
arm64) and Windows (x86_64, arm64) are supported. Its collection lives in a
|
|
23
|
+
directory of its own beside the journal's database - `<journal>.vectors` - and
|
|
24
|
+
persists through a write-ahead log exactly as the database does.
|
|
25
|
+
|
|
26
|
+
**One host per data directory.** zvec holds a `LOCK` in the collection
|
|
27
|
+
directory and refuses a second open, so one host owns one journal directory. A
|
|
28
|
+
collection another process holds is reported as no channel at all: the journal
|
|
29
|
+
warns once and searches lexically.
|
|
30
|
+
|
|
31
|
+
## The default embedding model
|
|
32
|
+
|
|
33
|
+
The default embedder runs a small bilingual retrieval model in-process through
|
|
34
|
+
[transformers.js](https://github.com/huggingface/transformers.js), which is an
|
|
35
|
+
optional peer of this package:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npm install @4fu/arbor-search-zvec @huggingface/transformers
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The model is `Xenova/bge-small-zh-v1.5` (512 dimensions, 24 MB quantized). It
|
|
42
|
+
runs locally and needs no key. Its files are fetched from the Hugging Face Hub
|
|
43
|
+
on first use and cached from then on; **nothing a host holds leaves it** - only
|
|
44
|
+
the text being embedded is handed to the model, and the fetch is of the model,
|
|
45
|
+
never of the text.
|
|
46
|
+
|
|
47
|
+
The fetch never blocks anything. It happens in the background on the first
|
|
48
|
+
append or search; until the model arrives the channel answers `ready()` with
|
|
49
|
+
`false`, an empty `search`, and holds the text it was given so it can be indexed
|
|
50
|
+
once the model is there. A fetch that fails - `huggingface.co` is unreachable
|
|
51
|
+
from mainland China, for instance - degrades to lexical search with one warning
|
|
52
|
+
naming what is missing, and is retried later rather than on every call.
|
|
53
|
+
|
|
54
|
+
### Pointing the model somewhere else
|
|
55
|
+
|
|
56
|
+
Both settings are process-global, because transformers.js keeps them on its
|
|
57
|
+
`env` singleton. There is no environment variable for either.
|
|
58
|
+
|
|
59
|
+
| Option | transformers.js setting | Default |
|
|
60
|
+
| --- | --- | --- |
|
|
61
|
+
| `endpoint` | `env.remoteHost` | `https://huggingface.co/` |
|
|
62
|
+
| `cacheDir` | `env.cacheDir` | the library's own `.cache` |
|
|
63
|
+
|
|
64
|
+
A mirror, for a network where `huggingface.co` is blocked:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
createZvecIndex({ path: "/var/lib/arbor/journal.db.vectors", endpoint: "https://hf-mirror.com" });
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
An offline install, where the model files are already on disk, names the
|
|
71
|
+
directory that holds them. transformers.js reads a model id or a local path the
|
|
72
|
+
same way, so nothing else changes:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
createZvecIndex({ path: "/var/lib/arbor/journal.db.vectors", model: "/opt/models/bge-small-zh-v1.5" });
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`dtype` (default `q8`) and `pooling` (default `cls`) choose the build and the
|
|
79
|
+
pooling. `retryAfterMs` (default 300000) is how long a failed fetch waits before
|
|
80
|
+
it is tried again, and `warn` (default `console.warn`) is where the one warning
|
|
81
|
+
goes.
|
|
82
|
+
|
|
83
|
+
## Bringing your own embedder
|
|
84
|
+
|
|
85
|
+
A deployment replaces the model by implementing `Embedder` and passing it. This
|
|
86
|
+
is also the only way text reaches anything outside the host, so it is opt-in by
|
|
87
|
+
construction:
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import { createZvecIndex } from "@4fu/arbor-search-zvec";
|
|
91
|
+
import type { Embedder } from "@4fu/arbor-interfaces";
|
|
92
|
+
|
|
93
|
+
const embed: Embedder = {
|
|
94
|
+
dimension: 1024,
|
|
95
|
+
async embed(texts) {
|
|
96
|
+
// A remote model, a different local one, anything at all.
|
|
97
|
+
return callMyModel(texts);
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const index = createZvecIndex({ path: "/var/lib/arbor/journal.db.vectors", embed });
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
With `embed` given, no model is fetched and transformers.js is never loaded.
|
|
105
|
+
|
|
106
|
+
## What a store does with it
|
|
107
|
+
|
|
108
|
+
- the journal indexes each event's text when the event is appended, and the note
|
|
109
|
+
store indexes each note's title, summary and body as one text when its
|
|
110
|
+
revision is written;
|
|
111
|
+
- `catchUpSemantic()` on either store indexes what the channel does not hold -
|
|
112
|
+
after a restart over an existing database, after the model arrives, and after
|
|
113
|
+
an indexing attempt that failed;
|
|
114
|
+
- a redaction erases the event's vector with its content, and a note's vector is
|
|
115
|
+
erased when the note is deleted and replaced when its revision is replaced;
|
|
116
|
+
- the channel's results merge with the lexical ones through the shared
|
|
117
|
+
`mergeHits`: exact substring hits first, then word hits, then the hits only
|
|
118
|
+
the semantic channel found. A hit no lexical index found is reported with
|
|
119
|
+
`match: "semantic"`. Each store applies its own filters to what the channel
|
|
120
|
+
returns, so a channel that filters less than the store does still answers the
|
|
121
|
+
read it was asked.
|
|
122
|
+
|
|
123
|
+
See [docs/journal.md#search](../../docs/journal.md#search) and
|
|
124
|
+
[docs/memory.md#search](../../docs/memory.md#search).
|
|
125
|
+
|
|
126
|
+
## Tests
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
pnpm --filter @4fu/arbor-search-zvec test
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The tests that need zvec's prebuilt binary, and the ones that need the default
|
|
133
|
+
embedding model, skip with a message naming what is missing. Running them all
|
|
134
|
+
needs `@huggingface/transformers` installed and network access to the model's
|
|
135
|
+
host; the rest need only the platform binding.
|
package/embedder.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The default embedder: a small bilingual retrieval model that runs in-process
|
|
3
|
+
* through transformers.js and needs no key (docs/journal.md#search).
|
|
4
|
+
*
|
|
5
|
+
* The model files are fetched from the Hugging Face Hub on first use and cached
|
|
6
|
+
* from then on. Nothing a host holds leaves it: only the text being embedded is
|
|
7
|
+
* handed to the model, and the fetch is of the model, never of the text. A
|
|
8
|
+
* deployment that must not fetch anything names a local directory with `model`,
|
|
9
|
+
* or passes its own embed function - the only way text reaches anything outside
|
|
10
|
+
* the host.
|
|
11
|
+
*
|
|
12
|
+
* transformers.js reads its model source from `env`, a process-global singleton,
|
|
13
|
+
* so `cacheDir` and `endpoint` are written once here and apply to every pipeline
|
|
14
|
+
* in the process. Neither has an environment variable: the setting is
|
|
15
|
+
* `env.remoteHost`, which is what `endpoint` writes, and `env.cacheDir`, which
|
|
16
|
+
* is what `cacheDir` writes.
|
|
17
|
+
*/
|
|
18
|
+
import type { Embedder } from "@4fu/arbor-interfaces";
|
|
19
|
+
/** The model the channel embeds with when the caller names none. */
|
|
20
|
+
export declare const DEFAULT_MODEL = "Xenova/bge-small-zh-v1.5";
|
|
21
|
+
export interface LocalEmbedderOptions {
|
|
22
|
+
/** A Hugging Face repo id, or a path to a directory that holds a model. */
|
|
23
|
+
model?: string;
|
|
24
|
+
/** Where fetched model files are cached. */
|
|
25
|
+
cacheDir?: string;
|
|
26
|
+
/** Where models are fetched from, for a mirror. Defaults to huggingface.co. */
|
|
27
|
+
endpoint?: string;
|
|
28
|
+
/** The build to load. Defaults to `q8`. */
|
|
29
|
+
dtype?: string;
|
|
30
|
+
/** How the token vectors become one vector. Defaults to `cls`. */
|
|
31
|
+
pooling?: string;
|
|
32
|
+
}
|
|
33
|
+
/** The default embedder, which has to load its model before it can embed. */
|
|
34
|
+
export interface LocalEmbedder extends Embedder {
|
|
35
|
+
/** Load the model. Resolves once the channel can embed, rejects when it cannot. */
|
|
36
|
+
load(): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
/** Whether transformers.js is installed, without loading it. */
|
|
39
|
+
export declare function transformersInstalled(): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* The default embedder, lazy: nothing is loaded and nothing is fetched until
|
|
42
|
+
* `load` is called, so constructing a semantic channel costs nothing and a
|
|
43
|
+
* journal that never searches never downloads a model.
|
|
44
|
+
*/
|
|
45
|
+
export declare function localEmbedder(options?: LocalEmbedderOptions): LocalEmbedder;
|
package/embedder.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The default embedder: a small bilingual retrieval model that runs in-process
|
|
3
|
+
* through transformers.js and needs no key (docs/journal.md#search).
|
|
4
|
+
*
|
|
5
|
+
* The model files are fetched from the Hugging Face Hub on first use and cached
|
|
6
|
+
* from then on. Nothing a host holds leaves it: only the text being embedded is
|
|
7
|
+
* handed to the model, and the fetch is of the model, never of the text. A
|
|
8
|
+
* deployment that must not fetch anything names a local directory with `model`,
|
|
9
|
+
* or passes its own embed function - the only way text reaches anything outside
|
|
10
|
+
* the host.
|
|
11
|
+
*
|
|
12
|
+
* transformers.js reads its model source from `env`, a process-global singleton,
|
|
13
|
+
* so `cacheDir` and `endpoint` are written once here and apply to every pipeline
|
|
14
|
+
* in the process. Neither has an environment variable: the setting is
|
|
15
|
+
* `env.remoteHost`, which is what `endpoint` writes, and `env.cacheDir`, which
|
|
16
|
+
* is what `cacheDir` writes.
|
|
17
|
+
*/
|
|
18
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
19
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
20
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
21
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
return path;
|
|
25
|
+
};
|
|
26
|
+
import { createRequire } from "node:module";
|
|
27
|
+
/** The package that runs the model in-process. */
|
|
28
|
+
const TRANSFORMERS_PACKAGE = "@huggingface/transformers";
|
|
29
|
+
/** The model the channel embeds with when the caller names none. */
|
|
30
|
+
export const DEFAULT_MODEL = "Xenova/bge-small-zh-v1.5";
|
|
31
|
+
/** The quantized build of the default model: 24 MB instead of 94 MB. */
|
|
32
|
+
const DEFAULT_DTYPE = "q8";
|
|
33
|
+
/** The pooling the default model was trained with. */
|
|
34
|
+
const DEFAULT_POOLING = "cls";
|
|
35
|
+
/** One text, embedded only to learn the model's dimension. */
|
|
36
|
+
const PROBE = "arbor";
|
|
37
|
+
/** Whether transformers.js is installed, without loading it. */
|
|
38
|
+
export function transformersInstalled() {
|
|
39
|
+
try {
|
|
40
|
+
createRequire(import.meta.url).resolve(TRANSFORMERS_PACKAGE);
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The default embedder, lazy: nothing is loaded and nothing is fetched until
|
|
49
|
+
* `load` is called, so constructing a semantic channel costs nothing and a
|
|
50
|
+
* journal that never searches never downloads a model.
|
|
51
|
+
*/
|
|
52
|
+
export function localEmbedder(options = {}) {
|
|
53
|
+
const model = options.model ?? DEFAULT_MODEL;
|
|
54
|
+
const pooling = options.pooling ?? DEFAULT_POOLING;
|
|
55
|
+
let extractor = null;
|
|
56
|
+
let dimension = 0;
|
|
57
|
+
const load = async () => {
|
|
58
|
+
const loaded = await import(__rewriteRelativeImportExtension(TRANSFORMERS_PACKAGE));
|
|
59
|
+
const pipeline = loaded.pipeline;
|
|
60
|
+
if (typeof pipeline !== "function") {
|
|
61
|
+
throw new Error(`${TRANSFORMERS_PACKAGE} exports no pipeline`);
|
|
62
|
+
}
|
|
63
|
+
const env = loaded.env;
|
|
64
|
+
if (env !== undefined && typeof env === "object") {
|
|
65
|
+
if (options.cacheDir !== undefined)
|
|
66
|
+
env.cacheDir = options.cacheDir;
|
|
67
|
+
// A mirror, for a network where huggingface.co is unreachable.
|
|
68
|
+
if (options.endpoint !== undefined)
|
|
69
|
+
env.remoteHost = options.endpoint;
|
|
70
|
+
}
|
|
71
|
+
extractor = (await pipeline("feature-extraction", model, {
|
|
72
|
+
dtype: options.dtype ?? DEFAULT_DTYPE,
|
|
73
|
+
}));
|
|
74
|
+
// One short text, so the dimension is known before anything is stored: the
|
|
75
|
+
// vector store is created with it.
|
|
76
|
+
const [probe] = await embedWith(extractor, [PROBE], pooling);
|
|
77
|
+
if (probe === undefined || probe.length === 0) {
|
|
78
|
+
throw new Error(`${model} returned no embedding dimension`);
|
|
79
|
+
}
|
|
80
|
+
dimension = probe.length;
|
|
81
|
+
};
|
|
82
|
+
return {
|
|
83
|
+
get dimension() {
|
|
84
|
+
return dimension;
|
|
85
|
+
},
|
|
86
|
+
load,
|
|
87
|
+
async embed(texts) {
|
|
88
|
+
if (extractor === null)
|
|
89
|
+
await load();
|
|
90
|
+
if (extractor === null)
|
|
91
|
+
return [];
|
|
92
|
+
return embedWith(extractor, texts, pooling);
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
async function embedWith(extractor, texts, pooling) {
|
|
97
|
+
if (texts.length === 0)
|
|
98
|
+
return [];
|
|
99
|
+
const output = await extractor(texts, { pooling, normalize: true });
|
|
100
|
+
const width = output.dims[output.dims.length - 1] ?? 0;
|
|
101
|
+
if (width <= 0)
|
|
102
|
+
throw new Error("the embedding model returned no dimension");
|
|
103
|
+
const vectors = [];
|
|
104
|
+
for (let index = 0; index < texts.length; index += 1) {
|
|
105
|
+
vectors.push(Array.from(output.data.slice(index * width, (index + 1) * width)));
|
|
106
|
+
}
|
|
107
|
+
return vectors;
|
|
108
|
+
}
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @4fu/arbor-search-zvec - the optional semantic search channel.
|
|
3
|
+
*
|
|
4
|
+
* `@4fu/arbor-journal-sqlite` and `@4fu/arbor-notes-sqlite` take this package as
|
|
5
|
+
* an optional peer: installed, text is embedded and searched by meaning beside
|
|
6
|
+
* the lexical indexes; absent, or when zvec's native binary does not load on the
|
|
7
|
+
* platform, or when no embedding model is available, search is exactly what it
|
|
8
|
+
* is without it (docs/journal.md#search, docs/memory.md#search). See README.md
|
|
9
|
+
* for what a deployment configures.
|
|
10
|
+
*/
|
|
11
|
+
export { createZvecIndex, filterExpression } from "./zvec-index.ts";
|
|
12
|
+
export type { ZvecIndexOptions } from "./zvec-index.ts";
|
|
13
|
+
export { localEmbedder, transformersInstalled, DEFAULT_MODEL } from "./embedder.ts";
|
|
14
|
+
export type { LocalEmbedder, LocalEmbedderOptions } from "./embedder.ts";
|
package/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @4fu/arbor-search-zvec - the optional semantic search channel.
|
|
3
|
+
*
|
|
4
|
+
* `@4fu/arbor-journal-sqlite` and `@4fu/arbor-notes-sqlite` take this package as
|
|
5
|
+
* an optional peer: installed, text is embedded and searched by meaning beside
|
|
6
|
+
* the lexical indexes; absent, or when zvec's native binary does not load on the
|
|
7
|
+
* platform, or when no embedding model is available, search is exactly what it
|
|
8
|
+
* is without it (docs/journal.md#search, docs/memory.md#search). See README.md
|
|
9
|
+
* for what a deployment configures.
|
|
10
|
+
*/
|
|
11
|
+
export { createZvecIndex, filterExpression } from "./zvec-index.js";
|
|
12
|
+
export { localEmbedder, transformersInstalled, DEFAULT_MODEL } from "./embedder.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@4fu/arbor-search-zvec",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "2026.1107.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "The optional semantic search channel: event text embedded and searched by meaning with zvec, an embedded vector database.",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./index.d.ts",
|
|
9
|
+
"default": "./index.js"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"types": "./index.d.ts",
|
|
13
|
+
"main": "./index.js",
|
|
14
|
+
"files": [
|
|
15
|
+
"embedder.d.ts",
|
|
16
|
+
"embedder.js",
|
|
17
|
+
"index.d.ts",
|
|
18
|
+
"index.js",
|
|
19
|
+
"zvec-index.d.ts",
|
|
20
|
+
"zvec-index.js"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=24"
|
|
24
|
+
},
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/4fuu/arbor.git",
|
|
29
|
+
"directory": "packages/search-zvec"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@4fu/arbor-interfaces": "2026.1107.1",
|
|
36
|
+
"@zvec/zvec": "^0.7.0"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@huggingface/transformers": "^4.3.2"
|
|
40
|
+
},
|
|
41
|
+
"peerDependenciesMeta": {
|
|
42
|
+
"@huggingface/transformers": {
|
|
43
|
+
"optional": true
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
package/zvec-index.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The semantic channel over zvec (docs/journal.md#search, docs/memory.md#search):
|
|
3
|
+
* text embedded and searched by meaning, beside a store's lexical indexes.
|
|
4
|
+
*
|
|
5
|
+
* zvec is an embedded vector database: a Node-API addon, published per platform
|
|
6
|
+
* as optional dependencies, so installing it needs no compiler and no server.
|
|
7
|
+
* A collection is a directory of its own beside the store's database, written
|
|
8
|
+
* through a write-ahead log, so it survives a restart exactly as the database
|
|
9
|
+
* does. Three limits shape this file:
|
|
10
|
+
*
|
|
11
|
+
* - **One writer per collection.** zvec holds a `LOCK` in the collection
|
|
12
|
+
* directory and refuses a second open, so one host owns one data directory.
|
|
13
|
+
* A collection another process holds is reported as no channel at all, and
|
|
14
|
+
* search stays lexical.
|
|
15
|
+
* - **The dimension is the store's, and it is fixed at creation.** The default
|
|
16
|
+
* model's dimension is only known once the model is loaded, so the collection
|
|
17
|
+
* is created on the first successful embed rather than at construction.
|
|
18
|
+
* - **A filter needs the data it filters on.** `kind` and `window` are stored as
|
|
19
|
+
* scalar fields beside each vector, so a filtered search is answered by the
|
|
20
|
+
* store rather than by discarding candidates afterwards.
|
|
21
|
+
*
|
|
22
|
+
* The ref a store hands over is its own - an event ref for the journal, a note
|
|
23
|
+
* id for the note store - and the channel keeps one vector per ref, replacing
|
|
24
|
+
* it when the store hands the same ref over again. The journal indexes an
|
|
25
|
+
* event's body; the note store indexes the title, the summary and the body of
|
|
26
|
+
* the note's current revision as one text.
|
|
27
|
+
*/
|
|
28
|
+
import type { Embedder, SemanticFilters, SemanticIndex } from "@4fu/arbor-interfaces";
|
|
29
|
+
import type { LocalEmbedderOptions } from "./embedder.ts";
|
|
30
|
+
/**
|
|
31
|
+
* The filters a semantic search applies, as a zvec filter expression. Targets
|
|
32
|
+
* and an external id are not fields of this collection, so the journal applies
|
|
33
|
+
* those to what this returns. Neither is a note's status: a note's status
|
|
34
|
+
* changes at every snapshot epoch without a revision, so the note store applies
|
|
35
|
+
* that one to what this returns as well (docs/memory.md#search).
|
|
36
|
+
*/
|
|
37
|
+
export declare function filterExpression(filters: SemanticFilters): string | undefined;
|
|
38
|
+
export interface ZvecIndexOptions extends LocalEmbedderOptions {
|
|
39
|
+
/** Where the collection lives. A store passes `<its own path>.vectors`. */
|
|
40
|
+
path: string;
|
|
41
|
+
/** The embedder. Omitted, the default local model is used. */
|
|
42
|
+
embed?: Embedder;
|
|
43
|
+
/** Where a warning goes. Defaults to `console.warn`. */
|
|
44
|
+
warn?: (message: string) => void;
|
|
45
|
+
/** How long a failed model load waits before it is tried again. */
|
|
46
|
+
retryAfterMs?: number;
|
|
47
|
+
/** The clock the retry window reads. Tests pass a fixed one. */
|
|
48
|
+
now?: () => number;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Open the semantic channel, or answer `null` when there is none: no embedding
|
|
52
|
+
* model is available, or the collection is held by another host.
|
|
53
|
+
*
|
|
54
|
+
* Throws when zvec itself will not load on this platform, which is the caller's
|
|
55
|
+
* signal that the channel is absent.
|
|
56
|
+
*/
|
|
57
|
+
export declare function createZvecIndex(options: ZvecIndexOptions): SemanticIndex | null;
|
package/zvec-index.js
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The semantic channel over zvec (docs/journal.md#search, docs/memory.md#search):
|
|
3
|
+
* text embedded and searched by meaning, beside a store's lexical indexes.
|
|
4
|
+
*
|
|
5
|
+
* zvec is an embedded vector database: a Node-API addon, published per platform
|
|
6
|
+
* as optional dependencies, so installing it needs no compiler and no server.
|
|
7
|
+
* A collection is a directory of its own beside the store's database, written
|
|
8
|
+
* through a write-ahead log, so it survives a restart exactly as the database
|
|
9
|
+
* does. Three limits shape this file:
|
|
10
|
+
*
|
|
11
|
+
* - **One writer per collection.** zvec holds a `LOCK` in the collection
|
|
12
|
+
* directory and refuses a second open, so one host owns one data directory.
|
|
13
|
+
* A collection another process holds is reported as no channel at all, and
|
|
14
|
+
* search stays lexical.
|
|
15
|
+
* - **The dimension is the store's, and it is fixed at creation.** The default
|
|
16
|
+
* model's dimension is only known once the model is loaded, so the collection
|
|
17
|
+
* is created on the first successful embed rather than at construction.
|
|
18
|
+
* - **A filter needs the data it filters on.** `kind` and `window` are stored as
|
|
19
|
+
* scalar fields beside each vector, so a filtered search is answered by the
|
|
20
|
+
* store rather than by discarding candidates afterwards.
|
|
21
|
+
*
|
|
22
|
+
* The ref a store hands over is its own - an event ref for the journal, a note
|
|
23
|
+
* id for the note store - and the channel keeps one vector per ref, replacing
|
|
24
|
+
* it when the store hands the same ref over again. The journal indexes an
|
|
25
|
+
* event's body; the note store indexes the title, the summary and the body of
|
|
26
|
+
* the note's current revision as one text.
|
|
27
|
+
*/
|
|
28
|
+
import { statSync } from "node:fs";
|
|
29
|
+
import { ZVecCollectionSchema, ZVecCreateAndOpen, ZVecDataType, ZVecOpen } from "@zvec/zvec";
|
|
30
|
+
import { localEmbedder, transformersInstalled } from "./embedder.js";
|
|
31
|
+
/** The collection's name, which is also the directory zvec writes beside it. */
|
|
32
|
+
const COLLECTION = "events";
|
|
33
|
+
/** The one vector field, and the scalar fields a filter reads. */
|
|
34
|
+
const VECTOR_FIELD = "embedding";
|
|
35
|
+
/**
|
|
36
|
+
* The store's own order, which is the ref's number and only grows: an event's
|
|
37
|
+
* `seq` for the journal, a note's id for the note store. It is what an
|
|
38
|
+
* `afterSeq`/`beforeSeq` filter reads, and it orders the collection for a
|
|
39
|
+
* reader that wants the newest first.
|
|
40
|
+
*/
|
|
41
|
+
const SEQ_FIELD = "seq";
|
|
42
|
+
const KIND_FIELD = "kind";
|
|
43
|
+
const WINDOW_FIELD = "window";
|
|
44
|
+
/** zvec refuses a write batch larger than this. */
|
|
45
|
+
const MAX_WRITE_BATCH = 1024;
|
|
46
|
+
/** How many texts one embedding call takes. */
|
|
47
|
+
const EMBED_BATCH = 32;
|
|
48
|
+
/** How long a failed model load waits before it is tried again. */
|
|
49
|
+
const RETRY_AFTER_MS = 300_000;
|
|
50
|
+
/** How many items wait in memory while the model loads. */
|
|
51
|
+
const MAX_PENDING = 5_000;
|
|
52
|
+
/** One value in a filter expression: a quoted string, or a bare number. */
|
|
53
|
+
function quote(value) {
|
|
54
|
+
return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The filters a semantic search applies, as a zvec filter expression. Targets
|
|
58
|
+
* and an external id are not fields of this collection, so the journal applies
|
|
59
|
+
* those to what this returns. Neither is a note's status: a note's status
|
|
60
|
+
* changes at every snapshot epoch without a revision, so the note store applies
|
|
61
|
+
* that one to what this returns as well (docs/memory.md#search).
|
|
62
|
+
*/
|
|
63
|
+
export function filterExpression(filters) {
|
|
64
|
+
const clauses = [];
|
|
65
|
+
if (filters.afterSeq !== undefined)
|
|
66
|
+
clauses.push(`${SEQ_FIELD} > ${filters.afterSeq}`);
|
|
67
|
+
if (filters.beforeSeq !== undefined)
|
|
68
|
+
clauses.push(`${SEQ_FIELD} < ${filters.beforeSeq}`);
|
|
69
|
+
const kinds = filters.kinds ?? [];
|
|
70
|
+
if (kinds.length === 1) {
|
|
71
|
+
clauses.push(`${KIND_FIELD} = ${quote(kinds[0])}`);
|
|
72
|
+
}
|
|
73
|
+
else if (kinds.length > 1) {
|
|
74
|
+
clauses.push(`${KIND_FIELD} IN (${kinds.map(quote).join(", ")})`);
|
|
75
|
+
}
|
|
76
|
+
const window = filters.window;
|
|
77
|
+
if (window !== undefined && window !== null)
|
|
78
|
+
clauses.push(`${WINDOW_FIELD} = ${quote(window)}`);
|
|
79
|
+
return clauses.length === 0 ? undefined : clauses.join(" AND ");
|
|
80
|
+
}
|
|
81
|
+
/** A directory that is already there. */
|
|
82
|
+
function exists(path) {
|
|
83
|
+
try {
|
|
84
|
+
statSync(path);
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function messageOf(error) {
|
|
92
|
+
return error instanceof Error ? error.message : String(error);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Open the semantic channel, or answer `null` when there is none: no embedding
|
|
96
|
+
* model is available, or the collection is held by another host.
|
|
97
|
+
*
|
|
98
|
+
* Throws when zvec itself will not load on this platform, which is the caller's
|
|
99
|
+
* signal that the channel is absent.
|
|
100
|
+
*/
|
|
101
|
+
export function createZvecIndex(options) {
|
|
102
|
+
const embedder = options.embed ?? (transformersInstalled() ? localEmbedder(options) : null);
|
|
103
|
+
if (embedder === null)
|
|
104
|
+
return null;
|
|
105
|
+
if (!exists(options.path))
|
|
106
|
+
return new ZvecSemanticIndex(options, embedder, null);
|
|
107
|
+
try {
|
|
108
|
+
return new ZvecSemanticIndex(options, embedder, ZVecOpen(options.path));
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
// One writer per collection: another host owns this data directory.
|
|
112
|
+
options.warn?.(`zvec: the collection at ${options.path} is held by another host; search stays lexical (${messageOf(error)})`);
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
class ZvecSemanticIndex {
|
|
117
|
+
path;
|
|
118
|
+
embedder;
|
|
119
|
+
loadModel;
|
|
120
|
+
warn;
|
|
121
|
+
retryAfterMs;
|
|
122
|
+
now;
|
|
123
|
+
collection;
|
|
124
|
+
pending = new Map();
|
|
125
|
+
loading = null;
|
|
126
|
+
loaded = false;
|
|
127
|
+
failedAt = null;
|
|
128
|
+
warned = new Set();
|
|
129
|
+
constructor(options, embedder, collection) {
|
|
130
|
+
this.path = options.path;
|
|
131
|
+
this.embedder = embedder;
|
|
132
|
+
this.loadModel = embedder.load;
|
|
133
|
+
this.warn = options.warn ?? ((message) => console.warn(message));
|
|
134
|
+
this.retryAfterMs = options.retryAfterMs ?? RETRY_AFTER_MS;
|
|
135
|
+
this.now = options.now ?? (() => Date.now());
|
|
136
|
+
this.collection = collection;
|
|
137
|
+
}
|
|
138
|
+
async add(ref, text, fields) {
|
|
139
|
+
this.kick();
|
|
140
|
+
if (!this.loaded) {
|
|
141
|
+
// The model has not arrived: the item stays lexical, and the text waits
|
|
142
|
+
// here, bounded, until the channel is ready.
|
|
143
|
+
if (this.pending.size < MAX_PENDING)
|
|
144
|
+
this.pending.set(ref, { text, fields });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
await this.guard("add", () => this.store([[ref, text, fields]]));
|
|
148
|
+
}
|
|
149
|
+
async remove(ref) {
|
|
150
|
+
this.pending.delete(ref);
|
|
151
|
+
if (this.collection === null)
|
|
152
|
+
return;
|
|
153
|
+
await this.guard("remove", () => {
|
|
154
|
+
this.collection?.deleteSync(ref);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
async search(query, limit, filters) {
|
|
158
|
+
this.kick();
|
|
159
|
+
if (!this.loaded || this.collection === null)
|
|
160
|
+
return [];
|
|
161
|
+
const [vector] = (await this.embed([query])) ?? [];
|
|
162
|
+
if (vector === undefined || vector.length === 0)
|
|
163
|
+
return [];
|
|
164
|
+
const filter = filterExpression(filters);
|
|
165
|
+
const hits = await this.guard("search", () => this.collection?.querySync({
|
|
166
|
+
fieldName: VECTOR_FIELD,
|
|
167
|
+
vector,
|
|
168
|
+
topk: Math.max(1, limit),
|
|
169
|
+
includeVector: false,
|
|
170
|
+
...(filter === undefined ? {} : { filter }),
|
|
171
|
+
}));
|
|
172
|
+
return (hits ?? []).map((hit) => hit.id);
|
|
173
|
+
}
|
|
174
|
+
async ready() {
|
|
175
|
+
// A channel that is only asked about still loads its model: a host that
|
|
176
|
+
// restarts over an existing store calls this before it searches.
|
|
177
|
+
this.kick();
|
|
178
|
+
return this.loaded;
|
|
179
|
+
}
|
|
180
|
+
async held() {
|
|
181
|
+
if (this.collection === null)
|
|
182
|
+
return new Set();
|
|
183
|
+
const held = new Set();
|
|
184
|
+
for (const doc of this.collection.iterDocsSync({ outputFields: [], includeVector: false })) {
|
|
185
|
+
held.add(doc.id);
|
|
186
|
+
}
|
|
187
|
+
return held;
|
|
188
|
+
}
|
|
189
|
+
async close() {
|
|
190
|
+
this.pending.clear();
|
|
191
|
+
const collection = this.collection;
|
|
192
|
+
if (collection === null)
|
|
193
|
+
return;
|
|
194
|
+
this.collection = null;
|
|
195
|
+
this.loaded = false;
|
|
196
|
+
try {
|
|
197
|
+
collection.closeSync();
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
this.warnOnce("close", `zvec: closing the collection at ${this.path} failed (${messageOf(error)})`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// --- internals ---------------------------------------------------------
|
|
204
|
+
/** Embed texts, and turn a refusal into nothing rather than a failed search. */
|
|
205
|
+
async embed(texts) {
|
|
206
|
+
return this.guard("embed", () => this.embedder.embed(texts));
|
|
207
|
+
}
|
|
208
|
+
/** One warning per kind of failure, so a broken channel is not a log flood. */
|
|
209
|
+
warnOnce(key, message) {
|
|
210
|
+
if (this.warned.has(key))
|
|
211
|
+
return;
|
|
212
|
+
this.warned.add(key);
|
|
213
|
+
this.warn(message);
|
|
214
|
+
}
|
|
215
|
+
/** Run one step, and turn a refusal into the channel's own answer. */
|
|
216
|
+
async guard(key, step) {
|
|
217
|
+
try {
|
|
218
|
+
return await step();
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
this.warnOnce(key, `zvec: the semantic channel refused (${messageOf(error)}); search stays lexical`);
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Start loading the model unless something already is, and unless a load that
|
|
227
|
+
* failed is still inside its retry window. Never awaited by a caller, so a
|
|
228
|
+
* fetch is never on the path of an append or a search.
|
|
229
|
+
*/
|
|
230
|
+
kick() {
|
|
231
|
+
if (this.loaded || this.loading !== null)
|
|
232
|
+
return;
|
|
233
|
+
if (this.failedAt !== null && this.now() - this.failedAt < this.retryAfterMs)
|
|
234
|
+
return;
|
|
235
|
+
this.loading = this.load().finally(() => {
|
|
236
|
+
this.loading = null;
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
async load() {
|
|
240
|
+
try {
|
|
241
|
+
if (this.loadModel !== undefined)
|
|
242
|
+
await this.loadModel();
|
|
243
|
+
await this.open();
|
|
244
|
+
// Index what arrived while the model was loading, and anything that
|
|
245
|
+
// arrived while that was happening: `add` holds its text until the
|
|
246
|
+
// channel is ready, so draining until nothing is left is exact.
|
|
247
|
+
while (this.pending.size > 0)
|
|
248
|
+
await this.flush();
|
|
249
|
+
this.loaded = true;
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
this.loaded = false;
|
|
253
|
+
this.failedAt = this.now();
|
|
254
|
+
// One warning per attempt, and the attempt is not retried until the
|
|
255
|
+
// window passes: an unreachable mirror costs one line, not one per call.
|
|
256
|
+
this.warn(`zvec: the embedding model is not available (${messageOf(error)}); search stays lexical and the model is retried in ${Math.round(this.retryAfterMs / 1000)}s`);
|
|
257
|
+
// The text that arrived while the model was being fetched stays here: the
|
|
258
|
+
// next attempt indexes it, and a catch-up indexes whatever the bound let
|
|
259
|
+
// go of.
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/** Open an existing collection, or create one now that the dimension is known. */
|
|
263
|
+
async open() {
|
|
264
|
+
if (this.collection !== null)
|
|
265
|
+
return;
|
|
266
|
+
const dimension = this.embedder.dimension;
|
|
267
|
+
if (dimension <= 0)
|
|
268
|
+
throw new Error("the embedder reported no dimension");
|
|
269
|
+
this.collection = ZVecCreateAndOpen(this.path, new ZVecCollectionSchema({
|
|
270
|
+
name: COLLECTION,
|
|
271
|
+
vectors: { name: VECTOR_FIELD, dataType: ZVecDataType.VECTOR_FP32, dimension },
|
|
272
|
+
fields: [
|
|
273
|
+
{ name: SEQ_FIELD, dataType: ZVecDataType.INT64 },
|
|
274
|
+
{ name: KIND_FIELD, dataType: ZVecDataType.STRING },
|
|
275
|
+
{ name: WINDOW_FIELD, dataType: ZVecDataType.STRING },
|
|
276
|
+
],
|
|
277
|
+
}));
|
|
278
|
+
}
|
|
279
|
+
/** Index what arrived while the model was loading. */
|
|
280
|
+
async flush() {
|
|
281
|
+
if (this.pending.size === 0)
|
|
282
|
+
return;
|
|
283
|
+
const entries = [...this.pending.entries()].map(([ref, held]) => [
|
|
284
|
+
ref,
|
|
285
|
+
held.text,
|
|
286
|
+
held.fields,
|
|
287
|
+
]);
|
|
288
|
+
this.pending.clear();
|
|
289
|
+
try {
|
|
290
|
+
await this.store(entries);
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
// The store refused: put the text back so a later flush, or a catch-up,
|
|
294
|
+
// tries again.
|
|
295
|
+
this.warnOnce("add", `zvec: the semantic channel refused (${messageOf(error)}); search stays lexical`);
|
|
296
|
+
for (const [ref, text, fields] of entries)
|
|
297
|
+
this.pending.set(ref, { text, fields });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
/** Embed and store entries, in the batches the model and zvec take. */
|
|
301
|
+
async store(entries) {
|
|
302
|
+
if (entries.length === 0)
|
|
303
|
+
return;
|
|
304
|
+
for (let start = 0; start < entries.length; start += EMBED_BATCH) {
|
|
305
|
+
const batch = entries.slice(start, start + EMBED_BATCH);
|
|
306
|
+
const vectors = await this.embedder.embed(batch.map(([, text]) => text));
|
|
307
|
+
const docs = [];
|
|
308
|
+
batch.forEach(([ref, , fields], index) => {
|
|
309
|
+
const vector = vectors[index];
|
|
310
|
+
if (vector === undefined || vector.length === 0)
|
|
311
|
+
return;
|
|
312
|
+
docs.push({
|
|
313
|
+
id: ref,
|
|
314
|
+
vectors: { [VECTOR_FIELD]: vector },
|
|
315
|
+
fields: {
|
|
316
|
+
// The ref's number: the event's seq for the journal, the note's id
|
|
317
|
+
// for the note store. Both only grow, so both order the collection.
|
|
318
|
+
[SEQ_FIELD]: Number(ref.slice(1)),
|
|
319
|
+
[KIND_FIELD]: fields?.kind ?? "",
|
|
320
|
+
[WINDOW_FIELD]: fields?.window ?? "",
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
for (let offset = 0; offset < docs.length; offset += MAX_WRITE_BATCH) {
|
|
325
|
+
this.collection?.upsertSync(docs.slice(offset, offset + MAX_WRITE_BATCH));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|