akm-cli 0.9.15 → 0.9.16-alpha.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/CHANGELOG.md +144 -0
- package/dist/assets/tasks/core/index-refresh.yml +1 -1
- package/dist/cli/retired-commands.js +2 -0
- package/dist/cli/unknown-flags.js +36 -3
- package/dist/commands/improve/collapse-detector.js +2 -2
- package/dist/commands/improve/consolidate.js +6 -4
- package/dist/commands/improve/improve-cli.js +1 -1
- package/dist/commands/proposal/repository.js +12 -3
- package/dist/commands/read/curate.js +34 -44
- package/dist/commands/read/search.js +50 -2
- package/dist/commands/sources/index-status.js +99 -0
- package/dist/commands/sources/info.js +8 -8
- package/dist/commands/sources/installed-stashes.js +33 -12
- package/dist/commands/sources/source-add.js +21 -6
- package/dist/commands/sources/stash-cli.js +119 -111
- package/dist/core/adapter/adapters/akm-adapter.js +35 -3
- package/dist/core/adapter/adapters/akm-metadata.js +11 -1
- package/dist/core/asset/asset-placement.js +35 -0
- package/dist/core/config/schema/embedding.js +7 -30
- package/dist/core/config/schema/search.js +11 -9
- package/dist/core/errors.js +5 -2
- package/dist/core/hash.js +18 -0
- package/dist/core/maintenance-barrier.js +8 -6
- package/dist/core/paths.js +0 -11
- package/dist/core/run-lock.js +5 -2
- package/dist/core/state/migrations.js +26 -1
- package/dist/core/state-db.js +63 -27
- package/dist/indexer/drain.js +306 -0
- package/dist/indexer/embedding-identity.js +20 -0
- package/dist/indexer/enrich.js +260 -0
- package/dist/indexer/ensure-index.js +5 -0
- package/dist/indexer/index-written-assets.js +133 -171
- package/dist/indexer/indexer.js +458 -1621
- package/dist/indexer/lookup/adapter-concept-owner.js +19 -5
- package/dist/indexer/passes/metadata.js +18 -1
- package/dist/indexer/reconcile.js +890 -0
- package/dist/indexer/scan/drain-dir.js +27 -70
- package/dist/indexer/scan/parse-file.js +66 -0
- package/dist/indexer/search/db-search.js +373 -89
- package/dist/indexer/search/ranking-contributors.js +21 -16
- package/dist/indexer/search/ranking.js +135 -57
- package/dist/indexer/units/unit.js +159 -0
- package/dist/llm/client.js +10 -1
- package/dist/llm/embedder.js +10 -3
- package/dist/llm/embedders/provider-limits.js +288 -0
- package/dist/llm/embedders/remote.js +133 -104
- package/dist/llm/feature-gate.js +4 -2
- package/dist/llm/rerank-client.js +3 -3
- package/dist/output/shapes/passthrough.js +1 -0
- package/dist/output/text/command-format.js +19 -13
- package/dist/output/text/helpers.js +1 -1
- package/dist/output/text/index.js +5 -2
- package/dist/scripts/akm-migrate-node.js +1141 -1237
- package/dist/scripts/akm-migrate.js +1141 -1237
- package/dist/setup/semantic-assets.js +2 -2
- package/dist/setup/steps/connection.js +3 -2
- package/dist/storage/repositories/files-repository.js +181 -0
- package/dist/storage/repositories/index-connection.js +1 -3
- package/dist/storage/repositories/index-entries-repository.js +77 -68
- package/dist/storage/repositories/index-entry-schema.js +16 -25
- package/dist/storage/repositories/index-fts-repository.js +29 -263
- package/dist/storage/repositories/index-meta-repository.js +0 -29
- package/dist/storage/repositories/index-schema.js +115 -122
- package/dist/storage/repositories/index-utility-repository.js +1 -1
- package/dist/storage/repositories/index-vec-repository.js +21 -334
- package/dist/storage/repositories/units-repository.js +510 -0
- package/docs/migration/release-notes/0.9.15.md +34 -36
- package/docs/migration/release-notes/0.9.16.md +110 -0
- package/docs/migration/release-notes/README.md +5 -0
- package/docs/reference/cli.md +93 -87
- package/docs/reference/configuration.md +128 -89
- package/docs/reference/data-and-telemetry.md +2 -1
- package/package.json +1 -1
- package/schemas/akm-config.json +2 -58
- package/dist/indexer/index-db-contention.js +0 -56
- package/dist/indexer/index-rebuild-lock.js +0 -73
- package/dist/indexer/materialize-embeddings.js +0 -771
- package/dist/indexer/passes/dir-staleness.js +0 -161
- package/dist/storage/repositories/embedding-salvage-repository.js +0 -184
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
/**
|
|
5
|
+
* `index.db` embedding-unit repository (docs/plans/index-fragment-vectors.md).
|
|
6
|
+
*
|
|
7
|
+
* Owns the durable, content-addressed vector store: `units_vec` (the vec0
|
|
8
|
+
* table, the ONE copy of every embedding), `units` (a plain lookup table
|
|
9
|
+
* mirroring `units_vec`'s `(unit_hash, identity)` key — vec0 auxiliary
|
|
10
|
+
* columns are not indexable, so a point lookup or an `IN (...)` filter on
|
|
11
|
+
* `units_vec` directly would be a full scan), and `entry_units` (the cheap,
|
|
12
|
+
* derived entry → ordinal → unit_hash mapping that a reindex rebuilds).
|
|
13
|
+
*
|
|
14
|
+
* Lifecycle invariant (the whole design, docs/plans/index-fragment-vectors.md
|
|
15
|
+
* "Vectors are content-addressed and never rebuilt"): `units` and `units_vec`
|
|
16
|
+
* are created if missing and NEVER dropped by a generation rebuild, by
|
|
17
|
+
* `akm index --full`, or by any embeddings purge — only {@link dropOtherIdentities}
|
|
18
|
+
* removes rows, and only for an identity other than the one being kept. A row
|
|
19
|
+
* exists in `units`/`units_vec` if and only if a vector was actually written
|
|
20
|
+
* for it: {@link upsertUnitVectors} and {@link dropOtherIdentities} are both
|
|
21
|
+
* no-ops when sqlite-vec is unavailable, rather than recording a hash with no
|
|
22
|
+
* vector behind it (which would make {@link listMissingHashes} lie).
|
|
23
|
+
*/
|
|
24
|
+
import { ConfigError } from "../../core/errors.js";
|
|
25
|
+
import { withImmediateTransaction } from "../../core/state-db.js";
|
|
26
|
+
import { getMeta } from "./index-meta-repository.js";
|
|
27
|
+
import { SQLITE_CHUNK_SIZE } from "./index-sql.js";
|
|
28
|
+
import { isVecAvailable } from "./index-vec-repository.js";
|
|
29
|
+
// ── Schema ──────────────────────────────────────────────────────────────────
|
|
30
|
+
function createUnitsVecTable(db, dim) {
|
|
31
|
+
db.exec(`
|
|
32
|
+
CREATE VIRTUAL TABLE units_vec USING vec0(
|
|
33
|
+
unit_id INTEGER PRIMARY KEY,
|
|
34
|
+
embedding FLOAT[${dim}],
|
|
35
|
+
+unit_hash TEXT,
|
|
36
|
+
+identity TEXT
|
|
37
|
+
);
|
|
38
|
+
`);
|
|
39
|
+
}
|
|
40
|
+
function tableExists(db, name) {
|
|
41
|
+
return db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name) != null;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The vector width `units_vec` was created at, read back from its own DDL
|
|
45
|
+
* (`sqlite_master.sql` carries the `CREATE VIRTUAL TABLE` text verbatim) so
|
|
46
|
+
* there is one source of truth for "what width is this table at right now" —
|
|
47
|
+
* no separate meta key to keep in sync. Returns `undefined` when the table
|
|
48
|
+
* does not exist.
|
|
49
|
+
*/
|
|
50
|
+
function unitsVecDimension(db) {
|
|
51
|
+
const row = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'units_vec'").get();
|
|
52
|
+
const match = row?.sql.match(/FLOAT\[(\d+)\]/);
|
|
53
|
+
return match?.[1] === undefined ? undefined : Number(match[1]);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Create `units`, `entry_units` and (when sqlite-vec is loaded) `units_vec`
|
|
57
|
+
* if they do not already exist. Idempotent and safe to call on every schema
|
|
58
|
+
* ensure — it never touches an existing table's rows or drops anything.
|
|
59
|
+
*
|
|
60
|
+
* `dim` only matters the first time `units_vec` is created; an existing table
|
|
61
|
+
* keeps its width until {@link dropOtherIdentities} recreates it for a real
|
|
62
|
+
* dimension change.
|
|
63
|
+
*/
|
|
64
|
+
export function ensureUnitTables(db, dim) {
|
|
65
|
+
db.exec(`
|
|
66
|
+
CREATE TABLE IF NOT EXISTS units (
|
|
67
|
+
unit_id INTEGER PRIMARY KEY,
|
|
68
|
+
unit_hash TEXT NOT NULL,
|
|
69
|
+
identity TEXT NOT NULL,
|
|
70
|
+
UNIQUE (unit_hash, identity)
|
|
71
|
+
);
|
|
72
|
+
CREATE TABLE IF NOT EXISTS entry_units (
|
|
73
|
+
entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
|
74
|
+
ordinal INTEGER NOT NULL,
|
|
75
|
+
fragment_id TEXT,
|
|
76
|
+
unit_hash TEXT NOT NULL,
|
|
77
|
+
PRIMARY KEY (entry_id, ordinal)
|
|
78
|
+
);
|
|
79
|
+
CREATE INDEX IF NOT EXISTS entry_units_hash ON entry_units(unit_hash);
|
|
80
|
+
`);
|
|
81
|
+
// Mirrors entries_vec: a vec0 virtual table needs the extension loaded to
|
|
82
|
+
// even be created. `units`/`entry_units` above are plain tables and exist
|
|
83
|
+
// regardless, so lookups and the entry→hash mapping keep working; only the
|
|
84
|
+
// KNN path is unavailable (see `searchUnits`).
|
|
85
|
+
if (!isVecAvailable(db))
|
|
86
|
+
return;
|
|
87
|
+
if (tableExists(db, "units_vec"))
|
|
88
|
+
return;
|
|
89
|
+
createUnitsVecTable(db, dim);
|
|
90
|
+
}
|
|
91
|
+
// ── Vector storage ──────────────────────────────────────────────────────────
|
|
92
|
+
function float32Buffer(vector) {
|
|
93
|
+
return Buffer.from(new Float32Array(vector).buffer);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Write new (hash, identity) → vector rows. Rows whose (hash, identity)
|
|
97
|
+
* already exists in `units` are left as-is in `units` (content-addressed: the
|
|
98
|
+
* same text under the same identity is the same vector) but their `units_vec`
|
|
99
|
+
* row is still rewritten, so a prior run that wrote the `units` row but failed
|
|
100
|
+
* before its vec0 mirror (or ran while sqlite-vec was unavailable) self-heals
|
|
101
|
+
* on the next call instead of leaving a permanent gap `searchUnits` can never
|
|
102
|
+
* see.
|
|
103
|
+
*
|
|
104
|
+
* A no-op returning `{ inserted: 0, failed: 0 }` when sqlite-vec is
|
|
105
|
+
* unavailable: there is no BLOB fallback table for units
|
|
106
|
+
* (docs/plans/index-fragment-vectors.md — one copy of every vector, in
|
|
107
|
+
* vec0), so writing a `units` row with no vector behind it would make it
|
|
108
|
+
* look present to {@link listMissingHashes} forever.
|
|
109
|
+
*
|
|
110
|
+
* Each row commits in its OWN transaction (not one transaction for the whole
|
|
111
|
+
* batch): a vec0 insert can throw — most concretely a vector-width mismatch,
|
|
112
|
+
* "Dimension mismatch for inserted vector" — and a single malformed or
|
|
113
|
+
* wrong-width row from an otherwise-good provider response must not roll
|
|
114
|
+
* back every other row this call already wrote. A row that fails is left out
|
|
115
|
+
* of `units` entirely (any `units` placeholder this call itself just created
|
|
116
|
+
* for it is deleted again), not left dangling with no vector behind it, so
|
|
117
|
+
* {@link listMissingHashes} still sees it as missing and retries it on the
|
|
118
|
+
* next drain — the same "no row without a vector" invariant the module
|
|
119
|
+
* doc above states, just enforced per-row instead of per-batch.
|
|
120
|
+
*/
|
|
121
|
+
export function upsertUnitVectors(db, rows) {
|
|
122
|
+
if (rows.length === 0 || !isVecAvailable(db))
|
|
123
|
+
return { inserted: 0, failed: 0 };
|
|
124
|
+
const insertUnit = db.prepare("INSERT INTO units (unit_hash, identity) VALUES (?, ?) ON CONFLICT(unit_hash, identity) DO NOTHING");
|
|
125
|
+
const selectUnitId = db.prepare("SELECT unit_id FROM units WHERE unit_hash = ? AND identity = ?");
|
|
126
|
+
const deleteVec = db.prepare("DELETE FROM units_vec WHERE unit_id = ?");
|
|
127
|
+
const insertVec = db.prepare("INSERT INTO units_vec (unit_id, embedding, unit_hash, identity) VALUES (?, ?, ?, ?)");
|
|
128
|
+
const deleteUnit = db.prepare("DELETE FROM units WHERE unit_id = ?");
|
|
129
|
+
// Each row gets its OWN `BEGIN IMMEDIATE` transaction (not a shared bare
|
|
130
|
+
// `db.transaction()`), for two reasons: a bare deferred transaction whose
|
|
131
|
+
// body reads (selectUnitId) before it writes fails instantly with
|
|
132
|
+
// SQLITE_BUSY under a competing writer instead of honouring
|
|
133
|
+
// `busy_timeout` (docs/plans/index-redesign.md rule 5), and this call
|
|
134
|
+
// must still commit PER ROW even once immediate — a malformed vector must
|
|
135
|
+
// not roll back the rest of a provider batch (see the docstring above).
|
|
136
|
+
const writeOne = (row) => withImmediateTransaction(db, () => {
|
|
137
|
+
const result = insertUnit.run(row.hash, row.identity);
|
|
138
|
+
const freshlyInserted = Number(result.changes) > 0;
|
|
139
|
+
const unitRow = selectUnitId.get(row.hash, row.identity);
|
|
140
|
+
if (!unitRow)
|
|
141
|
+
return false;
|
|
142
|
+
// DELETE-then-INSERT (not INSERT OR REPLACE) — the established pattern
|
|
143
|
+
// for writing a fixed-rowid row into a vec0 table on this driver.
|
|
144
|
+
deleteVec.run(unitRow.unit_id);
|
|
145
|
+
try {
|
|
146
|
+
insertVec.run(unitRow.unit_id, float32Buffer(row.vector), row.hash, row.identity);
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
// Undo the vector-less `units` row this call would otherwise leave
|
|
150
|
+
// behind — whether it was just created above or already existed (its
|
|
151
|
+
// prior vector, if any, is already gone via deleteVec either way).
|
|
152
|
+
deleteUnit.run(unitRow.unit_id);
|
|
153
|
+
throw err;
|
|
154
|
+
}
|
|
155
|
+
return freshlyInserted;
|
|
156
|
+
}, "index");
|
|
157
|
+
let inserted = 0;
|
|
158
|
+
let failed = 0;
|
|
159
|
+
for (const row of rows) {
|
|
160
|
+
try {
|
|
161
|
+
if (writeOne(row))
|
|
162
|
+
inserted++;
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
failed++;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return { inserted, failed };
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Which of `hashes` have no vector yet for `identity`. The set-difference
|
|
172
|
+
* that drives the whole indexing loop (docs/plans/index-fragment-vectors.md
|
|
173
|
+
* "Indexing is a set difference"): callers hash every unit's text, ask what is
|
|
174
|
+
* missing, and only send that difference to the provider.
|
|
175
|
+
*
|
|
176
|
+
* Deduplicated and returned in first-occurrence order — the same unit text
|
|
177
|
+
* commonly recurs across entries (content-addressed), so de-duping here is
|
|
178
|
+
* what keeps a shared fragment from being requested from the provider twice
|
|
179
|
+
* in one pass.
|
|
180
|
+
*/
|
|
181
|
+
export function listMissingHashes(db, hashes, identity) {
|
|
182
|
+
const unique = [...new Set(hashes)];
|
|
183
|
+
if (unique.length === 0)
|
|
184
|
+
return [];
|
|
185
|
+
const present = new Set();
|
|
186
|
+
for (let offset = 0; offset < unique.length; offset += SQLITE_CHUNK_SIZE) {
|
|
187
|
+
const chunk = unique.slice(offset, offset + SQLITE_CHUNK_SIZE);
|
|
188
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
189
|
+
const rows = db
|
|
190
|
+
.prepare(`SELECT unit_hash FROM units WHERE identity = ? AND unit_hash IN (${placeholders})`)
|
|
191
|
+
.all(identity, ...chunk);
|
|
192
|
+
for (const row of rows)
|
|
193
|
+
present.add(row.unit_hash);
|
|
194
|
+
}
|
|
195
|
+
return unique.filter((hash) => !present.has(hash));
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Remove rows for every identity except `keep`, recreating `units_vec` at
|
|
199
|
+
* `dim` first when its current width differs from `dim`.
|
|
200
|
+
*
|
|
201
|
+
* vec0's embedding column width is fixed for the whole table, so a genuine
|
|
202
|
+
* dimension change (a real model swap, not a rename) cannot be expressed as a
|
|
203
|
+
* row-level DELETE — the table is dropped and recreated instead. Any row that
|
|
204
|
+
* survives a width mismatch could only belong to `keep`, and it could only
|
|
205
|
+
* exist if it had already been written at `dim` — which the OLD, differently
|
|
206
|
+
* sized table would have rejected — so nothing behind `keep` is lost by the
|
|
207
|
+
* drop.
|
|
208
|
+
*
|
|
209
|
+
* The width check runs UNCONDITIONALLY, independent of `staleCount` (E6): a
|
|
210
|
+
* fresh index (or one whose only prior identity was already dropped) has
|
|
211
|
+
* zero rows under any identity, so gating the width check behind
|
|
212
|
+
* `staleCount > 0` — the round-2 shape of this bug — meant a `units_vec`
|
|
213
|
+
* created at a stale/misconfigured width (e.g. `embedding.dimension` set to
|
|
214
|
+
* a value the provider does not actually return) could never be corrected:
|
|
215
|
+
* every write fails "Dimension mismatch for inserted vector", the row this
|
|
216
|
+
* call itself just tried to write is discarded, `units` stays empty, and
|
|
217
|
+
* `staleCount` stays 0 forever — not even `akm index --reembed` (which
|
|
218
|
+
* reaches this same function) can recover, only deleting index.db could.
|
|
219
|
+
* Checking the width regardless of `staleCount` costs nothing extra when
|
|
220
|
+
* nothing is stale (`unitsVecDimension` is one cheap `sqlite_master` read)
|
|
221
|
+
* and fixes exactly this: the very first call for a new width recreates the
|
|
222
|
+
* table before anything is ever written under it.
|
|
223
|
+
*
|
|
224
|
+
* The drop/create/delete (or delete-stale-rows/delete) sequence runs in ONE
|
|
225
|
+
* transaction (E5a): as two separate autocommit statements, a concurrent
|
|
226
|
+
* `upsertUnitVectors` landing between the `DROP TABLE` and the `CREATE
|
|
227
|
+
* TABLE` would silently lose an already-paid-for embedding (counted
|
|
228
|
+
* `failed` and discarded) rather than simply retrying against the
|
|
229
|
+
* recreated table.
|
|
230
|
+
*
|
|
231
|
+
* A no-op when sqlite-vec is unavailable: `units`/`units_vec` only ever gain
|
|
232
|
+
* rows when a vector was actually written (see {@link upsertUnitVectors}), so
|
|
233
|
+
* there is nothing to remove and no vec0 table this could safely touch.
|
|
234
|
+
*/
|
|
235
|
+
export function dropOtherIdentities(db, keep, dim) {
|
|
236
|
+
if (!isVecAvailable(db))
|
|
237
|
+
return { removed: 0 };
|
|
238
|
+
ensureUnitTables(db, dim);
|
|
239
|
+
const currentDim = unitsVecDimension(db);
|
|
240
|
+
const widthChanged = currentDim !== undefined && currentDim !== dim;
|
|
241
|
+
const staleCount = db.prepare("SELECT COUNT(*) AS n FROM units WHERE identity != ?").get(keep).n;
|
|
242
|
+
if (staleCount === 0 && !widthChanged)
|
|
243
|
+
return { removed: 0 };
|
|
244
|
+
withImmediateTransaction(db, () => {
|
|
245
|
+
if (widthChanged) {
|
|
246
|
+
db.exec("DROP TABLE IF EXISTS units_vec");
|
|
247
|
+
createUnitsVecTable(db, dim);
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
const staleIds = db.prepare("SELECT unit_id FROM units WHERE identity != ?").all(keep).map((row) => row.unit_id);
|
|
251
|
+
for (let offset = 0; offset < staleIds.length; offset += SQLITE_CHUNK_SIZE) {
|
|
252
|
+
const chunk = staleIds.slice(offset, offset + SQLITE_CHUNK_SIZE);
|
|
253
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
254
|
+
db.prepare(`DELETE FROM units_vec WHERE unit_id IN (${placeholders})`).run(...chunk);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
db.prepare("DELETE FROM units WHERE identity != ?").run(keep);
|
|
258
|
+
}, "index");
|
|
259
|
+
return { removed: staleCount };
|
|
260
|
+
}
|
|
261
|
+
// ── Entry → unit mapping ────────────────────────────────────────────────────
|
|
262
|
+
/** Replace every `entry_units` row for `entryId` with `units` (delete-then-insert). */
|
|
263
|
+
export function replaceEntryUnits(db, entryId, units) {
|
|
264
|
+
// `applyChange` (indexer/reconcile.ts) normally already holds an outer
|
|
265
|
+
// `withImmediateTransaction` when it calls this — the join-if-open guard
|
|
266
|
+
// means this runs inside that transaction rather than opening its own.
|
|
267
|
+
withImmediateTransaction(db, () => {
|
|
268
|
+
db.prepare("DELETE FROM entry_units WHERE entry_id = ?").run(entryId);
|
|
269
|
+
const insert = db.prepare("INSERT INTO entry_units (entry_id, ordinal, fragment_id, unit_hash) VALUES (?, ?, ?, ?)");
|
|
270
|
+
for (const unit of units) {
|
|
271
|
+
insert.run(entryId, unit.ordinal, unit.fragmentId, unit.hash);
|
|
272
|
+
}
|
|
273
|
+
}, "index");
|
|
274
|
+
}
|
|
275
|
+
/** Remove every `entry_units` row for the given entries (e.g. entries deleted outside a cascade). */
|
|
276
|
+
export function deleteEntryUnits(db, entryIds) {
|
|
277
|
+
const unique = [...new Set(entryIds)];
|
|
278
|
+
if (unique.length === 0)
|
|
279
|
+
return;
|
|
280
|
+
for (let offset = 0; offset < unique.length; offset += SQLITE_CHUNK_SIZE) {
|
|
281
|
+
const chunk = unique.slice(offset, offset + SQLITE_CHUNK_SIZE);
|
|
282
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
283
|
+
db.prepare(`DELETE FROM entry_units WHERE entry_id IN (${placeholders})`).run(...chunk);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
// ── Search ──────────────────────────────────────────────────────────────────
|
|
287
|
+
/**
|
|
288
|
+
* vec0 0.1.9 rejects a KNN query that also filters on an auxiliary column —
|
|
289
|
+
* "An illegal WHERE constraint was provided on a vec0 auxiliary column in a
|
|
290
|
+
* KNN query" — so `identity` cannot be pushed into the `MATCH` query itself
|
|
291
|
+
* and has to be filtered afterward. Over-fetching by this factor keeps that
|
|
292
|
+
* post-filter from starving recall during the rare, transient window where
|
|
293
|
+
* more than one identity's rows coexist in `units_vec` (the steady state,
|
|
294
|
+
* enforced by {@link dropOtherIdentities}, is exactly one identity).
|
|
295
|
+
*
|
|
296
|
+
* index-redesign integration note (kept, not dropped): the notes proposed
|
|
297
|
+
* dropping this filter/overfetch entirely for B5 and asserting the
|
|
298
|
+
* one-identity invariant where the store is written instead, keeping the
|
|
299
|
+
* overfetch only if a test proves two identities can coexist. One does:
|
|
300
|
+
* `tests/storage/units-repository.test.ts`'s "searchUnits returns nearest
|
|
301
|
+
* units first, scoped by identity" writes two identities into `units_vec`
|
|
302
|
+
* via the real {@link upsertUnitVectors} (not a raw-SQL seed) and asserts
|
|
303
|
+
* `searchUnits` still returns only the requested identity's nearest rows —
|
|
304
|
+
* without this post-filter that test's own tied-vector fixture would drop
|
|
305
|
+
* a real hit for the requested identity, not merely tolerate a stray one.
|
|
306
|
+
* `identity` is also a required part of this function's stage-1 contract
|
|
307
|
+
* signature, used by every real caller (B3's semantic search branch), so an
|
|
308
|
+
* unenforced parameter here would be a silent footgun. Left in place; no
|
|
309
|
+
* hard invariant assertion added elsewhere, since the transient
|
|
310
|
+
* two-identity window this docblock already describes is a real,
|
|
311
|
+
* non-error state a hard assertion would wrongly reject.
|
|
312
|
+
*/
|
|
313
|
+
const UNIT_SEARCH_OVERFETCH = 4;
|
|
314
|
+
/**
|
|
315
|
+
* Which of a KNN candidate batch's unit hashes have an owning entry (via
|
|
316
|
+
* `entry_units` → `entries`) satisfying both a `typeFilter` and an
|
|
317
|
+
* `excludeTypes` predicate at once (index-redesign search-fix item 3).
|
|
318
|
+
*
|
|
319
|
+
* vec0 rejects a KNN query that also filters on an auxiliary column ("An
|
|
320
|
+
* illegal WHERE constraint was provided on a vec0 auxiliary column in a KNN
|
|
321
|
+
* query"), so the type predicate cannot be pushed INTO the `embedding MATCH`
|
|
322
|
+
* query itself. Solved instead by joining AROUND the KNN: run the KNN
|
|
323
|
+
* untyped (already over-fetching via {@link UNIT_SEARCH_OVERFETCH}), then
|
|
324
|
+
* filter the raw candidate hashes through `entry_units`/`entries` BEFORE the
|
|
325
|
+
* `k` cap below — the same "filter before the cap" fix as the lexical side
|
|
326
|
+
* (`runUnitsFtsQuery`, db-search.ts), so an eligible neighbor is never pushed
|
|
327
|
+
* out of the window by ineligible ones that rank closer.
|
|
328
|
+
*/
|
|
329
|
+
function eligibleUnitHashesByType(db, hashes, typeOpts) {
|
|
330
|
+
const eligible = new Set();
|
|
331
|
+
const clauses = [];
|
|
332
|
+
const baseParams = [];
|
|
333
|
+
if (typeOpts.typeFilter?.length) {
|
|
334
|
+
clauses.push(`e.type IN (${typeOpts.typeFilter.map(() => "?").join(",")})`);
|
|
335
|
+
baseParams.push(...typeOpts.typeFilter);
|
|
336
|
+
}
|
|
337
|
+
if (typeOpts.excludeTypes?.length) {
|
|
338
|
+
clauses.push(`e.type NOT IN (${typeOpts.excludeTypes.map(() => "?").join(",")})`);
|
|
339
|
+
baseParams.push(...typeOpts.excludeTypes);
|
|
340
|
+
}
|
|
341
|
+
for (let offset = 0; offset < hashes.length; offset += SQLITE_CHUNK_SIZE) {
|
|
342
|
+
const chunk = hashes.slice(offset, offset + SQLITE_CHUNK_SIZE);
|
|
343
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
344
|
+
const rows = db
|
|
345
|
+
.prepare(`SELECT DISTINCT eu.unit_hash AS hash FROM entry_units eu JOIN entries e ON e.id = eu.entry_id
|
|
346
|
+
WHERE eu.unit_hash IN (${placeholders}) AND ${clauses.join(" AND ")}`)
|
|
347
|
+
.all(...chunk, ...baseParams);
|
|
348
|
+
for (const row of rows)
|
|
349
|
+
eligible.add(row.hash);
|
|
350
|
+
}
|
|
351
|
+
return eligible;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* KNN search over `units_vec` for the given `identity`, nearest first.
|
|
355
|
+
* Throws a {@link ConfigError} when sqlite-vec is not loaded — there is no JS
|
|
356
|
+
* fallback for units (docs/plans/index-fragment-vectors.md), so the caller is
|
|
357
|
+
* expected to catch this and fall back to lexical search.
|
|
358
|
+
*
|
|
359
|
+
* `typeOpts`, when given, is applied to the raw KNN candidates BEFORE the `k`
|
|
360
|
+
* cap (see {@link eligibleUnitHashesByType} for how — vec0 cannot filter this
|
|
361
|
+
* inside the KNN query itself), the same fix `runUnitsFtsQuery` applies on
|
|
362
|
+
* the lexical side.
|
|
363
|
+
*/
|
|
364
|
+
export function searchUnits(db, query, k, identity, typeOpts) {
|
|
365
|
+
if (!isVecAvailable(db)) {
|
|
366
|
+
throw new ConfigError("sqlite-vec is not loaded, so unit search cannot run its vec0 KNN query. Fall back to lexical search.", "EMBEDDING_VEC_UNAVAILABLE");
|
|
367
|
+
}
|
|
368
|
+
if (k <= 0)
|
|
369
|
+
return [];
|
|
370
|
+
const rows = db
|
|
371
|
+
.prepare("SELECT unit_id AS unitId, unit_hash AS hash, identity AS identity, distance AS distance " +
|
|
372
|
+
"FROM units_vec WHERE embedding MATCH ? AND k = ?")
|
|
373
|
+
.all(float32Buffer(query), k * UNIT_SEARCH_OVERFETCH);
|
|
374
|
+
const identityFiltered = rows.filter((row) => row.identity === identity);
|
|
375
|
+
const hasTypeFilter = Boolean(typeOpts?.typeFilter?.length || typeOpts?.excludeTypes?.length);
|
|
376
|
+
const typed = hasTypeFilter
|
|
377
|
+
? (() => {
|
|
378
|
+
const eligible = eligibleUnitHashesByType(db, [...new Set(identityFiltered.map((row) => row.hash))], typeOpts);
|
|
379
|
+
return identityFiltered.filter((row) => eligible.has(row.hash));
|
|
380
|
+
})()
|
|
381
|
+
: identityFiltered;
|
|
382
|
+
return typed.slice(0, k).map(({ unitId, hash, distance }) => ({ unitId, hash, distance }));
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Group unit hits to their owning entries, keeping the best (lowest distance)
|
|
386
|
+
* unit per entry. A unit hash can back more than one `entry_units` row (the
|
|
387
|
+
* same fragment text shared across entries, or multiple hits sharing a hash
|
|
388
|
+
* under different identities in the rare transient window `searchUnits`
|
|
389
|
+
* already tolerates), so both the hit list and the hash→entries fan-out are
|
|
390
|
+
* reduced to a minimum by distance.
|
|
391
|
+
*/
|
|
392
|
+
export function groupUnitHitsByEntry(db, hits) {
|
|
393
|
+
const result = new Map();
|
|
394
|
+
if (hits.length === 0)
|
|
395
|
+
return result;
|
|
396
|
+
const distanceByHash = new Map();
|
|
397
|
+
for (const hit of hits) {
|
|
398
|
+
const existing = distanceByHash.get(hit.hash);
|
|
399
|
+
if (existing === undefined || hit.distance < existing)
|
|
400
|
+
distanceByHash.set(hit.hash, hit.distance);
|
|
401
|
+
}
|
|
402
|
+
const hashes = [...distanceByHash.keys()];
|
|
403
|
+
for (let offset = 0; offset < hashes.length; offset += SQLITE_CHUNK_SIZE) {
|
|
404
|
+
const chunk = hashes.slice(offset, offset + SQLITE_CHUNK_SIZE);
|
|
405
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
406
|
+
const rows = db
|
|
407
|
+
.prepare(`SELECT entry_id AS entryId, fragment_id AS fragmentId, unit_hash AS hash FROM entry_units WHERE unit_hash IN (${placeholders})`)
|
|
408
|
+
.all(...chunk);
|
|
409
|
+
for (const row of rows) {
|
|
410
|
+
const distance = distanceByHash.get(row.hash);
|
|
411
|
+
if (distance === undefined)
|
|
412
|
+
continue;
|
|
413
|
+
const existing = result.get(row.entryId);
|
|
414
|
+
if (!existing || distance < existing.distance) {
|
|
415
|
+
result.set(row.entryId, { distance, fragmentId: row.fragmentId, hash: row.hash });
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return result;
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* The number of raw unit hits `getNeighborsByEntryId` over-fetches relative
|
|
423
|
+
* to the requested `k` other entries, one-sentence reason: an entry can own
|
|
424
|
+
* several units (its card plus every fragment), so the nearest raw unit hits
|
|
425
|
+
* collapse into fewer distinct OTHER entries once grouped by entry and the
|
|
426
|
+
* querying entry's own units are excluded — over-fetching keeps that
|
|
427
|
+
* collapse from starving the requested `k`.
|
|
428
|
+
*/
|
|
429
|
+
const NEIGHBOR_CANDIDATE_OVERFETCH = 4;
|
|
430
|
+
/**
|
|
431
|
+
* Decode a `units_vec` embedding BLOB (the same `Buffer.from(new
|
|
432
|
+
* Float32Array(vector).buffer)` layout {@link upsertUnitVectors} writes) back
|
|
433
|
+
* into a plain vector, dimension inferred from byte length.
|
|
434
|
+
*/
|
|
435
|
+
function float32BufferToVector(buf) {
|
|
436
|
+
return Array.from(new Float32Array(buf.buffer, buf.byteOffset, Math.floor(buf.byteLength / 4)));
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* The `k` nearest OTHER entries to `id`, by distance between their card
|
|
440
|
+
* units (`entry_units` ordinal 0) under the active embedding identity —
|
|
441
|
+
* index-redesign-contract.md B5f item 4's replacement for the legacy
|
|
442
|
+
* `embeddings`/`entries_vec`-backed `getNeighborsByEntryId`
|
|
443
|
+
* (`index-vec-repository.ts`), which nothing wrote to since the units path
|
|
444
|
+
* became the only index. Those tables themselves — and everything that only
|
|
445
|
+
* ever read or wrote them — were deleted in B5h.
|
|
446
|
+
*
|
|
447
|
+
* No re-embedding, no network: reads the entry's own already-indexed card
|
|
448
|
+
* vector via a `units` rowid lookup (not a `units_vec` aux-column scan) and
|
|
449
|
+
* reuses {@link searchUnits}'s KNN, then groups the raw unit hits back to
|
|
450
|
+
* entries the same way search does ({@link groupUnitHitsByEntry}).
|
|
451
|
+
* The querying entry is excluded from its own result — its card is its own
|
|
452
|
+
* nearest neighbour at distance 0, and a caller asking for `k` neighbours
|
|
453
|
+
* wants `k` genuinely OTHER entries, not one slot spent confirming an entry
|
|
454
|
+
* is close to itself.
|
|
455
|
+
*
|
|
456
|
+
* Returns `[]` when there is no active identity yet, `id` has no card unit,
|
|
457
|
+
* or that unit has no vector for the active identity (drain has not reached
|
|
458
|
+
* it) — the caller (`consolidate.ts`'s `narrowToIncrementalCandidates`) fails
|
|
459
|
+
* open to the full pool on an empty/unusable result, same as it did for the
|
|
460
|
+
* legacy table being absent.
|
|
461
|
+
*/
|
|
462
|
+
export function getNeighborsByEntryId(db, id, k) {
|
|
463
|
+
if (k <= 0)
|
|
464
|
+
return [];
|
|
465
|
+
const identity = getMeta(db, "embeddingIdentity");
|
|
466
|
+
if (!identity)
|
|
467
|
+
return [];
|
|
468
|
+
const cardRow = db
|
|
469
|
+
.prepare("SELECT unit_hash AS unitHash FROM entry_units WHERE entry_id = ? AND ordinal = 0")
|
|
470
|
+
.get(id);
|
|
471
|
+
if (!cardRow)
|
|
472
|
+
return [];
|
|
473
|
+
const unitRow = db
|
|
474
|
+
.prepare("SELECT unit_id AS unitId FROM units WHERE unit_hash = ? AND identity = ?")
|
|
475
|
+
.get(cardRow.unitHash, identity);
|
|
476
|
+
if (!unitRow)
|
|
477
|
+
return [];
|
|
478
|
+
const vecRow = db.prepare("SELECT embedding FROM units_vec WHERE unit_id = ?").get(unitRow.unitId);
|
|
479
|
+
if (!vecRow)
|
|
480
|
+
return [];
|
|
481
|
+
const queryVector = float32BufferToVector(vecRow.embedding);
|
|
482
|
+
if (queryVector.length === 0)
|
|
483
|
+
return [];
|
|
484
|
+
const hits = searchUnits(db, queryVector, k * NEIGHBOR_CANDIDATE_OVERFETCH, identity);
|
|
485
|
+
const byEntry = groupUnitHitsByEntry(db, hits);
|
|
486
|
+
byEntry.delete(id);
|
|
487
|
+
return [...byEntry.entries()]
|
|
488
|
+
.sort((a, b) => a[1].distance - b[1].distance)
|
|
489
|
+
.slice(0, k)
|
|
490
|
+
.map(([entryId, match]) => ({ id: entryId, distance: match.distance }));
|
|
491
|
+
}
|
|
492
|
+
// ── Coverage ────────────────────────────────────────────────────────────────
|
|
493
|
+
/** How much of the current `entry_units` mapping has a vector for `identity`, for progress reporting. */
|
|
494
|
+
export function unitCoverage(db, identity) {
|
|
495
|
+
const entries = db.prepare("SELECT COUNT(DISTINCT entry_id) AS n FROM entry_units").get().n;
|
|
496
|
+
const unitsTotal = db.prepare("SELECT COUNT(*) AS n FROM entry_units").get().n;
|
|
497
|
+
const unitsPresent = db
|
|
498
|
+
.prepare("SELECT COUNT(*) AS n FROM entry_units eu JOIN units u ON u.unit_hash = eu.unit_hash AND u.identity = ?")
|
|
499
|
+
.get(identity).n;
|
|
500
|
+
const entriesFullyCovered = db
|
|
501
|
+
.prepare(`SELECT COUNT(*) AS n FROM (
|
|
502
|
+
SELECT eu.entry_id
|
|
503
|
+
FROM entry_units eu
|
|
504
|
+
LEFT JOIN units u ON u.unit_hash = eu.unit_hash AND u.identity = ?
|
|
505
|
+
GROUP BY eu.entry_id
|
|
506
|
+
HAVING COUNT(*) = COUNT(u.unit_id)
|
|
507
|
+
)`)
|
|
508
|
+
.get(identity).n;
|
|
509
|
+
return { entries, entriesFullyCovered, unitsTotal, unitsPresent };
|
|
510
|
+
}
|
|
@@ -85,27 +85,33 @@ scheduled or opportunistic run step aside instead of contending with a rebuild
|
|
|
85
85
|
already in progress; the shipped `index-refresh` scheduled task already passes
|
|
86
86
|
it.
|
|
87
87
|
|
|
88
|
-
`
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
exceeding the endpoint's context window,
|
|
96
|
-
the rest of that run (reported with one line) rather
|
|
97
|
-
the same wall on every following batch
|
|
88
|
+
`akm index` now packs embedding requests against the provider's own probed
|
|
89
|
+
context window and slot count (llama.cpp's `GET /props`, Ollama's
|
|
90
|
+
`POST /api/show`) instead of a flat configured token budget: an 8192-token
|
|
91
|
+
llama.cpp embedder, for example, is packed against its real 8192 rather
|
|
92
|
+
than a generic guess. An endpoint that answers neither probe (an
|
|
93
|
+
OpenAI-compatible server, a gateway) gets a conservative built-in default,
|
|
94
|
+
with the same run-scoped recovery as before — on the first request rejected
|
|
95
|
+
for exceeding the endpoint's context window, `akm index` lowers its
|
|
96
|
+
effective budget for the rest of that run (reported with one line) rather
|
|
97
|
+
than continuing to hit the same wall on every following batch; a window akm
|
|
98
|
+
actually probed from the endpoint is treated as authoritative and is never
|
|
99
|
+
second-guessed this way. `embedding.maxTokens` and `embedding.batchSize`
|
|
100
|
+
(the previous per-request token-budget and document-count config keys) are
|
|
101
|
+
retired — see [Retired Configuration](../../reference/configuration.md#retired-configuration)
|
|
102
|
+
— since the provider's own limits are now the source of truth.
|
|
98
103
|
|
|
99
104
|
`embedding.concurrency` (positive integer, 1-16) overrides the number of
|
|
100
105
|
embedding requests kept in flight at once, which otherwise defaults to 1 for
|
|
101
|
-
a loopback endpoint and 2 for a remote one
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
server that can
|
|
106
|
+
a loopback endpoint and 2 for a remote one, or the provider's own probed
|
|
107
|
+
slot count (llama.cpp's `total_slots`) when the probe reports one. Set an
|
|
108
|
+
explicit override only for an endpoint that genuinely serves parallel
|
|
109
|
+
requests — a local model server started with a multi-slot flag (llama.cpp's
|
|
110
|
+
`--parallel N`, vLLM) — since the default already protects an ordinary
|
|
111
|
+
single-slot server from reload-thrash. Embedding throughput is still tuned
|
|
112
|
+
first by request SIZE — the probed token budget and document-count cap
|
|
113
|
+
above; the concurrency override is a second lever for a server that can
|
|
114
|
+
actually use it.
|
|
109
115
|
|
|
110
116
|
`embedding.timeoutMs` bounds each embedding request (default 120s, up from a
|
|
111
117
|
prior fixed 30s that cut off a slow local model server mid-response). It is
|
|
@@ -142,24 +148,16 @@ this is a drop-in fix for anyone running `akm` under a scheduler,
|
|
|
142
148
|
supervisor, or hook that can time out or kill the launcher process
|
|
143
149
|
directly.
|
|
144
150
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
full, set `embedding.maxInputTokens` higher in `config.json`.
|
|
156
|
-
- `akm index --reembed` re-embeds every entry under the new cap — run it if
|
|
157
|
-
you want your entire existing index rebuilt against the new default (or a
|
|
158
|
-
custom `embedding.maxInputTokens` you've set).
|
|
159
|
-
- `embedding.contextLength` is Ollama's `num_ctx` only now; it no longer
|
|
160
|
-
also sets the per-request token budget (`embedding.maxTokens`). If you had
|
|
161
|
-
set `contextLength` specifically to control request batching (not your
|
|
162
|
-
Ollama server's context window), set `embedding.maxTokens` instead.
|
|
151
|
+
A large document is no longer truncated for embedding — `akm index` splits
|
|
152
|
+
it into multiple content-addressed units instead (one vector per section,
|
|
153
|
+
sized against the embedding provider's own probed context window), so a
|
|
154
|
+
long document's later sections are searchable too, not silently dropped
|
|
155
|
+
past a head cap. `embedding.maxInputTokens`, the earlier per-document
|
|
156
|
+
truncation cap this superseded within the same 0.9.15 cycle, is retired —
|
|
157
|
+
see [Retired Configuration](../../reference/configuration.md#retired-configuration).
|
|
158
|
+
`embedding.contextLength` is retired too: Ollama's `num_ctx` is now sent
|
|
159
|
+
automatically from the same probe, or set explicitly via
|
|
160
|
+
`embedding.ollamaOptions.num_ctx`.
|
|
163
161
|
|
|
164
162
|
**Which token knob fixed the original 8k-context overflow.** A 0.9.15-beta
|
|
165
163
|
field report described documents estimated under the request budget that
|