Skip to content

Commit 473d135

Browse files
committed
fix(search): enforce embedding identity
Forward the selected device to Transformers.js and record the model-device identity in each search index. Reject incompatible indexes before queries can mix embedding spaces. Replace duplicated cache tests with API-level regression coverage.
1 parent e79b291 commit 473d135

13 files changed

Lines changed: 390 additions & 164 deletions

README.md

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -241,21 +241,22 @@ The large default context can exceed memory for big models on constrained hardwa
241241

242242
### Embedding Model
243243

244-
`skilld search` is powered by a local embedding model. It runs offline through transformers.jsno API key, and no network traffic after the first download. Pick one under **Embedding model** in `skilld config`:
244+
`skilld search` uses a local embedding model. It runs offline through transformers.js. It needs no API key or network after the first download. Pick one under **Embedding model** in `skilld config`:
245245

246246
| Model | Dimensions | Notes |
247247
|-------|-----------:|-------|
248248
| `bge-small-en-v1.5` | 384 | Default. Fastest to index, smallest download. |
249249
| `bge-base-en-v1.5` | 768 | Balanced accuracy and speed. |
250250
| `bge-m3` | 1024 | Multilingual, 8192-token context. |
251251

252-
Larger models retrieve more accurately but cost more time and memory when indexing. Set `SKILLD_EMBED_MODEL` to override the saved setting for a single run:
252+
Larger models retrieve more accurately but cost more time and memory when indexing. Set `SKILLD_EMBED_MODEL` to override the saved setting:
253253

254254
```bash
255-
SKILLD_EMBED_MODEL=bge-m3 skilld add npm:vue
255+
export SKILLD_EMBED_MODEL=bge-m3
256+
skilld update --force
256257
```
257258

258-
Search indexes store fixed-width vectors, so changing to a model with different dimensions strands existing indexes. Rebuild them after switching:
259+
Each search index belongs to one model and device. Keep environment overrides set for both indexing and querying. Rebuild indexes after either setting changes:
259260

260261
```bash
261262
skilld update --force
@@ -267,7 +268,7 @@ The embedding model runs on the CPU by default. **Embedding device** in `skilld
267268

268269
| Device | Notes |
269270
|--------|-------|
270-
| `auto` | Default. Lets transformers.js choose CPU under Node. |
271+
| `auto` | Default. Lets transformers.js choose, CPU under Node. |
271272
| `cpu` | Always available, predictable. |
272273
| `webgpu` | Fastest on Apple Silicon in testing. |
273274
| `coreml` | Apple Neural Engine. Measured slower than CPU for these models. |
@@ -280,15 +281,16 @@ Measured on an Apple M5 Max, 120 documents, best of 3 after warm-up (docs/sec):
280281
| `bge-base-en-v1.5` | 198 | 68 | **580** |
281282
| `Xenova/bge-large-en-v1.5` | 71 | 9 | **201** |
282283

283-
WebGPU was 2.6-2.9x faster than CPU at every size, which means `bge-large` on WebGPU indexes faster than `bge-base` does on CPU — better retrieval for less wall-clock. CoreML was consistently slower.
284+
WebGPU was 2.6 to 2.9 times faster than CPU at every size. `bge-large` on WebGPU indexed faster than `bge-base` on CPU. CoreML was consistently slower.
284285

285-
The ranking is hardware-specific, so benchmark before trusting a device on other machines. Override for a single run with `SKILLD_EMBED_DEVICE`:
286+
The ranking is hardware-specific, so benchmark before trusting a device on other machines. Set `SKILLD_EMBED_DEVICE` to override the saved setting:
286287

287288
```bash
288-
SKILLD_EMBED_DEVICE=cpu skilld update --force
289+
export SKILLD_EMBED_DEVICE=cpu
290+
skilld update --force
289291
```
290292

291-
If a backend is unavailable, indexing fails to start — switch back to `auto`.
293+
If a backend is unavailable, indexing fails to start. Switch back to `auto`.
292294

293295
### Eject
294296

