Skip to content

Commit 25ab21b

Browse files
committed
fix(search): invalidate the embedding cache when the model changes
The vector cache keys entries by text hash alone and validated only that the dimension count was unchanged. That was sufficient while the model was fixed, but selecting a model makes it reachable: `bge-large-en-v1.5` and `bge-m3` are both 1024d, so switching between them kept every cached vector and served one model's embeddings for the other's queries. The result is not a crash but silently wrong ranking — documents embedded in one space, queries in another. Observed on a 43-document corpus where the correct answer dropped out of the top 3 entirely. Record the embedder identity alongside dimensions and clear the cache when it changes. Identity is `<model>@<device>`, since the same model on a different backend can differ numerically. A cache with no stored model predates this key and has unknown provenance, so it is also cleared.
1 parent e030617 commit 25ab21b

3 files changed

Lines changed: 134 additions & 8 deletions

File tree

src/retriv/embedding-cache.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,16 @@ function createSqliteStorage(db: DatabaseSync) {
5050
}
5151
}
5252

53-
export async function cachedEmbeddings(config: EmbeddingConfig): Promise<EmbeddingConfig> {
53+
/**
54+
* Wrap an embedding provider with the on-disk vector cache.
55+
*
56+
* `model` identifies which embedder produced the cached vectors. Entries are
57+
* 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
59+
* qwen3-embedding:0.6b are both 1024d) would silently mix embedding spaces and
60+
* destroy ranking. Dimensions alone cannot catch that; the model id can.
61+
*/
62+
export async function cachedEmbeddings(config: EmbeddingConfig, model?: string): Promise<EmbeddingConfig> {
5463
const { cachedEmbeddings: retrivCached } = await import('retriv/embeddings/cached')
5564
const db = await openDb()
5665
const storage = createSqliteStorage(db)
@@ -63,10 +72,18 @@ export async function cachedEmbeddings(config: EmbeddingConfig): Promise<Embeddi
6372
const setMetaStmt = db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)')
6473

6574
const storedDims = getMetaStmt.get('dimensions') as { value: string } | undefined
66-
if (storedDims && Number(storedDims.value) !== resolved.dimensions) {
75+
const storedModel = getMetaStmt.get('model') as { value: string } | undefined
76+
const dimsChanged = storedDims && Number(storedDims.value) !== resolved.dimensions
77+
// A cache written before this key existed has unknown provenance, so
78+
// treat a missing stored model as a mismatch once a model is supplied.
79+
const modelChanged = model !== undefined && storedModel?.value !== model
80+
81+
if (dimsChanged || modelChanged)
6782
db.exec('DELETE FROM embeddings')
68-
}
83+
6984
setMetaStmt.run('dimensions', String(resolved.dimensions))
85+
if (model !== undefined)
86+
setMetaStmt.run('model', model)
7087

7188
return resolved
7289
},

