Repository navigation
Bugs and doc mismatches found in retriv 0.15.0 #20
Description
🤖 An agent (Claude Code) filed this issue while writing the retriv package Skill with skilld's generate-package-skill. It reproduced each item against retriv 0.15.0.
Each item below has a minimal repro. They run with node <file>.ts (Node type stripping) from a directory where retriv@0.15.0 and the named peers are installed.
Environment: Node 24.18.0 unless stated, pnpm 12.4.2, TypeScript 6.0.3, pgvector 0.8.7 (pgvector/pgvector:pg17), @cloudflare/workers-types 5.20261006.1. The Cloudflare repros use an in-memory stand-in for the Vectorize binding. They test retriv's code, not Vectorize itself.
Order: silent wrong results (1 to 7), crashes (8 and 9), README and JSDoc mismatches (10 to 15), cleanups (16 to 20).
Silent wrong results
1. pgvector: the index is built on an empty table, so recall is low
Expected (per README or types): search() returns the nearest documents, like the other vector drivers.
Observed: The driver creates an ivfflat index with lists = 100 when it opens, before any rows exist. ivfflat.probes stays at its default of 1. Recall@10 against exact search is 0.11 here. REINDEX only lifts it to 0.16. HNSW under the same index name gives 0.99, and the driver keeps it because it uses CREATE INDEX IF NOT EXISTS. With bge-small-en-v1.5 on 800 real paragraphs and 40 queries, recall@10 was 0.21 as created, 0.44 after REINDEX, 0.89 with REINDEX plus ivfflat.probes = 10, and 1.00 with HNSW.
Source: src/db/pgvector.ts#L62-L69
// docker run -d --rm --name pgv -e POSTGRES_PASSWORD=test -p 127.0.0.1:55433:5432 pgvector/pgvector:pg17
import pg from 'pg'
import pgvector from 'retriv/db/pgvector'
const url = 'postgres://postgres:test@127.0.0.1:55433/postgres'
// Deterministic dense 32-dim unit vectors derived from the text.
function vec(text: string): number[] {
let h = 2166136261
for (const c of text) h = Math.imul(h ^ c.charCodeAt(0), 16777619)
const v = Array.from({ length: 32 }, () => ((h = Math.imul(h ^ (h >>> 15), 2246822507)) >>> 0) / 2 ** 32 - 0.5)
const n = Math.hypot(...v)
return v.map(x => x / n)
}
const embeddings = { resolve: async () => ({ dimensions: 32, embedder: async (t: string[]) => t.map(vec) }) }
const docs = Array.from({ length: 2000 }, (_, i) => ({ id: `d${i}`, content: `doc ${i}` }))
const queries = Array.from({ length: 30 }, (_, i) => `query ${i}`)
const dot = (a: number[], b: number[]) => a.reduce((s, x, i) => s + x * b[i]!, 0)
const exact = queries.map(q => docs.map(d => [d.id, dot(vec(q), vec(d.content))] as const).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([id]) => id))
const admin = new pg.Pool({ connectionString: url })
await admin.query('DROP TABLE IF EXISTS vectors')
async function measure(label: string) {
const db = await pgvector({ url, embeddings })
let rows = 0
let hits = 0
for (const [i, q] of queries.entries()) {
const r = await db.search(q, { limit: 10 })
rows += r.length
hits += r.filter(x => exact[i]!.includes(x.id)).length
}
console.log(label, '| rows per query:', rows / queries.length, '| recall@10:', (hits / (queries.length * 10)).toFixed(2))
await db.close!()
}
const first = await pgvector({ url, embeddings }) // creates the table and the ivfflat index while empty
await first.index(docs)
await first.close!()
await measure('as created')
await admin.query('REINDEX INDEX vectors_embedding_idx')
await measure('after REINDEX')
await admin.query('DROP INDEX vectors_embedding_idx')
await admin.query('CREATE INDEX vectors_embedding_idx ON vectors USING hnsw (embedding vector_cosine_ops)')
await measure('hnsw')
await admin.end()$ node pgvector-recall.ts
as created | rows per query: 9.933333333333334 | recall@10: 0.11
after REINDEX | rows per query: 10 | recall@10: 0.16
hnsw | rows per query: 10 | recall@10: 0.99Suggestion: create an HNSW index by default, or create the index only after the first index() call.
2. codeChunker drops text from the index
Expected (per README or types): The README says the chunker "splits on function/class boundaries". Every line of the source should land in some chunk.
Observed: At each chunk boundary, the next chunk starts at the declaration node. Comments and JSDoc before it are dropped. A variable chunk also loses its export const keyword. Each chunk ends at its last declaration, so statements after the last declaration are dropped too. None of this text is searchable.
Source: src/chunkers/typescript.ts#L328-L344, #L379-L389
import { codeChunker } from 'retriv/chunkers/typescript'
const source = `export function alpha() {
return 1
}
/** Beta doc comment */
export function beta() {
return 2
}
export const GAMMA = 3
registerPlugin('trailing statement')
`
const chunks = await codeChunker({ maxChunkSize: 40 })(source, { id: 'src/a.ts' })
const indexed = chunks.map(c => c.text).join('\n')
for (const text of ['Beta doc comment', 'export const GAMMA', "registerPlugin('trailing statement')"])
console.log(JSON.stringify(text), 'indexed:', indexed.includes(text))
console.log(chunks.map(c => c.text))$ node codechunker-drops.ts
"Beta doc comment" indexed: false
"export const GAMMA" indexed: false
"registerPlugin('trailing statement')" indexed: false
[
'export function alpha() {\n return 1\n}',
'export function beta() {\n return 2\n}',
'GAMMA = 3'
]Suggestion: start each chunk where the previous one ended, at the statement's full start, and flush the tail after the last declaration.
3. Filters behave differently by driver
Expected (per README or types): The README documents one MongoDB-style filter DSL and one operator table for every driver.
Observed: On the same four documents:
$nedrops documents without the field in SQL drivers. Cloudflare keeps them, but it drops a document that has no metadata at all.nested.levelreads nested JSON in sqlite-fts. pgvector reads it as one literal key (metadata->>'nested.level'), and Cloudflare matches nothing.{ flag: true }never matches in pgvector. Booleans become1and are compared with the text'true'.- A field name such as
my-fieldthrows in SQL drivers and returns[]in Cloudflare.
Upstash uses the same in-memory path as Cloudflare. I did not run it against Upstash.
Source: src/filter.ts#L19-L30, src/filter.ts#L137-L157
// pgvector needs: docker run -d --rm --name pgv -e POSTGRES_PASSWORD=test -p 127.0.0.1:55433:5432 pgvector/pgvector:pg17
import pg from 'pg'
import cloudflare from 'retriv/db/cloudflare'
import pgvector from 'retriv/db/pgvector'
import sqliteFts from 'retriv/db/sqlite-fts'
const embeddings = { resolve: async () => ({ dimensions: 2, embedder: async (t: string[]) => t.map(() => [1, 0]) }) }
const store = new Map<string, any>() // in-memory stand-in for a Vectorize binding
const binding = {
upsert: async (vs: any[]) => { for (const v of vs) store.set(v.id, v) },
insert: async (vs: any[]) => { for (const v of vs) store.set(v.id, v) },
deleteByIds: async () => {},
query: async (_v: any, o: any) => ({ matches: [...store.values()].slice(0, o.topK).map(v => ({ id: v.id, score: 0.9, metadata: v.metadata })) }),
}
const url = 'postgres://postgres:test@127.0.0.1:55433/postgres'
const admin = new pg.Pool({ connectionString: url })
await admin.query('DROP TABLE IF EXISTS vectors')
const drivers = {
'sqlite-fts': await sqliteFts(),
'pgvector': await pgvector({ url, embeddings }),
'cloudflare': await cloudflare({ binding, embeddings }),
}
await admin.query('DROP INDEX vectors_embedding_idx') // exact scan, so only the filter logic differs
const docs = [
{ id: 'a', content: 'auth', metadata: { status: 'draft', flag: true, nested: { level: 1 } } },
{ id: 'b', content: 'auth', metadata: { status: 'live' } },
{ id: 'c', content: 'auth', metadata: { kind: 'doc' } },
{ id: 'd', content: 'auth' },
]
const filters: Record<string, any>[] = [{ status: { $ne: 'draft' } }, { 'nested.level': 1 }, { flag: true }, { 'my-field': 'x' }]
for (const [name, db] of Object.entries(drivers)) {
await db.index(docs)
const row: string[] = []
for (const filter of filters)
row.push(await db.search('auth', { filter }).then(r => JSON.stringify(r.map(x => x.id)), (e: Error) => `throws "${e.message}"`))
console.log(name.padEnd(10), row.join(' '))
}
await drivers.pgvector.close!()
await admin.end()$ node filters.ts
sqlite-fts ["b"] ["a"] ["a"] throws "Invalid filter field name: "my-field""
pgvector ["b"] [] [] throws "Invalid filter field name: "my-field""
cloudflare ["b","c"] [] ["a"] []Suggestion: build pgvector paths with #>> and cast booleans, and make matchesFilter follow the SQL rules for missing fields, dotted paths, and field names.
4. Upstash and Cloudflare lose filter matches outside the limit * 4 window
Expected (per README or types): The README says filters give "exact result counts". Its per-driver table lists Upstash and Cloudflare as "Post-search filtering (4x over-fetch)".
Observed: These drivers fetch limit * 4 nearest vectors, then filter in memory. A match ranked past that window is dropped without a warning. Here the only guide document is at rank 10. limit: 2 fetches 8 rows and returns []. limit: 3 fetches 12 rows and finds it.
Source: src/db/cloudflare.ts#L85, src/db/upstash.ts#L70
import cloudflare from 'retriv/db/cloudflare'
const embeddings = { resolve: async () => ({ dimensions: 2, embedder: async (t: string[]) => t.map(() => [1, 0]) }) }
const store: any[] = [] // in-memory stand-in for a Vectorize binding; returns vectors in insertion order
const binding = {
upsert: async (vs: any[]) => { store.push(...vs) },
insert: async (vs: any[]) => { store.push(...vs) },
deleteByIds: async () => {},
query: async (_v: any, o: any) => ({ matches: store.slice(0, o.topK).map((v, i) => ({ id: v.id, score: 1 - i / 100, metadata: v.metadata })) }),
}
const db = await cloudflare({ binding, embeddings })
await db.index([
...Array.from({ length: 9 }, (_, i) => ({ id: `api${i}`, content: 'x', metadata: { type: 'api' } })),
{ id: 'guide', content: 'x', metadata: { type: 'guide' } },
])
console.log('limit 2:', (await db.search('x', { limit: 2, filter: { type: 'guide' } })).map(r => r.id))
console.log('limit 3:', (await db.search('x', { limit: 3, filter: { type: 'guide' } })).map(r => r.id))$ node filter-window.ts
limit 2: []
limit 3: [ 'guide' ]Suggestion: pass the filter to the native metadata filters of Vectorize and Upstash, or document the window.
5. Re-indexing a chunked document leaves stale chunks
Expected (per README or types): index() with an existing id replaces that document. All drivers upsert by id.
Observed: With chunking on, rows are stored as <id>#chunk-<n>. A second index() of the same id writes new rows but keeps every old chunk. Search then returns stale text. Only remove([id]) before index() clears them.
Source: src/retriv.ts#L95-L120
import { createRetriv } from 'retriv'
import { markdownChunker } from 'retriv/chunkers/markdown'
import sqliteFts from 'retriv/db/sqlite-fts'
const search = await createRetriv({ driver: sqliteFts(), chunking: markdownChunker({ chunkSize: 100, chunkOverlap: 0 }) })
const long = Array.from({ length: 4 }, (_, i) => `## Part ${i}\n\nold wombat text ${i} ${'filler '.repeat(8)}`).join('\n\n')
await search.index([{ id: 'notes.md', content: long }])
await search.index([{ id: 'notes.md', content: 'new wombat text' }]) // same id, now one chunk
console.log((await search.search('wombat', { limit: 10 })).map(r => r.id))$ node stale-chunks.ts
[
'notes.md',
'notes.md#chunk-0',
'notes.md#chunk-1',
'notes.md#chunk-2',
'notes.md#chunk-3'
]Suggestion: when chunking is on, remove the old rows for each parent id inside index().
6. Split-category search stops after a restart
Expected (per README or types): The README says "one query runs per seen category". A reopened index should keep the same results.
Observed: The seen categories live only in memory. A new process on the same database searches unsplit until it indexes documents from two categories again. The minority code.ts result disappears.
Source: src/retriv.ts#L73, #L181
import { rmSync } from 'node:fs'
import { createRetriv } from 'retriv'
import sqliteFts from 'retriv/db/sqlite-fts'
rmSync('./cats.db', { force: true })
const open = () => createRetriv({ driver: sqliteFts({ path: './cats.db' }), categories: doc => doc.metadata?.type })
const docs = [
{ id: 'code.ts', content: 'auth', metadata: { type: 'code' } },
...Array.from({ length: 10 }, (_, i) => ({ id: `doc${i}.md`, content: 'auth auth auth', metadata: { type: 'docs' } })),
]
const first = await open()
await first.index(docs)
console.log('same process:', (await first.search('auth', { limit: 3 })).map(r => r.id))
console.log('input doc after index():', docs[0]!.metadata)
await first.close!()
const reopened = await open()
console.log('after reopen:', (await reopened.search('auth', { limit: 3 })).map(r => r.id))$ node categories-restart.ts
same process: [ 'code.ts', 'doc0.md', 'doc1.md' ]
input doc after index(): { type: 'code', category: 'code' }
after reopen: [ 'doc0.md', 'doc1.md', 'doc2.md' ]Suggestion: read the distinct category values from the store on open, or accept a categories list.
7. transformersJs({ dimensions }) gives wrong vectors without an error
Expected (per README or types): The option is typed as "Embedding dimensions (auto-detected for known models)". A wrong value should fail.
Observed: The embedder slices the flat model output by the given size. With dimensions: 256 on the 384-dim default model, the vectors have the wrong offsets. Two related sentences drop from a similarity of 0.879 to 0.002. Nothing throws.
Source: src/embeddings/transformers-js.ts#L124, #L137
import { transformersJs } from 'retriv/embeddings/transformers-js'
const cos = (a: ArrayLike<number>, b: ArrayLike<number>) => { let s = 0; for (let i = 0; i < a.length; i++) s += a[i]! * b[i]!; return s }
const texts = ['password hashing', 'how to hash a password']
for (const dimensions of [undefined, 256]) {
const { embedder } = await transformersJs({ dimensions }).resolve() // default model bge-small-en-v1.5 emits 384
const [a, b] = await embedder(texts)
console.log(`dimensions: ${dimensions}`, 'length', a!.length, 'similarity', cos(a!, b!).toFixed(3))
}$ node transformers-dimensions.ts
dimensions: undefined length 384 similarity 0.879
dimensions: 256 length 256 similarity 0.002Suggestion: throw when output.data.length !== texts.length * dimensions.
Crashes
8. TypeScript 7 breaks codeChunker and autoChunker
Expected (per README or types): The README says pnpm add typescript sqlite-vec @huggingface/transformers. The peer range is typescript: ^5.0.0 || ^6.0.0-0.
Observed: pnpm add typescript now installs 7.0.2. Its root export is only lib/version.cjs, so the chunkers crash on the first code document. The "typescript is required" error also says npm i typescript, which installs the same broken version.
Source: package.json#L150, src/chunkers/typescript.ts#L9-L10, README.md#L55
import { codeChunker } from 'retriv/chunkers/typescript'
await codeChunker()('export const a = 1', { id: 'a.ts' })$ pnpm add retriv@0.15.0 typescript
+ retriv 0.15.0
+ typescript 7.0.2
$ pnpm peers check
✕ unmet peer typescript
Installed: 7.0.2
Wanted:
"^5.0.0 || ^6.0.0-0":
$ node ts7.ts
TypeError: Cannot read properties of undefined (reading 'TS')
at .../node_modules/retriv/dist/chunkers/typescript.mjs:268:34Suggestion: change the README and the error message to typescript@^6.
9. SQLite drivers with FTS5 fail on Node 22.14
Expected (per README or types): The README says "Node.js >= 22.5" for the SQLite drivers.
Observed: On Node 22.14, sqlite and sqlite-fts fail with no such module: fts5. sqlite-vec works there. Node 22.23.3 and 24.x work. According to the Node 22 changelog, node:sqlite was unflagged in 22.13.0 (nodejs/node#55890) and FTS5 was enabled in 22.16.0 (nodejs/node#57621). I ran only 22.14 and 22.23.3 from the 22 line.
Source: src/db/sqlite-fts.ts#L69, README.md#L284
import sqliteFts from 'retriv/db/sqlite-fts'
await sqliteFts()
console.log('ok', process.version)$ node --version && node --no-warnings node22.mjs
v22.14.0
Error: no such module: fts5
$ node --version && node --no-warnings node22.mjs
v22.23.3
ok v22.23.3Suggestion: say Node 22.16+ for sqlite and sqlite-fts, and set engines to match.
README and JSDoc mismatches
10. Scores are RRF values, not 0 to 1 relevance
Expected (per README or types): SearchResult.score is typed as "Relevance score (0-1, higher is better)". The README example shows scores of 0.82 and 0.71.
Observed: The hybrid sqlite driver returns RRF scores near 0.04. sqlite-fts returns RRF scores for multi-token queries, and about 1 for single tokens. search() turns a code identifier into a multi-token query, so identifier searches also get RRF scores.
Source: src/retriv.ts#L22, src/types.ts#L45, README.md#L90
import { createRetriv } from 'retriv'
import sqlite from 'retriv/db/sqlite'
import sqliteFts from 'retriv/db/sqlite-fts'
const embeddings = { resolve: async () => ({ dimensions: 2, embedder: async (t: string[]) => t.map(s => [s.length, 1]) }) }
const docs = [{ id: 'auth.ts', content: 'hashPassword hashes a raw password' }, { id: 'guide.md', content: 'Password hashing guide' }]
const hybrid = await createRetriv({ driver: sqlite({ embeddings }) })
await hybrid.index(docs)
console.log('sqlite hybrid:', (await hybrid.search('password hashing')).map(r => r.score))
const fts = await createRetriv({ driver: sqliteFts() })
await fts.index(docs)
console.log('sqlite-fts "password":', (await fts.search('password')).map(r => r.score))
console.log('sqlite-fts "password hashing":', (await fts.search('password hashing')).map(r => r.score))$ node scores.ts
sqlite hybrid: [ 0.04098360655737705, 0.04032258064516129 ]
sqlite-fts "password": [ 0.9999989268304201, 0.9999990638306637 ]
sqlite-fts "password hashing": [ 0.02459016393442623, 0.024193548387096774 ]Suggestion: document the RRF range in the README example and the score JSDoc.
11. returnContent returns a 5-line snippet, and rerankers score that snippet
Expected (per README or types): The README says returnContent will "Include original content in results". The type says "Original content (if returnContent: true)".
Observed: Every driver returns extractSnippet() output: up to 5 lines around the best matching line. rerank forces returnContent on, so rerankers score the same 5 lines and never see the full chunk.
Source: src/utils/extract-snippet.ts#L123, README.md#L362
import { createRetriv } from 'retriv'
import sqliteFts from 'retriv/db/sqlite-fts'
let rerankerSaw = 0
const search = await createRetriv({
driver: sqliteFts(),
rerank: { resolve: async () => async (_q, results) => { rerankerSaw = results[0]!.content!.split('\n').length; return results } },
})
const content = Array.from({ length: 30 }, (_, i) => i === 15 ? 'line 15: pasta sauce' : `line ${i}: filler`).join('\n')
await search.index([{ id: 'recipe.md', content }])
const [hit] = await search.search('pasta', { returnContent: true })
console.log('stored lines: 30 | returned lines:', hit!.content!.split('\n').length, '| reranker saw lines:', rerankerSaw)$ node return-content.ts
stored lines: 30 | returned lines: 5 | reranker saw lines: 5Suggestion: document the snippet, and give rerankers the stored chunk text.
12. Code chunks have no range or scope, and large declarations are not split
Expected (per README or types): The README example shows a code chunk with range: [140, 312] and scope: [{ name: 'AuthService', type: 'class' }]. ChunkEntity.isPartial is documented as "true if entity was split across chunks".
Observed: codeChunker sets only lineRange. It never sets range, and scope stays empty for top-level declarations. A 1,891-character class stays one chunk with the default maxChunkSize of 1000, so isPartial never appears.
Source: src/chunkers/typescript.ts#L468-L471, README.md#L94-L96, src/types.ts#L281
import { createRetriv } from 'retriv'
import { codeChunker } from 'retriv/chunkers/typescript'
import sqliteFts from 'retriv/db/sqlite-fts'
const methods = Array.from({ length: 40 }, (_, i) => ` method${i}(raw: string) { return raw + '${i}' }`).join('\n')
const source = `export class AuthService {\n${methods}\n hashPassword(raw: string) { return raw }\n}\n\nexport function other() { return 1 }\n`
const search = await createRetriv({ driver: sqliteFts(), chunking: codeChunker() })
await search.index([{ id: 'src/auth.ts', content: source }])
const [hit] = await search.search('hashPassword')
console.log('source chars:', source.length, '| maxChunkSize: 1000 (default)')
console.log('hit id:', hit!.id, '| chunk chars:', hit!._chunk && source.split('\n').slice(hit!._chunk.lineRange![0] - 1, hit!._chunk.lineRange![1]).join('\n').length)
console.log('_chunk:', JSON.stringify({ ...hit!._chunk, entities: hit!._chunk?.entities?.map(e => ({ name: e.name, isPartial: e.isPartial })) }))$ node chunk-fields.ts
source chars: 1930 | maxChunkSize: 1000 (default)
hit id: src/auth.ts#chunk-0 | chunk chars: 1891
_chunk: {"parentId":"src/auth.ts","index":0,"lineRange":[1,43],"entities":[{"name":"AuthService"}]}Suggestion: set range from the chunk offsets, and split an oversized declaration into its members.
13. returnMetadata defaults to true, and false also removes _chunk
Expected (per README or types): The README and the type say metadata is returned "If returnMetadata: true".
Observed: Every driver defaults returnMetadata to true. With returnMetadata: false, _chunk also disappears, because createRetriv rebuilds _chunk from the returned metadata.
Source: src/db/sqlite.ts#L214, src/retriv.ts#L131, src/types.ts#L49
import { createRetriv } from 'retriv'
import { markdownChunker } from 'retriv/chunkers/markdown'
import sqliteFts from 'retriv/db/sqlite-fts'
const search = await createRetriv({ driver: sqliteFts(), chunking: markdownChunker({ chunkSize: 60, chunkOverlap: 0 }) })
await search.index([{ id: 'a.md', content: `## One\n\nkoala ${'x '.repeat(30)}\n\n## Two\n\nmore text`, metadata: { type: 'docs' } }])
console.log('no option:', JSON.stringify((await search.search('koala'))[0]))
console.log('returnMetadata: false:', JSON.stringify((await search.search('koala', { returnMetadata: false }))[0]))$ node return-metadata.ts
no option: {"id":"a.md#chunk-0","score":0.9999991248851321,"metadata":{"type":"docs"},"_chunk":{"parentId":"a.md","index":0,"range":[0,60],"lineRange":[1,3]}}
returnMetadata: false: {"id":"a.md#chunk-0","score":0.9999991248851321}Suggestion: document the default, and always fetch chunk metadata internally.
14. Google embeddings ignore GOOGLE_API_KEY
Expected (per README or types): The apiKey JSDoc says it "falls back to GOOGLE_API_KEY env".
Observed: @ai-sdk/google reads only GOOGLE_GENERATIVE_AI_API_KEY.
Source: src/embeddings/google.ts#L9
import { google } from 'retriv/embeddings/google'
process.env.GOOGLE_API_KEY = 'my-key'
const { embedder } = await google().resolve()
await embedder(['hello']).catch((e: Error) => console.log(e.message))$ node google-env.ts
Google Generative AI API key is missing. Pass it using the 'apiKey' parameter or the GOOGLE_GENERATIVE_AI_API_KEY environment variable.Suggestion: change the comment to GOOGLE_GENERATIVE_AI_API_KEY.
15. The Cloudflare binding type rejects the workers-types Vectorize binding
Expected (per README or types): The JSDoc example passes binding: env.VECTORIZE.
Observed: Under strict, Vectorize from @cloudflare/workers-types is not assignable to retriv's VectorizeIndexBinding. retriv types insert, upsert, and deleteByIds as returning Promise<void>. The legacy VectorizeIndex type fails the same way.
Source: src/db/cloudflare.ts#L8-L13, #L32
import cloudflare from 'retriv/db/cloudflare'
import { cloudflareWorkersAi } from 'retriv/embeddings/cloudflare-workers-ai'
export default {
async fetch(_req: Request, env: { VECTORIZE: Vectorize, AI: Ai }) {
await cloudflare({ binding: env.VECTORIZE, embeddings: cloudflareWorkersAi({ ai: env.AI }) })
return new Response('ok')
},
}$ tsc -p tsconfig.vectorize.json # strict, types: ["@cloudflare/workers-types"]
vectorize-types.ts(6,24): error TS2322: Type 'Vectorize' is not assignable to type 'VectorizeIndexBinding'.
The types returned by 'insert(...)' are incompatible between these types.
Type 'Promise<VectorizeAsyncMutation>' is not assignable to type 'Promise<void>'.
Type 'VectorizeAsyncMutation' is not assignable to type 'void'.Suggestion: type those methods as returning Promise<unknown>.
Cleanups
16. categories mutates the documents passed to index()
Expected (per README or types): The README says the category "is stored in metadata.category". It does not say the caller's objects change.
Observed: index() writes category into the caller's doc.metadata.
Source: src/retriv.ts#L80
import { createRetriv } from 'retriv'
import sqliteFts from 'retriv/db/sqlite-fts'
const search = await createRetriv({ driver: sqliteFts(), categories: () => 'code' })
const doc = { id: 'a.ts', content: 'x', metadata: { lang: 'ts' } }
await search.index([doc])
console.log(doc.metadata)$ node categories-mutate.ts
{ lang: 'ts', category: 'code' }Suggestion: copy each document before adding the category.
17. remove() returns the number of ids passed, not the number removed
Expected (per README or types): remove resolves to { count }. The natural reading is the count of removed documents.
Observed: Removing one real id and two missing ids returns { count: 3 }.
Source: src/db/sqlite-fts.ts#L202
import sqliteFts from 'retriv/db/sqlite-fts'
const db = await sqliteFts()
await db.index([{ id: 'a', content: 'x' }])
console.log(await db.remove!(['a', 'missing-1', 'missing-2']))$ node remove-count.ts
{ count: 3 }Suggestion: sum the changes of each delete.
18. cachedEmbeddings keys the cache on the text only
Expected (per README or types): The JSDoc says it serves "cached embeddings for previously-seen text". Another model should not reuse those vectors.
Observed: The hash covers only the text. A second model with the same storage gets the first model's vectors. Here the dimensions differ, so it fails loudly. With equal dimensions, it would return the wrong vectors without an error.
Source: src/embeddings/cached.ts#L16-L18
import type { Embedding } from 'retriv'
import sqliteVec from 'retriv/db/sqlite-vec'
import { cachedEmbeddings } from 'retriv/embeddings/cached'
const cache = new Map<string, Embedding>()
const storage = { get: (h: string) => cache.get(h) ?? null, set: (h: string, e: Embedding) => { cache.set(h, e) } }
const model = (dimensions: number) => ({ resolve: async () => ({ dimensions, embedder: async (t: string[]) => t.map(() => Array.from({ length: dimensions }, () => 1)) }) })
await (await sqliteVec({ embeddings: cachedEmbeddings(model(4), { storage }) })).index([{ id: '1', content: 'hello' }])
await (await sqliteVec({ embeddings: cachedEmbeddings(model(8), { storage }) })).index([{ id: '1', content: 'hello' }]).catch((e: Error) => console.log(e.message))$ node cached-key.ts
Vector dimension mismatch: expected 8, got 4Suggestion: add a namespace option, or mix the model id into the hash.
19. @libsql/client 0.18 is outside the peer range but works
Expected (per README or types): The peer range is ^0.14.0 || ^0.15.0 || ^0.16.0 || ^0.17.0.
Observed: pnpm add @libsql/client installs 0.18.0 and reports an unmet peer. A local file: database works with it.
Source: package.json#L144
import libsql from 'retriv/db/libsql'
const embeddings = { resolve: async () => ({ dimensions: 2, embedder: async (t: string[]) => t.map(() => [1, 0]) }) }
const db = await libsql({ url: 'file:./test.db', embeddings })
await db.index([{ id: 'a', content: 'hello' }])
console.log(await db.search('hello'))$ pnpm add retriv@0.15.0 @libsql/client
+ @libsql/client 0.18.0
$ pnpm peers check
✕ unmet peer @libsql/client
Installed: 0.18.0
Wanted:
"^0.14.0 || ^0.15.0 || ^0.16.0 || ^0.17.0":
$ node libsql.ts
[ { id: 'a', score: 1 } ]Suggestion: add ^0.18.0 to the range.
20. The createRetriv() return type marks methods optional and exposes a test helper
Expected (per README or types): createRetriv() always returns remove, listIds, clear, and close.
Observed: The return type is SearchProvider & { _testSetCategories? }, so a strict call to search.remove() fails with TS2722. _testSetCategories is also public in the type, and the search._testSetCategories?.(...) line compiles.
Source: src/retriv.ts#L46, src/types.ts#L139-L154
import { createRetriv } from 'retriv'
import sqliteFts from 'retriv/db/sqlite-fts'
const search = await createRetriv({ driver: sqliteFts() })
await search.remove(['a'])
search._testSetCategories?.(['x'])$ tsc -p tsconfig.optional.json # strict
optional-methods.ts(5,7): error TS2722: Cannot invoke an object which is possibly 'undefined'.Suggestion: return a type with required methods, and move the test helper out of the public type.
Activity
harlan-github-agent commentedon Oct 8, 2026
🤖 ISSUE TRIAGE