@4fu/arbor-notes-sqlite 2026.1106.0 → 2026.1107.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 +14 -6
- package/index.d.ts +3 -1
- package/index.js +3 -1
- package/notes.d.ts +71 -2
- package/notes.js +298 -54
- package/package.json +17 -5
- package/schema.d.ts +8 -3
- package/schema.js +60 -10
- package/search.d.ts +49 -16
- package/search.js +109 -32
- package/semantic.d.ts +29 -0
- package/semantic.js +57 -0
package/schema.d.ts
CHANGED
|
@@ -23,12 +23,17 @@
|
|
|
23
23
|
* it froze them at, so a window's prefix never changes under it.
|
|
24
24
|
* - `notes_fts` - trigram index for exact substrings over title, summary and
|
|
25
25
|
* body.
|
|
26
|
+
* - `notes_words` - word index for BM25 over the same three columns, segmented
|
|
27
|
+
* (docs/journal.md#search).
|
|
26
28
|
*/
|
|
27
29
|
import type { DatabaseSync } from "node:sqlite";
|
|
30
|
+
import type { Segmenter } from "@4fu/arbor-interfaces";
|
|
28
31
|
/** Bumped when a change makes an existing database unusable. */
|
|
29
|
-
export declare const SCHEMA_VERSION =
|
|
32
|
+
export declare const SCHEMA_VERSION = 2;
|
|
30
33
|
/** The FTS5 table note search reads. */
|
|
31
34
|
export declare const FTS_TABLE = "notes_fts";
|
|
32
|
-
/**
|
|
33
|
-
export declare
|
|
35
|
+
/** The word index note search reads. */
|
|
36
|
+
export declare const WORD_TABLE = "notes_words";
|
|
37
|
+
/** Create the schema when it is missing, migrate an older one, and record the version. */
|
|
38
|
+
export declare function createSchema(db: DatabaseSync, segmenter?: Segmenter): void;
|
|
34
39
|
export declare function readMeta(db: DatabaseSync, key: string): string | null;
|
package/schema.js
CHANGED
|
@@ -23,11 +23,25 @@
|
|
|
23
23
|
* it froze them at, so a window's prefix never changes under it.
|
|
24
24
|
* - `notes_fts` - trigram index for exact substrings over title, summary and
|
|
25
25
|
* body.
|
|
26
|
+
* - `notes_words` - word index for BM25 over the same three columns, segmented
|
|
27
|
+
* (docs/journal.md#search).
|
|
26
28
|
*/
|
|
29
|
+
import { indexText, resolveSegmenter } from "@4fu/arbor-lexical";
|
|
27
30
|
/** Bumped when a change makes an existing database unusable. */
|
|
28
|
-
export const SCHEMA_VERSION =
|
|
31
|
+
export const SCHEMA_VERSION = 2;
|
|
29
32
|
/** The FTS5 table note search reads. */
|
|
30
33
|
export const FTS_TABLE = "notes_fts";
|
|
34
|
+
/** The word index note search reads. */
|
|
35
|
+
export const WORD_TABLE = "notes_words";
|
|
36
|
+
/** The trigram index: exact substrings, for Chinese and mixed text. */
|
|
37
|
+
const TRIGRAM_INDEX = `CREATE VIRTUAL TABLE IF NOT EXISTS ${FTS_TABLE}
|
|
38
|
+
USING fts5(title, summary, body, tokenize = 'trigram')`;
|
|
39
|
+
/**
|
|
40
|
+
* The word index: one column per searched field, each holding that field's
|
|
41
|
+
* segments, one per line. The columns are what the BM25 weights rank by.
|
|
42
|
+
*/
|
|
43
|
+
const WORD_INDEX = `CREATE VIRTUAL TABLE IF NOT EXISTS ${WORD_TABLE}
|
|
44
|
+
USING fts5(title, summary, body, tokenize = 'unicode61')`;
|
|
31
45
|
const STATEMENTS = [
|
|
32
46
|
`PRAGMA journal_mode = WAL`,
|
|
33
47
|
`PRAGMA foreign_keys = ON`,
|
|
@@ -114,25 +128,61 @@ const STATEMENTS = [
|
|
|
114
128
|
time INTEGER NOT NULL,
|
|
115
129
|
PRIMARY KEY (epoch, note)
|
|
116
130
|
)`,
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
USING fts5(title, summary, body, tokenize = 'trigram')`,
|
|
131
|
+
TRIGRAM_INDEX,
|
|
132
|
+
WORD_INDEX,
|
|
120
133
|
];
|
|
121
|
-
/**
|
|
122
|
-
|
|
134
|
+
/**
|
|
135
|
+
* Version 2 added the word index (docs/journal.md#search): a segmented word
|
|
136
|
+
* index with BM25 beside the trigram index, which is what lets a multi-word
|
|
137
|
+
* query find a note that holds the words apart from each other. Every note,
|
|
138
|
+
* every ref and the trigram index stay, so a version-1 database opens where it
|
|
139
|
+
* left off: the notes it already holds are segmented into the new index in the
|
|
140
|
+
* same step that records the version, and a delete leaves a note out of it.
|
|
141
|
+
*/
|
|
142
|
+
function migrateToVersion2(db, segmenter) {
|
|
143
|
+
// One transaction: the index and the version that says it is there land
|
|
144
|
+
// together, so a database that stops half way through is still the version it
|
|
145
|
+
// was and migrates again, whole, instead of half-indexing twice.
|
|
146
|
+
db.exec("BEGIN IMMEDIATE");
|
|
147
|
+
try {
|
|
148
|
+
db.exec(WORD_INDEX);
|
|
149
|
+
const insert = db.prepare(`INSERT INTO ${WORD_TABLE} (rowid, title, summary, body) VALUES (?, ?, ?, ?)`);
|
|
150
|
+
const rows = db
|
|
151
|
+
.prepare("SELECT id, title, summary, body FROM notes WHERE status <> 'deleted'")
|
|
152
|
+
.all();
|
|
153
|
+
for (const row of rows) {
|
|
154
|
+
insert.run(Number(row.id), indexText(row.title, segmenter), indexText(row.summary, segmenter), indexText(row.body, segmenter));
|
|
155
|
+
}
|
|
156
|
+
db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?)").run(String(SCHEMA_VERSION));
|
|
157
|
+
db.exec("COMMIT");
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
try {
|
|
161
|
+
db.exec("ROLLBACK");
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
// The transaction is already gone; the original error is what matters.
|
|
165
|
+
}
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/** Create the schema when it is missing, migrate an older one, and record the version. */
|
|
170
|
+
export function createSchema(db, segmenter = resolveSegmenter()) {
|
|
123
171
|
// The version lives in the one table that has to exist before anything else.
|
|
124
172
|
db.exec(`CREATE TABLE IF NOT EXISTS meta (
|
|
125
173
|
key TEXT PRIMARY KEY,
|
|
126
174
|
value TEXT NOT NULL
|
|
127
175
|
)`);
|
|
128
176
|
const version = readMeta(db, "schema_version");
|
|
129
|
-
if (version !== null && Number(version)
|
|
130
|
-
throw new Error(`notes schema ${version} is
|
|
177
|
+
if (version !== null && Number(version) > SCHEMA_VERSION) {
|
|
178
|
+
throw new Error(`notes schema ${version} is newer than this build (${SCHEMA_VERSION}); upgrade arbor, or migrate the database`);
|
|
131
179
|
}
|
|
180
|
+
if (Number(version) === SCHEMA_VERSION - 1)
|
|
181
|
+
migrateToVersion2(db, segmenter);
|
|
132
182
|
for (const statement of STATEMENTS)
|
|
133
183
|
db.exec(statement);
|
|
134
|
-
if (version === null) {
|
|
135
|
-
db.prepare("INSERT INTO meta (key, value) VALUES ('schema_version', ?)").run(String(SCHEMA_VERSION));
|
|
184
|
+
if (version === null || Number(version) !== SCHEMA_VERSION) {
|
|
185
|
+
db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?)").run(String(SCHEMA_VERSION));
|
|
136
186
|
}
|
|
137
187
|
}
|
|
138
188
|
export function readMeta(db, key) {
|
package/search.d.ts
CHANGED
|
@@ -1,27 +1,60 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Lexical search over notes (docs/journal.md#search, docs/memory.md).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* The channels the journal has, merged the same way, and one more when a
|
|
5
|
+
* deployment installed it:
|
|
6
|
+
*
|
|
7
|
+
* - a word index, segmented and ranked with BM25 over title, summary and body,
|
|
8
|
+
* the title and the summary weighted above the body;
|
|
9
|
+
* - a trigram index for exact substrings, which is what makes a Chinese
|
|
10
|
+
* substring findable;
|
|
11
|
+
* - a `LIKE` scan, for one- and two-character queries, which have no trigram of
|
|
12
|
+
* their own;
|
|
13
|
+
* - a semantic channel, note text embedded and searched by meaning, when
|
|
14
|
+
* `@4fu/arbor-search-zvec` is installed.
|
|
15
|
+
*
|
|
16
|
+
* Exact substring hits come first, then word hits that are not already there,
|
|
17
|
+
* then the hits only the semantic channel found, each channel by its own
|
|
18
|
+
* relevance. The `LIKE` scan has no relevance of its own, so it answers in id
|
|
19
|
+
* order and a page is byte-stable however it is paged.
|
|
10
20
|
*/
|
|
11
21
|
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
|
12
|
-
|
|
13
|
-
|
|
22
|
+
import type { NoteStatus, Segmenter } from "@4fu/arbor-interfaces";
|
|
23
|
+
import { MIN_TRIGRAM_CHARS, escapeLike, likePattern, quoteFts } from "@4fu/arbor-lexical";
|
|
24
|
+
import type { Ranked } from "@4fu/arbor-lexical";
|
|
25
|
+
export { MIN_TRIGRAM_CHARS, escapeLike, likePattern, quoteFts };
|
|
26
|
+
/**
|
|
27
|
+
* The BM25 column weights both note indexes are ranked with, in the order the
|
|
28
|
+
* FTS5 tables carry their columns: title, summary, body. The title names the
|
|
29
|
+
* note and the summary is its one line, so a hit in either counts for more
|
|
30
|
+
* than one in the body: ten times as much for the title, four times for the
|
|
31
|
+
* summary.
|
|
32
|
+
*/
|
|
33
|
+
export declare const COLUMN_WEIGHTS = "10.0, 4.0, 1.0";
|
|
14
34
|
/** One condition over `notes n`, and the values it is bound to. */
|
|
15
35
|
export interface SubstringClause {
|
|
16
36
|
sql: string;
|
|
17
37
|
params: readonly SQLInputValue[];
|
|
18
38
|
}
|
|
19
|
-
/** The notes holding the query, as a condition over `notes n`. */
|
|
39
|
+
/** The notes holding the query as an exact substring, as a condition over `notes n`. */
|
|
20
40
|
export declare function substringClause(query: string): SubstringClause;
|
|
21
|
-
/**
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
41
|
+
/**
|
|
42
|
+
* The notes a text query matches, in the order search returns them: exact
|
|
43
|
+
* substring hits first, then word hits that are not already there, each channel
|
|
44
|
+
* by its own relevance, then the hits only the semantic channel found. A
|
|
45
|
+
* one- or two-character query has no trigram and no word match of its own, so
|
|
46
|
+
* the fallback scan answers it in id order, and the semantic channel's hits
|
|
47
|
+
* follow it.
|
|
48
|
+
*
|
|
49
|
+
* `semantic` is the channel's own candidates as ranks, in the order it gave
|
|
50
|
+
* them: the note store asks it for the notes nearest the query and applies its
|
|
51
|
+
* own filters to what comes back, so a channel that filters less than the store
|
|
52
|
+
* does still answers the read it was asked (docs/memory.md#search).
|
|
53
|
+
*/
|
|
54
|
+
export declare function matchedIds(db: DatabaseSync, query: string, segmenter: Segmenter, status?: NoteStatus, semantic?: readonly Ranked[]): number[];
|
|
55
|
+
/**
|
|
56
|
+
* The ids of several notes that hold the query: an exact substring or the
|
|
57
|
+
* query's words. `about` and `linked` bound a lookup to the notes the journal
|
|
58
|
+
* resolved, in their own weight order, so this answers a set and not an order.
|
|
59
|
+
*/
|
|
60
|
+
export declare function holdingIds(db: DatabaseSync, query: string, ids: readonly number[], segmenter: Segmenter): number[];
|
package/search.js
CHANGED
|
@@ -1,50 +1,127 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Lexical search over notes (docs/journal.md#search, docs/memory.md).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* The channels the journal has, merged the same way, and one more when a
|
|
5
|
+
* deployment installed it:
|
|
6
|
+
*
|
|
7
|
+
* - a word index, segmented and ranked with BM25 over title, summary and body,
|
|
8
|
+
* the title and the summary weighted above the body;
|
|
9
|
+
* - a trigram index for exact substrings, which is what makes a Chinese
|
|
10
|
+
* substring findable;
|
|
11
|
+
* - a `LIKE` scan, for one- and two-character queries, which have no trigram of
|
|
12
|
+
* their own;
|
|
13
|
+
* - a semantic channel, note text embedded and searched by meaning, when
|
|
14
|
+
* `@4fu/arbor-search-zvec` is installed.
|
|
15
|
+
*
|
|
16
|
+
* Exact substring hits come first, then word hits that are not already there,
|
|
17
|
+
* then the hits only the semantic channel found, each channel by its own
|
|
18
|
+
* relevance. The `LIKE` scan has no relevance of its own, so it answers in id
|
|
19
|
+
* order and a page is byte-stable however it is paged.
|
|
10
20
|
*/
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
export
|
|
14
|
-
/**
|
|
21
|
+
import { MIN_TRIGRAM_CHARS, escapeLike, likePattern, mergeHits, quoteFts, trigramMatch, wordMatch, } from "@4fu/arbor-lexical";
|
|
22
|
+
import { FTS_TABLE, WORD_TABLE } from "./schema.js";
|
|
23
|
+
export { MIN_TRIGRAM_CHARS, escapeLike, likePattern, quoteFts };
|
|
24
|
+
/**
|
|
25
|
+
* The BM25 column weights both note indexes are ranked with, in the order the
|
|
26
|
+
* FTS5 tables carry their columns: title, summary, body. The title names the
|
|
27
|
+
* note and the summary is its one line, so a hit in either counts for more
|
|
28
|
+
* than one in the body: ten times as much for the title, four times for the
|
|
29
|
+
* summary.
|
|
30
|
+
*/
|
|
31
|
+
export const COLUMN_WEIGHTS = "10.0, 4.0, 1.0";
|
|
32
|
+
/** The `LIKE` scan's condition, for a query the trigram index cannot answer. */
|
|
33
|
+
function likeClause(query) {
|
|
34
|
+
const pattern = likePattern(query);
|
|
35
|
+
return {
|
|
36
|
+
sql: `(n.title LIKE ? ESCAPE '\\' OR n.summary LIKE ? ESCAPE '\\' OR n.body LIKE ? ESCAPE '\\')`,
|
|
37
|
+
params: [pattern, pattern, pattern],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** The word index's condition: every one of the query's segments. */
|
|
41
|
+
function wordClause(tokens) {
|
|
42
|
+
return {
|
|
43
|
+
sql: `n.id IN (SELECT rowid FROM ${WORD_TABLE} WHERE ${WORD_TABLE} MATCH ?)`,
|
|
44
|
+
params: [wordMatch(tokens)],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** The notes holding the query as an exact substring, as a condition over `notes n`. */
|
|
15
48
|
export function substringClause(query) {
|
|
16
49
|
if (Array.from(query).length >= MIN_TRIGRAM_CHARS) {
|
|
17
50
|
return {
|
|
18
51
|
sql: `n.id IN (SELECT rowid FROM ${FTS_TABLE} WHERE ${FTS_TABLE} MATCH ?)`,
|
|
19
|
-
params: [
|
|
52
|
+
params: [trigramMatch(query)],
|
|
20
53
|
};
|
|
21
54
|
}
|
|
22
|
-
|
|
23
|
-
return {
|
|
24
|
-
sql: `(n.title LIKE ? ESCAPE '\\' OR n.summary LIKE ? ESCAPE '\\' OR n.body LIKE ? ESCAPE '\\')`,
|
|
25
|
-
params: [pattern, pattern, pattern],
|
|
26
|
-
};
|
|
55
|
+
return likeClause(query);
|
|
27
56
|
}
|
|
28
|
-
/**
|
|
29
|
-
|
|
57
|
+
/** Every match from one index, by relevance, with the status filter applied. */
|
|
58
|
+
function ranked(db, table, match, status) {
|
|
59
|
+
const filtered = status === undefined ? "" : " AND n.status = ?";
|
|
60
|
+
const params = status === undefined ? [match] : [match, status];
|
|
61
|
+
const rows = db
|
|
62
|
+
.prepare(`SELECT f.rowid AS rowid, bm25(${table}, ${COLUMN_WEIGHTS}) AS rank
|
|
63
|
+
FROM ${table} f JOIN notes n ON n.id = f.rowid
|
|
64
|
+
WHERE ${table} MATCH ?${filtered}
|
|
65
|
+
ORDER BY rank, f.rowid`)
|
|
66
|
+
.all(...params);
|
|
67
|
+
return rows.map((row) => ({ rowid: Number(row.rowid), rank: Number(row.rank) }));
|
|
68
|
+
}
|
|
69
|
+
/** The notes a one- or two-character query holds, in id order. */
|
|
70
|
+
function scanIds(db, query, status) {
|
|
71
|
+
const clause = likeClause(query);
|
|
72
|
+
// A delete leaves the row and marks it, and the scan reads the notes table
|
|
73
|
+
// itself, so it leaves the deleted ones out on its own (docs/memory.md).
|
|
74
|
+
const filtered = status === undefined ? " AND n.status <> 'deleted'" : " AND n.status = ?";
|
|
75
|
+
const rows = db
|
|
76
|
+
.prepare(`SELECT n.id AS id FROM notes n WHERE ${clause.sql}${filtered} ORDER BY n.id`)
|
|
77
|
+
.all(...clause.params, ...(status === undefined ? [] : [status]));
|
|
78
|
+
return rows.map((row) => Number(row.id));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The notes a text query matches, in the order search returns them: exact
|
|
82
|
+
* substring hits first, then word hits that are not already there, each channel
|
|
83
|
+
* by its own relevance, then the hits only the semantic channel found. A
|
|
84
|
+
* one- or two-character query has no trigram and no word match of its own, so
|
|
85
|
+
* the fallback scan answers it in id order, and the semantic channel's hits
|
|
86
|
+
* follow it.
|
|
87
|
+
*
|
|
88
|
+
* `semantic` is the channel's own candidates as ranks, in the order it gave
|
|
89
|
+
* them: the note store asks it for the notes nearest the query and applies its
|
|
90
|
+
* own filters to what comes back, so a channel that filters less than the store
|
|
91
|
+
* does still answers the read it was asked (docs/memory.md#search).
|
|
92
|
+
*/
|
|
93
|
+
export function matchedIds(db, query, segmenter, status, semantic = []) {
|
|
94
|
+
if (Array.from(query).length < MIN_TRIGRAM_CHARS) {
|
|
95
|
+
// A one- or two-character query has no trigram and no word match of its
|
|
96
|
+
// own: the scan answers it in id order, and the semantic channel's hits
|
|
97
|
+
// follow it.
|
|
98
|
+
const scanned = scanIds(db, query, status).map((rowid) => ({ rowid, rank: 0 }));
|
|
99
|
+
return mergeHits({ channel: "substring", hits: scanned }, { channel: "semantic", hits: semantic }).map((hit) => hit.rowid);
|
|
100
|
+
}
|
|
101
|
+
const substrings = ranked(db, FTS_TABLE, trigramMatch(query), status);
|
|
102
|
+
const match = wordMatch(segmenter.segment(query));
|
|
103
|
+
const words = match === "" ? [] : ranked(db, WORD_TABLE, match, status);
|
|
104
|
+
return mergeHits({ channel: "substring", hits: substrings }, { channel: "word", hits: words }, { channel: "semantic", hits: semantic }).map((hit) => hit.rowid);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The ids of several notes that hold the query: an exact substring or the
|
|
108
|
+
* query's words. `about` and `linked` bound a lookup to the notes the journal
|
|
109
|
+
* resolved, in their own weight order, so this answers a set and not an order.
|
|
110
|
+
*/
|
|
111
|
+
export function holdingIds(db, query, ids, segmenter) {
|
|
30
112
|
if (ids.length === 0)
|
|
31
113
|
return [];
|
|
32
|
-
const
|
|
114
|
+
const clauses = [substringClause(query)];
|
|
115
|
+
if (Array.from(query).length >= MIN_TRIGRAM_CHARS) {
|
|
116
|
+
const tokens = segmenter.segment(query);
|
|
117
|
+
if (wordMatch(tokens) !== "")
|
|
118
|
+
clauses.push(wordClause(tokens));
|
|
119
|
+
}
|
|
33
120
|
const rows = db
|
|
34
121
|
.prepare(`SELECT n.id AS id FROM notes n
|
|
35
|
-
WHERE
|
|
122
|
+
WHERE (${clauses.map((clause) => clause.sql).join(" OR ")})
|
|
123
|
+
AND n.id IN (${ids.map(() => "?").join(", ")})
|
|
36
124
|
ORDER BY n.id`)
|
|
37
|
-
.all(...clause.params, ...ids);
|
|
125
|
+
.all(...clauses.flatMap((clause) => clause.params), ...ids);
|
|
38
126
|
return rows.map((row) => Number(row.id));
|
|
39
127
|
}
|
|
40
|
-
/** Escape a literal for `LIKE ... ESCAPE '\'`. */
|
|
41
|
-
export function escapeLike(value) {
|
|
42
|
-
return value.replace(/[\\%_]/g, (character) => `\\${character}`);
|
|
43
|
-
}
|
|
44
|
-
export function likePattern(query) {
|
|
45
|
-
return `%${escapeLike(query)}%`;
|
|
46
|
-
}
|
|
47
|
-
/** Quote one phrase for an FTS5 `MATCH`, so outside text is never syntax. */
|
|
48
|
-
export function quoteFts(value) {
|
|
49
|
-
return `"${value.replace(/"/g, '""')}"`;
|
|
50
|
-
}
|
package/semantic.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The semantic channel the note store uses when the caller names none
|
|
3
|
+
* (docs/memory.md#search).
|
|
4
|
+
*
|
|
5
|
+
* The spec calls for note text to be embedded and searched by meaning beside the
|
|
6
|
+
* lexical indexes, and the default is zvec when `@4fu/arbor-search-zvec` is
|
|
7
|
+
* installed: `resolveSemanticIndex` loads it, and the store uses what it returns
|
|
8
|
+
* when the caller names no channel. The package is an optional peer dependency,
|
|
9
|
+
* so a deployment that leaves it out keeps working.
|
|
10
|
+
*
|
|
11
|
+
* What is left here is the absence that answers when the package is not there:
|
|
12
|
+
* no channel, and search is exactly lexical. That is also the answer when the
|
|
13
|
+
* package is installed but its vector store will not open - zvec's prebuilt
|
|
14
|
+
* binary does not load on this platform, or another host holds the collection -
|
|
15
|
+
* and when no embedding model is available, because the package says so by
|
|
16
|
+
* handing back nothing.
|
|
17
|
+
*/
|
|
18
|
+
import type { SemanticIndex } from "@4fu/arbor-interfaces";
|
|
19
|
+
/**
|
|
20
|
+
* The semantic channel a note store uses when the caller names none: the zvec
|
|
21
|
+
* one when the package is installed and its store opens, and none otherwise.
|
|
22
|
+
*
|
|
23
|
+
* `load` replaces how the zvec package is loaded. A host that resolves modules
|
|
24
|
+
* its own way passes its own; so does a test that has no zvec package to load. A
|
|
25
|
+
* load that fails falls back rather than stopping the store, because the package
|
|
26
|
+
* is optional: it may be missing, or its prebuilt binary may not load on this
|
|
27
|
+
* platform.
|
|
28
|
+
*/
|
|
29
|
+
export declare function resolveSemanticIndex(path: string, load?: (path: string) => SemanticIndex | null): SemanticIndex | null;
|
package/semantic.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The semantic channel the note store uses when the caller names none
|
|
3
|
+
* (docs/memory.md#search).
|
|
4
|
+
*
|
|
5
|
+
* The spec calls for note text to be embedded and searched by meaning beside the
|
|
6
|
+
* lexical indexes, and the default is zvec when `@4fu/arbor-search-zvec` is
|
|
7
|
+
* installed: `resolveSemanticIndex` loads it, and the store uses what it returns
|
|
8
|
+
* when the caller names no channel. The package is an optional peer dependency,
|
|
9
|
+
* so a deployment that leaves it out keeps working.
|
|
10
|
+
*
|
|
11
|
+
* What is left here is the absence that answers when the package is not there:
|
|
12
|
+
* no channel, and search is exactly lexical. That is also the answer when the
|
|
13
|
+
* package is installed but its vector store will not open - zvec's prebuilt
|
|
14
|
+
* binary does not load on this platform, or another host holds the collection -
|
|
15
|
+
* and when no embedding model is available, because the package says so by
|
|
16
|
+
* handing back nothing.
|
|
17
|
+
*/
|
|
18
|
+
import { createRequire } from "node:module";
|
|
19
|
+
/** The package that holds the zvec semantic index, when a deployment installed it. */
|
|
20
|
+
const ZVEC_PACKAGE = "@4fu/arbor-search-zvec";
|
|
21
|
+
/**
|
|
22
|
+
* Load the zvec semantic index over a store beside the notes database's own, or
|
|
23
|
+
* `null` when it is not there: the package is not installed, or it holds no
|
|
24
|
+
* index.
|
|
25
|
+
*/
|
|
26
|
+
function loadZvec(path) {
|
|
27
|
+
const require = createRequire(import.meta.url);
|
|
28
|
+
const loaded = require(ZVEC_PACKAGE);
|
|
29
|
+
const create = loaded?.createZvecIndex;
|
|
30
|
+
if (typeof create !== "function")
|
|
31
|
+
return null;
|
|
32
|
+
const index = create({ path });
|
|
33
|
+
if (index === null || typeof index !== "object")
|
|
34
|
+
return null;
|
|
35
|
+
const shape = index;
|
|
36
|
+
if (typeof shape.add !== "function" || typeof shape.search !== "function")
|
|
37
|
+
return null;
|
|
38
|
+
return index;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The semantic channel a note store uses when the caller names none: the zvec
|
|
42
|
+
* one when the package is installed and its store opens, and none otherwise.
|
|
43
|
+
*
|
|
44
|
+
* `load` replaces how the zvec package is loaded. A host that resolves modules
|
|
45
|
+
* its own way passes its own; so does a test that has no zvec package to load. A
|
|
46
|
+
* load that fails falls back rather than stopping the store, because the package
|
|
47
|
+
* is optional: it may be missing, or its prebuilt binary may not load on this
|
|
48
|
+
* platform.
|
|
49
|
+
*/
|
|
50
|
+
export function resolveSemanticIndex(path, load = loadZvec) {
|
|
51
|
+
try {
|
|
52
|
+
return load(path);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|