src/retriv/index.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,18 @@ export async function getDb(config: Pick<IndexConfig, 'dbPath'>) {
7373
throw err
7474
}
7575
const userConfig = readConfig()
76+
const embedModel = resolveEmbedModel(userConfig.embedModel)
7677
const device = resolveEmbedDevice(userConfig.embedDevice)
77-
const embeddings = await cachedEmbeddings(transformersJs({
78-
model: resolveEmbedModel(userConfig.embedModel),
79-
// Omitted when `auto` so transformers.js keeps its own device resolution.
80-
...(device ? { device } : {}),
81-
}))
78+
// Cache identity pairs model with device: cached vectors are only valid for
79+
// the embedder that produced them, and backends can differ numerically.
80+
const embeddings = await cachedEmbeddings(
81+
transformersJs({
82+
model: embedModel,
83+
// Omitted when `auto` so transformers.js keeps its own device resolution.
84+
...(device ? { device } : {}),
85+
}),
86+
`${embedModel}@${device ?? 'auto'}`,
87+
)
8288
return createRetriv({
8389
driver: sqliteMod.default({
8490
path: config.dbPath,
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { DatabaseSync } from 'node:sqlite'
2+
import { describe, expect, it } from 'vitest'
3+
4+
/**
5+
* Guards the cache-invalidation rule in `src/retriv/embedding-cache.ts`.
6+
*
7+
* Vectors are keyed by text hash alone, so the only thing preventing one
8+
* model's vectors being served to another is the stored identity. Dimensions
9+
* are not enough: `Xenova/bge-large-en-v1.5` and `ollama:qwen3-embedding:0.6b`
10+
* are both 1024d, so switching between them would silently mix embedding
11+
* spaces and wreck ranking.
12+
*
13+
* This reimplements the decision against an in-memory database so the rule is
14+
* pinned without touching the user's real cache.
15+
*/
16+
function applyIdentity(db: DatabaseSync, dimensions: number, model?: string): void {
17+
const get = db.prepare('SELECT value FROM meta WHERE key = ?')
18+
const set = db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)')
19+
20+
const storedDims = get.get('dimensions') as { value: string } | undefined
21+
const storedModel = get.get('model') as { value: string } | undefined
22+
const dimsChanged = storedDims && Number(storedDims.value) !== dimensions
23+
const modelChanged = model !== undefined && storedModel?.value !== model
24+
25+
if (dimsChanged || modelChanged)
26+
db.exec('DELETE FROM embeddings')
27+
28+
set.run('dimensions', String(dimensions))
29+
if (model !== undefined)
30+
set.run('model', model)
31+
}
32+
33+
function makeDb(): DatabaseSync {
34+
const db = new DatabaseSync(':memory:')
35+
db.exec('CREATE TABLE embeddings (text_hash TEXT PRIMARY KEY, embedding BLOB NOT NULL)')
36+
db.exec('CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)')
37+
return db
38+
}
39+
40+
function seed(db: DatabaseSync, n = 3): void {
41+
const stmt = db.prepare('INSERT OR IGNORE INTO embeddings (text_hash, embedding) VALUES (?, ?)')
42+
for (let i = 0; i < n; i++)
43+
stmt.run(`hash-${i}`, Buffer.from(new Float32Array([i, i, i]).buffer))
44+
}
45+
46+
function count(db: DatabaseSync): number {
47+
return (db.prepare('SELECT COUNT(*) c FROM embeddings').get() as { c: number }).c
48+
}
49+
50+
describe('embedding cache identity', () => {
51+
it('keeps cached vectors when model and dimensions are unchanged', () => {
52+
const db = makeDb()
53+
applyIdentity(db, 1024, 'model-a')
54+
seed(db)
55+
applyIdentity(db, 1024, 'model-a')
56+
expect(count(db)).toBe(3)
57+
db.close()
58+
})
59+
60+
// The regression: equal width, different model.
61+
it('clears cached vectors when the model changes at identical dimensions', () => {
62+
const db = makeDb()
63+
applyIdentity(db, 1024, 'Xenova/bge-large-en-v1.5@webgpu')
64+
seed(db)
65+
expect(count(db)).toBe(3)
66+
67+
applyIdentity(db, 1024, 'ollama:qwen3-embedding:0.6b')
68+
expect(count(db)).toBe(0)
69+
db.close()
70+
})
71+
72+
it('clears cached vectors when dimensions change', () => {
73+
const db = makeDb()
74+
applyIdentity(db, 384, 'model-a')
75+
seed(db)
76+
applyIdentity(db, 1024, 'model-a')
77+
expect(count(db)).toBe(0)
78+
db.close()
79+
})
80+
81+
// Same model on a different backend: numeric output can differ, so vectors
82+
// are only interchangeable within a device.
83+
it('clears cached vectors when only the device changes', () => {
84+
const db = makeDb()
85+
applyIdentity(db, 1024, 'Xenova/bge-large-en-v1.5@cpu')
86+
seed(db)
87+
applyIdentity(db, 1024, 'Xenova/bge-large-en-v1.5@webgpu')
88+
expect(count(db)).toBe(0)
89+
db.close()
90+
})
91+
92+
// A cache written before the model key existed has unknown provenance.
93+
it('clears a legacy cache that has no stored model', () => {
94+
const db = makeDb()
95+
applyIdentity(db, 1024)
96+
seed(db)
97+
expect(count(db)).toBe(3)
98+
99+
applyIdentity(db, 1024, 'model-a')
100+
expect(count(db)).toBe(0)
101+
db.close()
102+
})
103+
})

0 commit comments

Comments
 (0)