Skip to content

Bugs and doc mismatches found in retriv 0.15.0 #20

Open

Description

@harlan-zw

🤖 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.99

Suggestion: 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:

  • $ne drops documents without the field in SQL drivers. Cloudflare keeps them, but it drops a document that has no metadata at all.
  • nested.level reads 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 become 1 and are compared with the text 'true'.
  • A field name such as my-field throws 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.002

Suggestion: 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:34

Suggestion: 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.3

Suggestion: 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: 5

Suggestion: 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 4

Suggestion: 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

  1. harlan-github-agent commented on Oct 8, 2026

    @harlan-github-agent

    🤖 ISSUE TRIAGE

    Harlan Agent Kit posted this automated triage. It is not Harlan's personal assessment or commitment. AI open source policy.

    • Route: Ready to spec
    • Difficulty: 5/5
    • Impact: 5/5
    • Reproduction: Yes
    • Codebase review: Needed
    • Summary: Well-researched 20-item defect report against v0.15.0, all verified against current main. Seven silent-wrong-result bugs (pgvector ivfflat built on empty table with recall 0.11; codeChunker drops comments and trailing statements; filter sem
    • Next action: Write a specification that triages the 20 items into staged PRs: first the silent-wrong-result bugs (pgvector index strategy, chunker boundary/tail coverage, unified filter semantics incl. jsonb #>> paths and boolean casts, over-fetch window, stale-chunk cleanup on re-index, category persistence, transformersJs dimension validation), then crashes and peer/engines ranges, then doc and type mismatches and cleanups; record the chosen fix strategy for each decision point (HNSW default vs deferred index creation, native metadata filters vs documented over-fetch window, filter semantics canonical source).
  2. added
    harlan-agent-ready-to-specIssue triage found work that needs a specification before implementation.
    harlan-agent-runningAn Agent holds a Task on this issue or pull request right now.
    and removed
    harlan-agent-runningAn Agent holds a Task on this issue or pull request right now.
    on Oct 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    harlan-agent-ready-to-specIssue triage found work that needs a specification before implementation.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions