tinywebui 0.1.3 → 0.1.5
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 +20 -13
- package/bin/tinywebui.js +69 -63
- package/package.json +25 -8
- package/public/app.js +40 -14
- package/public/chat/composer.js +1 -1
- package/public/chat/sidebar.js +8 -5
- package/public/index.html +88 -49
- package/public/panels/admin.js +40 -1
- package/public/panels/model-editor.js +336 -0
- package/public/panels/panels.js +37 -12
- package/public/panels/settings-home.js +125 -0
- package/public/panels/settings.js +124 -36
- package/public/shared/attachment_note.js +7 -1
- package/public/shared/events.js +1 -0
- package/public/shared/features.js +1 -0
- package/public/shared/origins.js +1 -0
- package/public/shared/usage.js +16 -1
- package/public/styles.css +83 -20
- package/src/access/auth.js +155 -155
- package/src/access/auth_gate.js +39 -38
- package/src/access/email.js +17 -0
- package/src/access/policy.js +123 -138
- package/src/audit.js +1 -1
- package/src/automations/automation.js +135 -123
- package/src/automations/scheduler.js +138 -127
- package/src/chat/compact.js +149 -156
- package/src/chat/llm.js +380 -382
- package/src/chat/runs.js +162 -151
- package/src/chat/tool_executor.js +229 -218
- package/src/cli.js +535 -433
- package/src/config/approval.js +35 -29
- package/src/config/config.js +546 -451
- package/src/config/data_dir.js +24 -0
- package/src/config/models.js +191 -115
- package/src/config/refs.js +32 -0
- package/src/config/secrets.js +153 -0
- package/src/files/documents.js +57 -68
- package/src/files/images.js +30 -31
- package/src/harness/agent.js +60 -60
- package/src/harness/attribution.js +66 -58
- package/src/harness/checkpoint.js +67 -62
- package/src/harness/history.js +28 -23
- package/src/harness/prompt.js +40 -57
- package/src/harness/provider.js +284 -283
- package/src/harness/types.js +1 -0
- package/src/http.js +98 -66
- package/src/log.js +11 -12
- package/src/mcp.js +398 -374
- package/src/retrieval/embedding.js +228 -240
- package/src/retrieval/retrieval.js +182 -173
- package/src/routes/admin.js +178 -138
- package/src/routes/auth.js +184 -100
- package/src/routes/automations.js +99 -82
- package/src/routes/chats.js +240 -218
- package/src/routes/documents.js +130 -120
- package/src/routes/library.js +43 -46
- package/src/routes/mcp.js +49 -49
- package/src/routes/settings.js +127 -122
- package/src/routes/types.js +1 -0
- package/src/server.js +428 -392
- package/src/store/automations.js +106 -106
- package/src/store/chats.js +217 -242
- package/src/store/documents.js +181 -185
- package/src/store/embeddings.js +52 -54
- package/src/store/index.js +95 -49
- package/src/store/messages.js +55 -80
- package/src/store/schema.js +128 -107
- package/src/store/scope.js +18 -17
- package/src/store/search.js +25 -27
- package/src/store/turns.js +53 -53
- package/src/store/usage.js +343 -269
- package/src/store/users.js +162 -87
- package/src/store/wire.js +50 -41
- package/src/tools/ask_tool.js +68 -68
- package/src/tools/chat_search_tool.js +38 -44
- package/src/tools/context_tool.js +131 -138
- package/src/tools/document_tool.js +73 -78
- package/src/tools/mcp_tool.js +274 -232
- package/src/tools/task_tool.js +53 -50
- package/src/tools/types.js +1 -0
- package/public/panels/connectors.js +0 -184
- package/src/set-password.js +0 -43
package/src/store/documents.js
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
import { scope, ftsQuery } from './scope.js';
|
|
2
2
|
import { createHash, randomBytes } from 'node:crypto';
|
|
3
3
|
import { CORPUS } from './embeddings.js';
|
|
4
|
-
|
|
5
4
|
// Attached documents, split into passages for read_document (BM25 and dense retrieval).
|
|
6
5
|
// One area of the Store; see index.js.
|
|
7
|
-
|
|
8
6
|
/** Default passage split: characters per passage, and how much neighbours overlap. */
|
|
9
7
|
export const PASSAGE_SIZE = 1800;
|
|
10
8
|
export const PASSAGE_OVERLAP = 200;
|
|
11
|
-
|
|
12
9
|
/**
|
|
13
10
|
* Splits text into overlapping passages -- the unit read_document returns.
|
|
14
11
|
* Character-based on purpose: passages are for reading and BM25, and the
|
|
@@ -17,94 +14,97 @@ export const PASSAGE_OVERLAP = 200;
|
|
|
17
14
|
* passage doesn't open or close mid-sentence more than it has to.
|
|
18
15
|
*/
|
|
19
16
|
export function splitPassages(text, size = PASSAGE_SIZE, overlap = PASSAGE_OVERLAP) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
17
|
+
const passages = [];
|
|
18
|
+
let start = 0;
|
|
19
|
+
while (start < text.length) {
|
|
20
|
+
let end = Math.min(start + size, text.length);
|
|
21
|
+
if (end < text.length) {
|
|
22
|
+
const para = text.lastIndexOf('\n\n', end);
|
|
23
|
+
const line = text.lastIndexOf('\n', end);
|
|
24
|
+
const boundary = para > start + size * 0.5 ? para : (line > start + size * 0.5 ? line : -1);
|
|
25
|
+
if (boundary !== -1)
|
|
26
|
+
end = boundary;
|
|
27
|
+
}
|
|
28
|
+
passages.push({ start, text: text.slice(start, end) });
|
|
29
|
+
if (end >= text.length)
|
|
30
|
+
break;
|
|
31
|
+
start = Math.max(end - overlap, start + 1);
|
|
29
32
|
}
|
|
30
|
-
passages
|
|
31
|
-
if (end >= text.length) break;
|
|
32
|
-
start = Math.max(end - overlap, start + 1);
|
|
33
|
-
}
|
|
34
|
-
return passages;
|
|
33
|
+
return passages;
|
|
35
34
|
}
|
|
36
|
-
|
|
37
35
|
const md5Of = (bytes) => createHash('md5').update(bytes).digest('hex');
|
|
38
|
-
|
|
39
36
|
function insertPassages(db, docId, chatId, content, size, overlap) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
37
|
+
const passages = splitPassages(content, size, overlap);
|
|
38
|
+
const insertPassage = db.prepare('INSERT INTO document_passages (body) VALUES (?)');
|
|
39
|
+
const insertMap = db.prepare(`
|
|
43
40
|
INSERT INTO document_passage_map (passage_rowid, doc_id, chat_id, passage_idx, char_start)
|
|
44
41
|
VALUES (?, ?, ?, ?, ?)
|
|
45
42
|
`);
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
43
|
+
passages.forEach((passage, idx) => {
|
|
44
|
+
const { lastInsertRowid } = insertPassage.run(passage.text);
|
|
45
|
+
insertMap.run(lastInsertRowid, docId, chatId, idx, passage.start);
|
|
46
|
+
});
|
|
47
|
+
return passages.length;
|
|
51
48
|
}
|
|
52
|
-
|
|
53
49
|
export class DocumentStore {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
50
|
+
constructor(db, deps) {
|
|
51
|
+
this.db = db;
|
|
52
|
+
Object.assign(this, deps);
|
|
53
|
+
}
|
|
54
|
+
claimUnowned(userId) {
|
|
55
|
+
this.db.prepare('UPDATE documents SET user_id = ? WHERE user_id IS NULL').run(userId);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* `original`: the uploaded bytes, kept (once per distinct file) so the file
|
|
59
|
+
* itself can be opened later. `md5` names them when the caller already has
|
|
60
|
+
* the fingerprint, e.g. copying a document into another chat.
|
|
61
|
+
*/
|
|
62
|
+
add(chatId, { filename, mime, content, original = null, md5 = null }, { passageSize = PASSAGE_SIZE, passageOverlap = PASSAGE_OVERLAP } = {}) {
|
|
63
|
+
const id = randomBytes(12).toString('hex');
|
|
64
|
+
const createdAt = Date.now();
|
|
65
|
+
const hash = original ? md5Of(original) : md5;
|
|
66
|
+
this.db.exec('BEGIN');
|
|
67
|
+
try {
|
|
68
|
+
if (original)
|
|
69
|
+
this.db.prepare('INSERT OR IGNORE INTO document_blobs (md5, data) VALUES (?, ?)').run(hash, original);
|
|
70
|
+
this.db.prepare(`
|
|
72
71
|
INSERT INTO documents (id, chat_id, filename, mime, char_len, created_at, content, passage_size, passage_overlap, md5)
|
|
73
72
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
74
73
|
`).run(id, chatId, filename, mime ?? null, content.length, createdAt, content, passageSize, passageOverlap, hash);
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
74
|
+
const passages = insertPassages(this.db, id, chatId, content, passageSize, passageOverlap);
|
|
75
|
+
this.db.exec('COMMIT');
|
|
76
|
+
return { id, filename, mime: mime ?? null, char_len: content.length, created_at: createdAt, passages };
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
this.db.exec('ROLLBACK');
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** The chat's document with these bytes, if it already has one -- a file is attached to a chat once. */
|
|
84
|
+
findInChat(chatId, original) {
|
|
85
|
+
return this.#inChat(chatId, md5Of(original));
|
|
86
|
+
}
|
|
87
|
+
#inChat(chatId, md5) {
|
|
88
|
+
return this.db.prepare('SELECT id, filename, mime, char_len, created_at FROM documents WHERE chat_id = ? AND md5 = ?')
|
|
89
|
+
.get(chatId, md5) || null;
|
|
90
|
+
}
|
|
91
|
+
/** The uploaded bytes as a Buffer, or null (no such document, not yours, or stored before originals were kept). */
|
|
92
|
+
original(id, userId) {
|
|
93
|
+
const doc = this.get(id, userId);
|
|
94
|
+
if (!doc?.md5)
|
|
95
|
+
return null;
|
|
96
|
+
const row = this.db.prepare('SELECT data FROM document_blobs WHERE md5 = ?').get(doc.md5);
|
|
97
|
+
return row ? Buffer.from(row.data) : null;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* What the user has attached before, across all their chats, newest first --
|
|
101
|
+
* for picking a file again without uploading it. One entry per distinct file:
|
|
102
|
+
* the same bytes in ten chats is one line, and it names the latest chat.
|
|
103
|
+
*/
|
|
104
|
+
library(userId, limit = 200) {
|
|
105
|
+
const s = scope(userId, 'c.user_id');
|
|
106
|
+
const inner = scope(userId, 'c2.user_id'); // the "latest" must be the user's own copy, not someone else's
|
|
107
|
+
return this.db.prepare(`
|
|
108
108
|
SELECT d.id, d.filename, d.mime, d.char_len, d.created_at, c.title AS chatTitle
|
|
109
109
|
FROM documents d JOIN chats c ON c.id = d.chat_id
|
|
110
110
|
WHERE ${s.sql}
|
|
@@ -116,100 +116,98 @@ export class DocumentStore {
|
|
|
116
116
|
ORDER BY d.created_at DESC
|
|
117
117
|
LIMIT ?
|
|
118
118
|
`).all(...s.params, ...inner.params, limit);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Puts a copy of one of the user's documents into another chat of theirs.
|
|
123
|
-
* Documents are chat-scoped (the chat's deletion takes them, its passages
|
|
124
|
-
* carry its id), so this is a new row: same text, same file blob, its own
|
|
125
|
-
* passages. Null when the source is not theirs; the chat's existing copy
|
|
126
|
-
* when it already has this file.
|
|
127
|
-
*/
|
|
128
|
-
copyToChat(sourceId, chatId, userId, passageSettings) {
|
|
129
|
-
const src = this.get(sourceId, userId);
|
|
130
|
-
if (!src) return null;
|
|
131
|
-
if (src.md5) {
|
|
132
|
-
const have = this.#inChat(chatId, src.md5);
|
|
133
|
-
if (have) return { ...have, existing: true };
|
|
134
119
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
120
|
+
/**
|
|
121
|
+
* Puts a copy of one of the user's documents into another chat of theirs.
|
|
122
|
+
* Documents are chat-scoped (the chat's deletion takes them, its passages
|
|
123
|
+
* carry its id), so this is a new row: same text, same file blob, its own
|
|
124
|
+
* passages. Null when the source is not theirs; the chat's existing copy
|
|
125
|
+
* when it already has this file.
|
|
126
|
+
*/
|
|
127
|
+
copyToChat(sourceId, chatId, userId, passageSettings) {
|
|
128
|
+
const src = this.get(sourceId, userId);
|
|
129
|
+
if (!src)
|
|
130
|
+
return null;
|
|
131
|
+
if (src.md5) {
|
|
132
|
+
const have = this.#inChat(chatId, src.md5);
|
|
133
|
+
if (have)
|
|
134
|
+
return { ...have, existing: true };
|
|
135
|
+
}
|
|
136
|
+
return this.add(chatId, { filename: src.filename, mime: src.mime, content: src.content, md5: src.md5 }, passageSettings);
|
|
137
|
+
}
|
|
138
|
+
/** Documents split with other passage settings than these (NULL = the original 1800/200). */
|
|
139
|
+
withOtherPassages(size, overlap) {
|
|
140
|
+
return this.db.prepare(`
|
|
141
141
|
SELECT id FROM documents
|
|
142
142
|
WHERE COALESCE(passage_size, ${PASSAGE_SIZE}) != ? OR COALESCE(passage_overlap, ${PASSAGE_OVERLAP}) != ?
|
|
143
143
|
`).all(size, overlap).map((r) => r.id);
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
144
|
+
}
|
|
145
|
+
/** Re-splits a stored document with new passage settings; its old vectors go with the old passages. */
|
|
146
|
+
repassage(docId, size, overlap) {
|
|
147
|
+
const doc = this.db.prepare('SELECT id, chat_id, content FROM documents WHERE id = ?').get(docId);
|
|
148
|
+
if (!doc)
|
|
149
|
+
return 0;
|
|
150
|
+
this.db.exec('BEGIN');
|
|
151
|
+
try {
|
|
152
|
+
this.#deletePassages(docId);
|
|
153
|
+
const n = insertPassages(this.db, doc.id, doc.chat_id, doc.content, size, overlap);
|
|
154
|
+
this.db.prepare('UPDATE documents SET passage_size = ?, passage_overlap = ? WHERE id = ?').run(size, overlap, docId);
|
|
155
|
+
this.db.exec('COMMIT');
|
|
156
|
+
return n;
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
this.db.exec('ROLLBACK');
|
|
160
|
+
throw err;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/** Scoped through the owning chat: a document is its chat's user's. */
|
|
164
|
+
get(id, userId) {
|
|
165
|
+
const s = scope(userId, 'c.user_id');
|
|
166
|
+
return this.db.prepare(`SELECT d.* FROM documents d JOIN chats c ON c.id = d.chat_id WHERE d.id = ? AND ${s.sql}`).get(id, ...s.params) || null;
|
|
167
|
+
}
|
|
168
|
+
list(chatId) {
|
|
169
|
+
return this.db.prepare(`
|
|
170
170
|
SELECT id, filename, mime, char_len, created_at
|
|
171
171
|
FROM documents WHERE chat_id = ? ORDER BY created_at ASC
|
|
172
172
|
`).all(chatId);
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
#deletePassages(docId) {
|
|
199
|
-
this.db.prepare(`
|
|
173
|
+
}
|
|
174
|
+
/** Drops one document, its passages and their chunk vectors. The rest of the chat is untouched. */
|
|
175
|
+
delete(id, userId) {
|
|
176
|
+
if (!this.get(id, userId))
|
|
177
|
+
return false;
|
|
178
|
+
this.#deletePassages(id);
|
|
179
|
+
const gone = this.db.prepare('DELETE FROM documents WHERE id = ?').run(id).changes > 0;
|
|
180
|
+
this.#dropOrphanBlobs();
|
|
181
|
+
return gone;
|
|
182
|
+
}
|
|
183
|
+
/** Drops every document of a chat, with its passages and vectors (the chat is going). */
|
|
184
|
+
deleteForChat(chatId) {
|
|
185
|
+
for (const { id } of this.db.prepare('SELECT id FROM documents WHERE chat_id = ?').all(chatId))
|
|
186
|
+
this.#deletePassages(id);
|
|
187
|
+
this.db.prepare('DELETE FROM documents WHERE chat_id = ?').run(chatId);
|
|
188
|
+
this.#dropOrphanBlobs();
|
|
189
|
+
}
|
|
190
|
+
/** A stored file goes when the last document naming it does. */
|
|
191
|
+
#dropOrphanBlobs() {
|
|
192
|
+
this.db.exec('DELETE FROM document_blobs WHERE md5 NOT IN (SELECT md5 FROM documents WHERE md5 IS NOT NULL)');
|
|
193
|
+
}
|
|
194
|
+
// FTS5 has no foreign keys of its own, so passage rows are dropped by rowid
|
|
195
|
+
// before the map that names them.
|
|
196
|
+
#deletePassages(docId) {
|
|
197
|
+
this.db.prepare(`
|
|
200
198
|
DELETE FROM document_passages WHERE rowid IN (
|
|
201
199
|
SELECT passage_rowid FROM document_passage_map WHERE doc_id = ?
|
|
202
200
|
)
|
|
203
201
|
`).run(docId);
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
202
|
+
this.embeddings.deleteOwner(CORPUS.DOCUMENTS, docId);
|
|
203
|
+
this.db.prepare('DELETE FROM document_passage_map WHERE doc_id = ?').run(docId);
|
|
204
|
+
}
|
|
205
|
+
/** Ranked passage search within one document, via FTS5 bm25() (lexical mode). */
|
|
206
|
+
searchPassages(docId, query, limit = 5) {
|
|
207
|
+
const q = ftsQuery(query);
|
|
208
|
+
if (!q)
|
|
209
|
+
return [];
|
|
210
|
+
return this.db.prepare(`
|
|
213
211
|
SELECT m.passage_idx AS passageIdx, m.char_start AS charStart,
|
|
214
212
|
document_passages.body AS body, bm25(document_passages) AS rank
|
|
215
213
|
FROM document_passages
|
|
@@ -218,39 +216,37 @@ export class DocumentStore {
|
|
|
218
216
|
ORDER BY rank
|
|
219
217
|
LIMIT ?
|
|
220
218
|
`).all(q, docId, limit);
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
return this.db.prepare(`
|
|
219
|
+
}
|
|
220
|
+
/** Every passage of a document, in order -- the candidate set for dense and hybrid ranking. */
|
|
221
|
+
passages(docId) {
|
|
222
|
+
return this.db.prepare(`
|
|
226
223
|
SELECT m.passage_rowid AS rowid, m.passage_idx AS passageIdx, m.char_start AS charStart, document_passages.body AS body
|
|
227
224
|
FROM document_passage_map m JOIN document_passages ON document_passages.rowid = m.passage_rowid
|
|
228
225
|
WHERE m.doc_id = ? ORDER BY m.passage_idx
|
|
229
226
|
`).all(docId);
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* BM25 scores per passage, higher is better. `any`: any query word may
|
|
230
|
+
* match (the hybrid's lexical side, so partial matches still rank above
|
|
231
|
+
* none -- dense ranking handles the rest); otherwise all of them must.
|
|
232
|
+
*/
|
|
233
|
+
lexicalScores(docId, query, { any = true } = {}) {
|
|
234
|
+
const q = ftsQuery(query, { any });
|
|
235
|
+
if (!q)
|
|
236
|
+
return new Map();
|
|
237
|
+
const rows = this.db.prepare(`
|
|
241
238
|
SELECT m.passage_rowid AS rowid, bm25(document_passages) AS rank
|
|
242
239
|
FROM document_passages JOIN document_passage_map m ON m.passage_rowid = document_passages.rowid
|
|
243
240
|
WHERE document_passages MATCH ? AND m.doc_id = ?
|
|
244
241
|
`).all(q, docId);
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
return this.db.prepare(`
|
|
242
|
+
return new Map(rows.map((r) => [r.rowid, -r.rank]));
|
|
243
|
+
}
|
|
244
|
+
/** Documents with passages not yet embedded under this model (for backfill). */
|
|
245
|
+
missingVectors(modelKey) {
|
|
246
|
+
return this.db.prepare(`
|
|
251
247
|
SELECT DISTINCT m.doc_id AS id FROM document_passage_map m
|
|
252
248
|
LEFT JOIN embeddings e ON e.corpus = ? AND e.unit_id = m.passage_rowid AND e.model_key = ? AND e.chunk = 0
|
|
253
249
|
WHERE e.unit_id IS NULL
|
|
254
250
|
`).all(CORPUS.DOCUMENTS, modelKey).map((r) => r.id);
|
|
255
|
-
|
|
251
|
+
}
|
|
256
252
|
}
|
package/src/store/embeddings.js
CHANGED
|
@@ -1,67 +1,65 @@
|
|
|
1
1
|
// Chunk vectors for every retrieval corpus (see retrieval/retrieval.js).
|
|
2
2
|
// One area of the Store; see index.js.
|
|
3
|
-
|
|
4
3
|
/** The retrieval corpora whose vectors live here (see retrieval/retrieval.js). */
|
|
5
4
|
export const CORPUS = Object.freeze({ DOCUMENTS: 'documents', CHATS: 'chats' });
|
|
6
|
-
|
|
7
5
|
export class EmbeddingStore {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
6
|
+
db;
|
|
7
|
+
constructor(db) {
|
|
8
|
+
this.db = db;
|
|
9
|
+
}
|
|
10
|
+
/** Chunk vectors of the given units under one model: Map(unit id -> Float32Array[], one per chunk). */
|
|
11
|
+
vectors(corpus, modelKey, unitIds) {
|
|
12
|
+
const out = new Map();
|
|
13
|
+
if (!unitIds.length)
|
|
14
|
+
return out;
|
|
15
|
+
const rows = this.db.prepare(`
|
|
18
16
|
SELECT unit_id, vec FROM embeddings
|
|
19
17
|
WHERE corpus = ? AND model_key = ? AND unit_id IN (SELECT value FROM json_each(?))
|
|
20
18
|
ORDER BY unit_id, chunk
|
|
21
19
|
`).all(corpus, modelKey, JSON.stringify(unitIds));
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
20
|
+
for (const r of rows) {
|
|
21
|
+
const buf = r.vec;
|
|
22
|
+
if (!out.has(r.unit_id))
|
|
23
|
+
out.set(r.unit_id, []);
|
|
24
|
+
out.get(r.unit_id).push(new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4).slice());
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
/** What each of an owner's units was embedded from: Map(unit id -> digest). */
|
|
29
|
+
digests(corpus, ownerId, modelKey) {
|
|
30
|
+
const rows = this.db.prepare('SELECT unit_id, digest FROM embeddings WHERE corpus = ? AND owner_id = ? AND model_key = ? AND chunk = 0')
|
|
31
|
+
.all(corpus, ownerId, modelKey);
|
|
32
|
+
return new Map(rows.map((r) => [r.unit_id, r.digest]));
|
|
33
|
+
}
|
|
34
|
+
/** rows: [{ unitId, digest, vecs: Float32Array[] (its chunks, in order) }], replacing what those units had. */
|
|
35
|
+
put(corpus, ownerId, modelKey, rows) {
|
|
36
|
+
const clear = this.db.prepare('DELETE FROM embeddings WHERE corpus = ? AND unit_id = ? AND model_key = ?');
|
|
37
|
+
const put = this.db.prepare('INSERT INTO embeddings (corpus, unit_id, owner_id, model_key, digest, chunk, vec) VALUES (?, ?, ?, ?, ?, ?, ?)');
|
|
38
|
+
this.db.exec('BEGIN');
|
|
39
|
+
try {
|
|
40
|
+
for (const { unitId, digest, vecs } of rows) {
|
|
41
|
+
clear.run(corpus, unitId, modelKey);
|
|
42
|
+
vecs.forEach((vec, chunk) => put.run(corpus, unitId, ownerId, modelKey, digest, chunk, Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength)));
|
|
43
|
+
}
|
|
44
|
+
this.db.exec('COMMIT');
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
this.db.exec('ROLLBACK');
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
26
50
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
/** What each of an owner's units was embedded from: Map(unit id -> digest). */
|
|
31
|
-
digests(corpus, ownerId, modelKey) {
|
|
32
|
-
const rows = this.db.prepare('SELECT unit_id, digest FROM embeddings WHERE corpus = ? AND owner_id = ? AND model_key = ? AND chunk = 0')
|
|
33
|
-
.all(corpus, ownerId, modelKey);
|
|
34
|
-
return new Map(rows.map((r) => [r.unit_id, r.digest]));
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** rows: [{ unitId, digest, vecs: Float32Array[] (its chunks, in order) }], replacing what those units had. */
|
|
38
|
-
put(corpus, ownerId, modelKey, rows) {
|
|
39
|
-
const clear = this.db.prepare('DELETE FROM embeddings WHERE corpus = ? AND unit_id = ? AND model_key = ?');
|
|
40
|
-
const put = this.db.prepare('INSERT INTO embeddings (corpus, unit_id, owner_id, model_key, digest, chunk, vec) VALUES (?, ?, ?, ?, ?, ?, ?)');
|
|
41
|
-
this.db.exec('BEGIN');
|
|
42
|
-
try {
|
|
43
|
-
for (const { unitId, digest, vecs } of rows) {
|
|
44
|
-
clear.run(corpus, unitId, modelKey);
|
|
45
|
-
vecs.forEach((vec, chunk) => put.run(corpus, unitId, ownerId, modelKey, digest, chunk, Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength)));
|
|
46
|
-
}
|
|
47
|
-
this.db.exec('COMMIT');
|
|
48
|
-
} catch (err) { this.db.exec('ROLLBACK'); throw err; }
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** Drops an owner's vectors for units it no longer has. */
|
|
52
|
-
dropStale(corpus, ownerId, keepUnitIds) {
|
|
53
|
-
return this.db.prepare(`
|
|
51
|
+
/** Drops an owner's vectors for units it no longer has. */
|
|
52
|
+
dropStale(corpus, ownerId, keepUnitIds) {
|
|
53
|
+
return this.db.prepare(`
|
|
54
54
|
DELETE FROM embeddings WHERE corpus = ? AND owner_id = ? AND unit_id NOT IN (SELECT value FROM json_each(?))
|
|
55
55
|
`).run(corpus, ownerId, JSON.stringify(keepUnitIds)).changes;
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
this.db.prepare('DELETE FROM embeddings WHERE corpus = ? AND owner_id = ?').run(corpus, ownerId);
|
|
66
|
-
}
|
|
56
|
+
}
|
|
57
|
+
/** Drops vectors from models (or chunkings) no longer configured. */
|
|
58
|
+
prune(keepModelKey) {
|
|
59
|
+
return this.db.prepare('DELETE FROM embeddings WHERE model_key != ?').run(keepModelKey).changes;
|
|
60
|
+
}
|
|
61
|
+
/** Drops every vector an owner (a document, a chat) has in a corpus. */
|
|
62
|
+
deleteOwner(corpus, ownerId) {
|
|
63
|
+
this.db.prepare('DELETE FROM embeddings WHERE corpus = ? AND owner_id = ?').run(corpus, ownerId);
|
|
64
|
+
}
|
|
67
65
|
}
|