src/commands/config.ts

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { guard, menuLoop } from '../cli/menu.ts'
1111
import { NO_MODELS_MESSAGE, OAUTH_NOTE, pickModel } from '../cli/model-picker.ts'
1212
import { defaultFeatures, readConfig, updateConfig } from '../core/config.ts'
1313
import { getProjectState } from '../core/skills.ts'
14-
import { DEFAULT_EMBED_DEVICE, DEFAULT_EMBED_MODEL, EMBED_DEVICES, EMBED_MODELS, getEmbedModelInfo, resolveEmbedModel } from '../retriv/models.ts'
14+
import { DEFAULT_EMBED_DEVICE, DEFAULT_EMBED_MODEL, EMBED_DEVICES, EMBED_MODELS, resolveEmbedModel } from '../retriv/models.ts'
1515

1616
export async function configCommand(): Promise<void> {
1717
const initConfig = readConfig()
@@ -248,7 +248,7 @@ async function configureModel(): Promise<void> {
248248
}
249249
}
250250

251-
// ── Embedding model selection ────────────────────────────────────────
251+
// Embedding model selection
252252

253253
async function configureEmbedModel(): Promise<void> {
254254
const config = readConfig()
@@ -260,7 +260,7 @@ async function configureEmbedModel(): Promise<void> {
260260
}
261261

262262
const choice = guard(await p.select({
263-
message: 'Embedding model — indexes and queries docs for skilld search',
263+
message: 'Embedding model for indexing and querying skilld search',
264264
options: EMBED_MODELS.map(m => ({
265265
label: m.label,
266266
value: m.id,
@@ -274,22 +274,12 @@ async function configureEmbedModel(): Promise<void> {
274274
return
275275
}
276276

277-
const previous = getEmbedModelInfo(current)
278-
const next = getEmbedModelInfo(choice as string)
279277
updateConfig({ embedModel: choice === DEFAULT_EMBED_MODEL ? undefined : choice as string })
280278
p.log.success(`Embedding model set to ${choice}`)
281-
282-
// sqlite-vec columns are fixed-width, so a dimension change strands existing
283-
// indexes: they stay queryable at the old width but new docs cannot join them.
284-
if (previous && next && previous.dimensions !== next.dimensions) {
285-
p.log.warn(
286-
`Vector width changed ${previous.dimensions}d → ${next.dimensions}d. `
287-
+ 'Existing search indexes must be rebuilt: skilld update --force',
288-
)
289-
}
279+
p.log.warn('Embedding model changed. Rebuild existing search indexes: skilld update --force')
290280
}
291281

292-
// ── Embedding device selection ───────────────────────────────────────
282+
// Embedding device selection
293283

294284
async function configureEmbedDevice(): Promise<void> {
295285
const config = readConfig()
@@ -301,23 +291,28 @@ async function configureEmbedDevice(): Promise<void> {
301291

302292
p.note(
303293
'The fastest backend depends on your hardware. On an Apple M5 Max, WebGPU\n'
304-
+ 'ran 2.6-2.9x faster than CPU across every model size, while CoreML ran\n'
305-
+ '3-8x slower. Benchmark before trusting a device on other machines.',
294+
+ 'ran 2.6 to 2.9 times faster than CPU. CoreML ran 3 to 8 times slower.\n'
295+
+ 'Benchmark before trusting a device on other machines.',
306296
'Choosing a device',
307297
)
308298

309299
const choice = guard(await p.select({
310-
message: 'Embedding device where the model runs',
300+
message: 'Embedding device where the model runs',
311301
options: EMBED_DEVICES.map(d => ({ label: d.label, value: d.id, hint: d.hint })),
312302
initialValue: current,
313303
}))
314304

305+
if (choice === current) {
306+
p.log.info(`Embedding device unchanged (${choice})`)
307+
return
308+
}
309+
315310
updateConfig({ embedDevice: choice === DEFAULT_EMBED_DEVICE ? undefined : choice as string })
316311
p.log.success(`Embedding device set to ${choice}`)
312+
p.log.warn('Embedding device changed. Rebuild existing search indexes: skilld update --force')
317313

318-
if (choice !== DEFAULT_EMBED_DEVICE && choice !== 'cpu') {
319-
p.log.info('If indexing fails to start, the backend is unavailable on this machine — switch back to Auto.')
320-
}
314+
if (choice !== DEFAULT_EMBED_DEVICE && choice !== 'cpu')
315+
p.log.info('If indexing fails to start, switch back to Auto. The backend may be unavailable on this machine.')
321316
}
322317

323318
export const configCommandDef = defineCommand({

src/retriv/embedding-cache.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ function createSqliteStorage(db: DatabaseSync) {
5555
*
5656
* `model` identifies which embedder produced the cached vectors. Entries are
5757
* keyed by text hash alone, so vectors from a different model would be served
58-
* for the same text — two models of equal width (bge-large and
58+
* for the same text. Two models of equal width (bge-large and
5959
* qwen3-embedding:0.6b are both 1024d) would silently mix embedding spaces and
6060
* destroy ranking. Dimensions alone cannot catch that; the model id can.
6161
*/
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { DatabaseSync } from 'node:sqlite'
2+
import { existsSync } from 'node:fs'
3+
import { DEFAULT_EMBEDDING_IDENTITY } from './models.ts'
4+
5+
const META_TABLE = 'skilld_meta'
6+
const IDENTITY_KEY = 'embedding_identity'
7+
8+
export type IndexEmbeddingIdentityState
9+
= | { _tag: 'Current' }
10+
| { _tag: 'Missing' }
11+
| { _tag: 'Mismatch', current: string, stored: string }
12+
13+
function tableExists(db: DatabaseSync, name: string): boolean {
14+
return db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name) !== undefined
15+
}
16+
17+
function hasIndexedDocuments(db: DatabaseSync): boolean {
18+
if (!tableExists(db, 'documents_meta'))
19+
return false
20+
const row = db.prepare('SELECT EXISTS(SELECT 1 FROM documents_meta) AS found').get() as { found: number }
21+
return row.found === 1
22+
}
23+
24+
export function checkIndexEmbeddingIdentity(dbPath: string, current: string): IndexEmbeddingIdentityState {
25+
if (dbPath === ':memory:' || !existsSync(dbPath))
26+
return { _tag: 'Missing' }
27+
28+
const db = new DatabaseSync(dbPath, { open: true, readOnly: true })
29+
try {
30+
const row = tableExists(db, META_TABLE)
31+
? db.prepare(`SELECT value FROM ${META_TABLE} WHERE key = ?`).get(IDENTITY_KEY) as { value: string } | undefined
32+
: undefined
33+
const stored = row?.value ?? (hasIndexedDocuments(db) ? DEFAULT_EMBEDDING_IDENTITY : undefined)
34+
35+
if (!stored)
36+
return { _tag: 'Missing' }
37+
if (stored !== current)
38+
return { _tag: 'Mismatch', current, stored }
39+
return { _tag: 'Current' }
40+
}
41+
finally {
42+
db.close()
43+
}
44+
}
45+
46+
export function recordIndexEmbeddingIdentity(dbPath: string, identity: string): void {
47+
if (dbPath === ':memory:')
48+
return
49+
const db = new DatabaseSync(dbPath)
50+
try {
51+
db.exec(`CREATE TABLE IF NOT EXISTS ${META_TABLE} (key TEXT PRIMARY KEY, value TEXT NOT NULL)`)
52+
db.prepare(`INSERT OR REPLACE INTO ${META_TABLE} (key, value) VALUES (?, ?)`).run(IDENTITY_KEY, identity)
53+
}
54+
finally {
55+
db.close()
56+
}
57+
}

src/retriv/index.ts

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import type { ChunkEntity, Document, IndexConfig, IndexPhase, IndexProgress, SearchFilter, SearchOptions, SearchResult, SearchSnippet } from './types.ts'
22
import { readConfig } from '../core/config.ts'
33
import { stripFrontmatter } from '../core/markdown.ts'
4-
import { resolveEmbedDevice, resolveEmbedModel } from './models.ts'
4+
import { checkIndexEmbeddingIdentity, recordIndexEmbeddingIdentity } from './index-embedding-identity.ts'
5+
import { getEmbeddingIdentity, resolveEmbedDevice, resolveEmbedModel } from './models.ts'
6+
import { transformersEmbeddings } from './transformers-embeddings.ts'
57

68
export type { ChunkEntity, Document, IndexConfig, IndexPhase, IndexProgress, SearchFilter, SearchOptions, SearchResult, SearchSnippet }
79

@@ -15,6 +17,14 @@ export class SearchDepsUnavailableError extends Error {
1517
}
1618
}
1719

20+
export class EmbeddingIndexMismatchError extends Error {
21+
constructor(dbPath: string, stored: string, current: string) {
22+
super(`Search index uses ${stored}, but embedding settings resolve to ${current}. Rebuild indexes with: skilld update --force`)
23+
this.name = 'EmbeddingIndexMismatchError'
24+
this.cause = { dbPath, stored, current }
25+
}
26+
}
27+
1828
let _fts5Available: boolean | null = null
1929

2030
/**
@@ -49,21 +59,27 @@ export async function getDb(config: Pick<IndexConfig, 'dbPath'>) {
4959
if (!checkFts5())
5060
throw new SearchDepsUnavailableError(new Error('FTS5 module not available'), 'SQLite FTS5 module not available. Search indexing skipped. On Windows, run from WSL where FTS5 is included.')
5161

52-
let createRetriv, autoChunker, sqliteMod, sqliteVec, transformersJs, cachedEmbeddings
62+
const userConfig = readConfig()
63+
const embedModel = resolveEmbedModel(userConfig.embedModel)
64+
const device = resolveEmbedDevice(userConfig.embedDevice)
65+
const embeddingIdentity = getEmbeddingIdentity(embedModel, device)
66+
const identityState = checkIndexEmbeddingIdentity(config.dbPath, embeddingIdentity)
67+
if (identityState._tag === 'Mismatch')
68+
throw new EmbeddingIndexMismatchError(config.dbPath, identityState.stored, identityState.current)
69+
70+
let createRetriv, autoChunker, sqliteMod, sqliteVec, cachedEmbeddings
5371
try {
5472
;([
5573
{ createRetriv },
5674
{ autoChunker },
5775
sqliteMod,
5876
sqliteVec,
59-
{ transformersJs },
6077
{ cachedEmbeddings },
6178
] = await Promise.all([
6279
import('retriv'),
6380
import('retriv/chunkers/auto'),
6481
import('retriv/db/sqlite'),
6582
import('sqlite-vec'),
66-
import('retriv/embeddings/transformers-js'),
6783
import('./embedding-cache.ts'),
6884
]))
6985
}
@@ -72,27 +88,29 @@ export async function getDb(config: Pick<IndexConfig, 'dbPath'>) {
7288
throw new SearchDepsUnavailableError(err)
7389
throw err
7490
}
75-
const userConfig = readConfig()
76-
const embedModel = resolveEmbedModel(userConfig.embedModel)
77-
const device = resolveEmbedDevice(userConfig.embedDevice)
78-
// Cache identity pairs model with device: cached vectors are only valid for
79-
// the embedder that produced them, and backends can differ numerically.
8091
const embeddings = await cachedEmbeddings(
81-
transformersJs({
92+
transformersEmbeddings({
8293
model: embedModel,
83-
// Omitted when `auto` so transformers.js keeps its own device resolution.
8494
...(device ? { device } : {}),
8595
}),
86-
`${embedModel}@${device ?? 'auto'}`,
96+
embeddingIdentity,
8797
)
88-
return createRetriv({
98+
const db = await createRetriv({
8999
driver: sqliteMod.default({
90100
path: config.dbPath,
91101
embeddings,
92102
sqliteVec,
93103
}),
94104
chunking: autoChunker(),
95105
})
106+
try {
107+
recordIndexEmbeddingIdentity(config.dbPath, embeddingIdentity)
108+
}
109+
catch (error) {
110+
await db.close?.()
111+
throw error
112+
}
113+
return db
96114
}
97115

98116
/**

0 commit comments

Comments
 (0)