From b87839434ab6e6750501914cd9fdd3ac76c0bc22 Mon Sep 17 00:00:00 2001 From: jamby77 Date: Mon, 15 Jun 2026 20:09:06 +0300 Subject: [PATCH 1/2] feat(retrieval): Phase 4 query with filters and rerank - Add buildFtSearchQuery: KNN query string with TAG/NUMERIC filter clauses - Add Retriever.query: embed text or accept a precomputed vector, FT.SEARCH, map rows to { id, score, fields, text } stripping reserved fields - Reject both/neither text|vector; empty results return [] - Support optional hybrid:'rerank' via an injectable rerankFn - Export query types, buildFtSearchQuery, SCORE_FIELD --- .../retrieval/src/__tests__/ft-create.test.ts | 18 ++ .../retrieval/src/__tests__/ft-search.test.ts | 48 ++++ .../retrieval/src/__tests__/query.test.ts | 216 ++++++++++++++++++ .../src/__tests__/upsert-delete.test.ts | 18 ++ packages/retrieval/src/fields.ts | 4 + packages/retrieval/src/ft-create.ts | 4 + packages/retrieval/src/ft-search.ts | 41 ++++ packages/retrieval/src/index.ts | 8 +- packages/retrieval/src/retriever.ts | 118 +++++++++- 9 files changed, 471 insertions(+), 4 deletions(-) create mode 100644 packages/retrieval/src/__tests__/ft-search.test.ts create mode 100644 packages/retrieval/src/__tests__/query.test.ts create mode 100644 packages/retrieval/src/fields.ts create mode 100644 packages/retrieval/src/ft-search.ts diff --git a/packages/retrieval/src/__tests__/ft-create.test.ts b/packages/retrieval/src/__tests__/ft-create.test.ts index cf06c70b..e093eb7a 100644 --- a/packages/retrieval/src/__tests__/ft-create.test.ts +++ b/packages/retrieval/src/__tests__/ft-create.test.ts @@ -451,3 +451,21 @@ describe('keyPrefix', () => { expect(() => keyPrefix('')).toThrow(/Index name must not be empty/); }); }); + +describe('buildFtCreateArgs reserved field names', () => { + it('rejects a schema field named __score', () => { + const schema: RetrievalSchema = { + fields: { __score: { type: 'tag' } }, + vector: { metric: 'cosine', algorithm: 'hnsw', dims: 4 }, + }; + expect(() => buildFtCreateArgs('docs', schema)).toThrow(/reserved/i); + }); + + it('rejects a schema field named __text', () => { + const schema: RetrievalSchema = { + fields: { __text: { type: 'text' } }, + vector: { metric: 'cosine', algorithm: 'hnsw', dims: 4 }, + }; + expect(() => buildFtCreateArgs('docs', schema)).toThrow(/reserved/i); + }); +}); diff --git a/packages/retrieval/src/__tests__/ft-search.test.ts b/packages/retrieval/src/__tests__/ft-search.test.ts new file mode 100644 index 00000000..fe215e4a --- /dev/null +++ b/packages/retrieval/src/__tests__/ft-search.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { buildFtSearchQuery } from '../ft-search'; +import type { RetrievalSchema } from '../schema'; + +const schema: RetrievalSchema = { + fields: { + source: { type: 'tag' }, + title: { type: 'text' }, + updated: { type: 'numeric' }, + }, + vector: { metric: 'cosine', algorithm: 'hnsw', dims: 4 }, +}; + +describe('buildFtSearchQuery', () => { + it('emits a bare KNN query with no filter', () => { + expect(buildFtSearchQuery(schema, 10)).toBe('*=>[KNN 10 @embedding $vec AS __score]'); + }); + + it('wraps a single TAG filter clause', () => { + expect(buildFtSearchQuery(schema, 5, { source: 'docs' })).toBe( + '(@source:{docs})=>[KNN 5 @embedding $vec AS __score]', + ); + }); + + it('joins TAG and NUMERIC clauses with AND semantics', () => { + expect(buildFtSearchQuery(schema, 5, { source: 'docs', updated: 1717200000 })).toBe( + '(@source:{docs} @updated:[1717200000 1717200000])=>[KNN 5 @embedding $vec AS __score]', + ); + }); + + it('escapes TAG filter values', () => { + expect(buildFtSearchQuery(schema, 5, { source: 'a:b c' })).toBe( + '(@source:{a\\:b\\ c})=>[KNN 5 @embedding $vec AS __score]', + ); + }); + + it('throws for a filter on an unknown field', () => { + expect(() => buildFtSearchQuery(schema, 5, { missing: 'x' })).toThrow(/unknown/i); + }); + + it('throws for a filter on a TEXT field', () => { + expect(() => buildFtSearchQuery(schema, 5, { title: 'x' })).toThrow(/text/i); + }); + + it('throws when a NUMERIC filter value is not a number', () => { + expect(() => buildFtSearchQuery(schema, 5, { updated: 'recent' })).toThrow(/numeric/i); + }); +}); diff --git a/packages/retrieval/src/__tests__/query.test.ts b/packages/retrieval/src/__tests__/query.test.ts new file mode 100644 index 00000000..1a20573e --- /dev/null +++ b/packages/retrieval/src/__tests__/query.test.ts @@ -0,0 +1,216 @@ +import { describe, it, expect, vi } from 'vitest'; +import { encodeFloat32 } from '@betterdb/valkey-search-kit'; +import { Retriever } from '../retriever'; +import type { RetrievalSchema } from '../schema'; +import type { QueryHit } from '../retriever'; + +const schema: RetrievalSchema = { + fields: { source: { type: 'tag' }, updated: { type: 'numeric' } }, + vector: { metric: 'cosine', algorithm: 'hnsw', dims: 4 }, +}; + +interface Row { + key: string; + fields: Record; +} + +function searchReply(rows: Row[]): unknown[] { + const out: unknown[] = [String(rows.length)]; + for (const row of rows) { + out.push(row.key); + const flat: string[] = []; + for (const [field, value] of Object.entries(row.fields)) { + flat.push(field, value); + } + out.push(flat); + } + return out; +} + +describe('Retriever query', () => { + it('embeds the text, runs FT.SEARCH, and maps rows to hits', async () => { + const vec = [0.1, 0.2, 0.3, 0.4]; + const embedFn = vi.fn(async () => vec); + const reply = searchReply([ + { + key: 'docs:doc:1', + fields: { + source: 'docs', + updated: '1717200000', + __text: 'hello world', + __score: '0.12', + embedding: 'rawbytes', + }, + }, + ]); + const call = vi.fn(async () => reply); + const retriever = new Retriever({ client: { call }, name: 'docs', schema, embedFn }); + + const hits = await retriever.query({ text: 'hi', k: 10, filter: { source: 'docs' } }); + + expect(embedFn).toHaveBeenCalledWith('hi'); + expect(call).toHaveBeenCalledWith( + 'FT.SEARCH', + 'docs:idx', + '(@source:{docs})=>[KNN 10 @embedding $vec AS __score]', + 'PARAMS', + '2', + 'vec', + encodeFloat32(vec), + 'LIMIT', + '0', + '10', + 'DIALECT', + '2', + ); + const expected: QueryHit[] = [ + { + id: 'doc:1', + score: 0.12, + text: 'hello world', + fields: { source: 'docs', updated: '1717200000' }, + }, + ]; + expect(hits).toEqual(expected); + }); + + it('uses a precomputed vector and does not call embedFn', async () => { + const vec = [0.5, 0.5, 0.5, 0.5]; + const embedFn = vi.fn(async () => [0, 0, 0, 0]); + const call = vi.fn(async () => searchReply([])); + const retriever = new Retriever({ client: { call }, name: 'docs', schema, embedFn }); + + await retriever.query({ vector: vec, k: 5 }); + + expect(embedFn).not.toHaveBeenCalled(); + expect(call).toHaveBeenCalledWith( + 'FT.SEARCH', + 'docs:idx', + '*=>[KNN 5 @embedding $vec AS __score]', + 'PARAMS', + '2', + 'vec', + encodeFloat32(vec), + 'LIMIT', + '0', + '5', + 'DIALECT', + '2', + ); + }); + + it('throws when both text and vector are provided', async () => { + const embedFn = vi.fn(async () => [0, 0, 0, 0]); + const call = vi.fn(async () => searchReply([])); + const retriever = new Retriever({ client: { call }, name: 'docs', schema, embedFn }); + + await expect(retriever.query({ text: 'a', vector: [1, 2, 3, 4], k: 5 })).rejects.toThrow( + /both/i, + ); + + expect(call).not.toHaveBeenCalled(); + }); + + it('throws when neither text nor vector is provided', async () => { + const call = vi.fn(async () => searchReply([])); + const retriever = new Retriever({ client: { call }, name: 'docs', schema }); + + await expect(retriever.query({ k: 5 })).rejects.toThrow(/text or/i); + + expect(call).not.toHaveBeenCalled(); + }); + + it('returns an empty array when FT.SEARCH yields no hits', async () => { + const embedFn = vi.fn(async () => [0, 0, 0, 0]); + const call = vi.fn(async () => searchReply([])); + const retriever = new Retriever({ client: { call }, name: 'docs', schema, embedFn }); + + const hits = await retriever.query({ text: 'x', k: 5 }); + + expect(hits).toEqual([]); + }); + + it('reorders hits via rerankFn when hybrid is rerank', async () => { + const embedFn = vi.fn(async () => [0, 0, 0, 0]); + const reply = searchReply([ + { key: 'docs:a', fields: { __text: 'first', __score: '0.9', source: 'docs' } }, + { key: 'docs:b', fields: { __text: 'second', __score: '0.8', source: 'docs' } }, + ]); + const call = vi.fn(async () => reply); + const rerankFn = vi.fn(async (_queryText: string, hits: QueryHit[]) => [...hits].reverse()); + const retriever = new Retriever({ client: { call }, name: 'docs', schema, embedFn, rerankFn }); + + const hits = await retriever.query({ text: 'q', k: 5, hybrid: 'rerank' }); + + const passedHits = rerankFn.mock.calls[0][1]; + expect(passedHits).toEqual([ + { id: 'a', score: 0.9, text: 'first', fields: { source: 'docs' } }, + { id: 'b', score: 0.8, text: 'second', fields: { source: 'docs' } }, + ]); + expect(hits.map((h) => h.id)).toEqual(['b', 'a']); + }); + + it('throws for hybrid rerank without a rerankFn', async () => { + const embedFn = vi.fn(async () => [0, 0, 0, 0]); + const call = vi.fn(async () => searchReply([])); + const retriever = new Retriever({ client: { call }, name: 'docs', schema, embedFn }); + + await expect(retriever.query({ text: 'q', k: 5, hybrid: 'rerank' })).rejects.toThrow( + /rerankFn/, + ); + + expect(call).not.toHaveBeenCalled(); + }); + + it('throws for hybrid rerank without text', async () => { + const rerankFn = vi.fn(async (_q: string, hits: QueryHit[]) => hits); + const call = vi.fn(async () => searchReply([])); + const retriever = new Retriever({ client: { call }, name: 'docs', schema, rerankFn }); + + await expect(retriever.query({ vector: [1, 2, 3, 4], k: 5, hybrid: 'rerank' })).rejects.toThrow( + /text/i, + ); + + expect(call).not.toHaveBeenCalled(); + }); + + it('throws when k is not a positive integer', async () => { + const embedFn = vi.fn(async () => [0, 0, 0, 0]); + const call = vi.fn(async () => searchReply([])); + const retriever = new Retriever({ client: { call }, name: 'docs', schema, embedFn }); + + await expect(retriever.query({ text: 'x', k: 0 })).rejects.toThrow(/positive integer/i); + + expect(call).not.toHaveBeenCalled(); + }); + + it('throws when a precomputed vector has the wrong dimension', async () => { + const call = vi.fn(async () => searchReply([])); + const retriever = new Retriever({ client: { call }, name: 'docs', schema }); + + await expect(retriever.query({ vector: [1, 2], k: 5 })).rejects.toThrow(/dimension/i); + + expect(call).not.toHaveBeenCalled(); + }); + + it('rejects a precomputed vector that mismatches the inferred (cached) dimension', async () => { + const embedFn = vi.fn(async () => [0, 0, 0, 0]); + const noDims: RetrievalSchema = { + fields: { source: { type: 'tag' } }, + vector: { metric: 'cosine', algorithm: 'hnsw' }, + }; + const call = vi.fn(async (command: string) => { + if (command === 'FT.INFO') { + throw new Error("Unknown index name 'docs:idx'"); + } + return searchReply([]); + }); + const retriever = new Retriever({ client: { call }, name: 'docs', schema: noDims, embedFn }); + + await retriever.createIndex(); + + await expect(retriever.query({ vector: [1, 2], k: 5 })).rejects.toThrow(/dimension/i); + const searchCalls = call.mock.calls.filter((args) => args[0] === 'FT.SEARCH'); + expect(searchCalls).toHaveLength(0); + }); +}); diff --git a/packages/retrieval/src/__tests__/upsert-delete.test.ts b/packages/retrieval/src/__tests__/upsert-delete.test.ts index 1b3b44e1..3e6c6542 100644 --- a/packages/retrieval/src/__tests__/upsert-delete.test.ts +++ b/packages/retrieval/src/__tests__/upsert-delete.test.ts @@ -199,6 +199,24 @@ describe('Retriever upsert', () => { expect(hsetCalls).toHaveLength(0); }); + it('rejects an entry field named __score', async () => { + const embedFn = vi.fn(fakeEmbed(4)); + const call = vi.fn(async () => 'OK'); + const retriever = new Retriever({ + client: { call }, + name: 'docs', + schema: schemaWithDims, + embedFn, + }); + + await expect( + retriever.upsert([{ id: 'doc:1', text: 'x', fields: { __score: 'oops' } }]), + ).rejects.toThrow(/reserved/i); + + const hsetCalls = call.mock.calls.filter((args) => args[0] === 'HSET'); + expect(hsetCalls).toHaveLength(0); + }); + it('probes embedFn once and caches dims across multiple entries', async () => { const embedFn = vi.fn(fakeEmbed(8)); const call = vi.fn(async () => 'OK'); diff --git a/packages/retrieval/src/fields.ts b/packages/retrieval/src/fields.ts new file mode 100644 index 00000000..8e4ac3a6 --- /dev/null +++ b/packages/retrieval/src/fields.ts @@ -0,0 +1,4 @@ +export const TEXT_FIELD = '__text'; +export const SCORE_FIELD = '__score'; + +export const RESERVED_FIELD_NAMES: readonly string[] = [TEXT_FIELD, SCORE_FIELD]; diff --git a/packages/retrieval/src/ft-create.ts b/packages/retrieval/src/ft-create.ts index 733ed542..dcb5360f 100644 --- a/packages/retrieval/src/ft-create.ts +++ b/packages/retrieval/src/ft-create.ts @@ -1,4 +1,5 @@ import type { RetrievalSchema, FtCapabilities, FieldSpec, VectorSpec } from './schema'; +import { RESERVED_FIELD_NAMES } from './fields'; const HNSW_DEFAULTS = { m: 16, @@ -29,6 +30,9 @@ function validateFieldNames(fields: Record, vectorFieldName: `Field name '${name}' collides with the vector field name '${vectorFieldName}'`, ); } + if (RESERVED_FIELD_NAMES.includes(name)) { + throw new Error(`Field name '${name}' is reserved and cannot be used in the schema`); + } } } diff --git a/packages/retrieval/src/ft-search.ts b/packages/retrieval/src/ft-search.ts new file mode 100644 index 00000000..84c133c0 --- /dev/null +++ b/packages/retrieval/src/ft-search.ts @@ -0,0 +1,41 @@ +import { escapeTag } from '@betterdb/valkey-search-kit'; +import type { RetrievalSchema } from './schema'; +import { resolveVectorFieldName } from './ft-create'; +import { SCORE_FIELD } from './fields'; + +export type QueryFilter = Record; + +function buildFilterClause(field: string, value: string | number, schema: RetrievalSchema): string { + const spec = schema.fields[field]; + if (spec === undefined) { + throw new Error(`Cannot filter on unknown field '${field}'`); + } + if (spec.type === 'tag') { + return `@${field}:{${escapeTag(String(value))}}`; + } + if (spec.type === 'numeric') { + if (typeof value !== 'number') { + throw new Error(`Numeric filter on field '${field}' requires a number, got: ${typeof value}`); + } + return `@${field}:[${value} ${value}]`; + } + throw new Error( + `Cannot filter on TEXT field '${field}'; only tag and numeric fields are filterable`, + ); +} + +export function buildFtSearchQuery( + schema: RetrievalSchema, + k: number, + filter?: QueryFilter, +): string { + const vectorField = resolveVectorFieldName(schema.vector); + const clauses: string[] = []; + if (filter !== undefined) { + for (const [field, value] of Object.entries(filter)) { + clauses.push(buildFilterClause(field, value, schema)); + } + } + const filterExpr = clauses.length > 0 ? `(${clauses.join(' ')})` : '*'; + return `${filterExpr}=>[KNN ${k} @${vectorField} $vec AS ${SCORE_FIELD}]`; +} diff --git a/packages/retrieval/src/index.ts b/packages/retrieval/src/index.ts index 837166c3..16e085f9 100644 --- a/packages/retrieval/src/index.ts +++ b/packages/retrieval/src/index.ts @@ -10,11 +10,17 @@ export type { FtCapabilities, } from './schema'; export { buildFtCreateArgs, indexName, keyPrefix, resolveVectorFieldName } from './ft-create'; -export { Retriever, TEXT_FIELD } from './retriever'; +export { TEXT_FIELD, SCORE_FIELD } from './fields'; +export { buildFtSearchQuery } from './ft-search'; +export type { QueryFilter } from './ft-search'; +export { Retriever } from './retriever'; export type { RetrieverClient, RetrieverOptions, IndexDescription, EmbedFn, UpsertEntry, + RerankFn, + QueryHit, + QueryOptions, } from './retriever'; diff --git a/packages/retrieval/src/retriever.ts b/packages/retrieval/src/retriever.ts index 3a3512b9..6af48f71 100644 --- a/packages/retrieval/src/retriever.ts +++ b/packages/retrieval/src/retriever.ts @@ -3,14 +3,33 @@ import { isIndexNotFoundError, parseDimensionFromInfo, parseFtInfoStats, + parseFtSearchResponse, + type FtSearchHit, } from '@betterdb/valkey-search-kit'; import type { RetrievalSchema, FtCapabilities } from './schema'; import { buildFtCreateArgs, indexName, keyPrefix, resolveVectorFieldName } from './ft-create'; - -export const TEXT_FIELD = '__text'; +import { buildFtSearchQuery, type QueryFilter } from './ft-search'; +import { TEXT_FIELD, SCORE_FIELD, RESERVED_FIELD_NAMES } from './fields'; export type EmbedFn = (text: string) => Promise; +export type RerankFn = (queryText: string, hits: QueryHit[]) => Promise; + +export interface QueryHit { + id: string; + score: number; + text: string; + fields: Record; +} + +export interface QueryOptions { + text?: string; + vector?: number[]; + k: number; + filter?: QueryFilter; + hybrid?: 'rerank'; +} + export interface RetrieverClient { call(command: string, ...args: (string | Buffer | number)[]): Promise; } @@ -28,6 +47,7 @@ export interface RetrieverOptions { schema: RetrievalSchema; capabilities?: FtCapabilities; embedFn?: EmbedFn; + rerankFn?: RerankFn; } export interface UpsertEntry { @@ -42,6 +62,7 @@ export class Retriever { private readonly schema: RetrievalSchema; private readonly capabilities?: FtCapabilities; private readonly embedFn?: EmbedFn; + private readonly rerankFn?: RerankFn; private resolvedDims?: number; constructor(options: RetrieverOptions) { @@ -50,6 +71,7 @@ export class Retriever { this.schema = options.schema; this.capabilities = options.capabilities; this.embedFn = options.embedFn; + this.rerankFn = options.rerankFn; } private async resolveDims(): Promise { @@ -95,7 +117,7 @@ export class Retriever { private assertNoReservedFields(entry: UpsertEntry, vectorField: string): void { for (const field of Object.keys(entry.fields)) { - if (field === TEXT_FIELD || field === vectorField) { + if (RESERVED_FIELD_NAMES.includes(field) || field === vectorField) { throw new Error( `Entry '${entry.id}' uses reserved field name '${field}'; choose a different field name`, ); @@ -164,4 +186,94 @@ export class Retriever { indexingState: stats.indexingState, }; } + + private knownDims(): number | undefined { + const declared = this.schema.vector.dims; + if (declared !== undefined && Number.isInteger(declared) && declared > 0) { + return declared; + } + return this.resolvedDims; + } + + private async resolveQueryVector(options: QueryOptions): Promise { + if (options.vector !== undefined && options.text !== undefined) { + throw new Error('query accepts either text or a precomputed vector, not both'); + } + if (options.vector !== undefined) { + const dims = this.knownDims(); + if (dims !== undefined && options.vector.length !== dims) { + throw new Error( + `Query vector dimension mismatch: index expects ${dims}, got ${options.vector.length}`, + ); + } + return options.vector; + } + if (options.text !== undefined) { + return this.embed(options.text); + } + throw new Error('query requires either text or a precomputed vector'); + } + + private mapHit(hit: FtSearchHit): QueryHit { + const prefix = keyPrefix(this.name); + let id = hit.key; + if (hit.key.startsWith(prefix)) { + id = hit.key.slice(prefix.length); + } + const vectorField = resolveVectorFieldName(this.schema.vector); + const fields: Record = {}; + for (const [field, value] of Object.entries(hit.fields)) { + if (field === TEXT_FIELD || field === SCORE_FIELD || field === vectorField) { + continue; + } + fields[field] = value; + } + return { + id, + score: Number(hit.fields[SCORE_FIELD]), + text: hit.fields[TEXT_FIELD] ?? '', + fields, + }; + } + + private resolveRerank(options: QueryOptions): { fn: RerankFn; text: string } | null { + if (options.hybrid !== 'rerank') { + return null; + } + if (this.rerankFn === undefined) { + throw new Error("query({ hybrid: 'rerank' }) requires a rerankFn"); + } + if (options.text === undefined) { + throw new Error("query({ hybrid: 'rerank' }) requires text to rerank against"); + } + return { fn: this.rerankFn, text: options.text }; + } + + async query(options: QueryOptions): Promise { + if (!Number.isInteger(options.k) || options.k <= 0) { + throw new Error(`query k must be a positive integer, got: ${options.k}`); + } + const rerank = this.resolveRerank(options); + const vector = await this.resolveQueryVector(options); + const queryString = buildFtSearchQuery(this.schema, options.k, options.filter); + const raw = await this.client.call( + 'FT.SEARCH', + indexName(this.name), + queryString, + 'PARAMS', + '2', + 'vec', + encodeFloat32(vector), + 'LIMIT', + '0', + String(options.k), + 'DIALECT', + '2', + ); + const hits = parseFtSearchResponse(raw).map((hit) => this.mapHit(hit)); + if (rerank !== null) { + return rerank.fn(rerank.text, hits); + } + return hits; + } } From 33287ab813f6538f2e62dd6032bf374cd2c617f7 Mon Sep 17 00:00:00 2001 From: jamby77 Date: Thu, 18 Jun 2026 09:35:36 +0300 Subject: [PATCH 2/2] fix(retrieval): document QueryHit.score distance semantics and validate vector dims - Add JSDoc on QueryHit.score clarifying it is a KNN distance (lower is closer), so consumers rank ascending instead of assuming higher is better - Resolve inferred dimensions for precomputed-vector queries so a mismatched vector is rejected before reaching FT.SEARCH, matching the text path --- packages/retrieval/src/__tests__/query.test.ts | 15 +++++++++++++++ packages/retrieval/src/retriever.ts | 18 +++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/retrieval/src/__tests__/query.test.ts b/packages/retrieval/src/__tests__/query.test.ts index 1a20573e..71c6f964 100644 --- a/packages/retrieval/src/__tests__/query.test.ts +++ b/packages/retrieval/src/__tests__/query.test.ts @@ -213,4 +213,19 @@ describe('Retriever query', () => { const searchCalls = call.mock.calls.filter((args) => args[0] === 'FT.SEARCH'); expect(searchCalls).toHaveLength(0); }); + + it('rejects a precomputed vector against inferred dims before the index is created', async () => { + const embedFn = vi.fn(async () => [0, 0, 0, 0]); + const noDims: RetrievalSchema = { + fields: { source: { type: 'tag' } }, + vector: { metric: 'cosine', algorithm: 'hnsw' }, + }; + const call = vi.fn(async () => searchReply([])); + const retriever = new Retriever({ client: { call }, name: 'docs', schema: noDims, embedFn }); + + await expect(retriever.query({ vector: [1, 2], k: 5 })).rejects.toThrow(/dimension/i); + + const searchCalls = call.mock.calls.filter((args) => args[0] === 'FT.SEARCH'); + expect(searchCalls).toHaveLength(0); + }); }); diff --git a/packages/retrieval/src/retriever.ts b/packages/retrieval/src/retriever.ts index 6af48f71..a7aecb5d 100644 --- a/packages/retrieval/src/retriever.ts +++ b/packages/retrieval/src/retriever.ts @@ -17,6 +17,11 @@ export type RerankFn = (queryText: string, hits: QueryHit[]) => Promise; @@ -195,12 +200,23 @@ export class Retriever { return this.resolvedDims; } + private async queryVectorDims(): Promise { + const known = this.knownDims(); + if (known !== undefined) { + return known; + } + if (this.embedFn === undefined) { + return undefined; + } + return this.resolveDims(); + } + private async resolveQueryVector(options: QueryOptions): Promise { if (options.vector !== undefined && options.text !== undefined) { throw new Error('query accepts either text or a precomputed vector, not both'); } if (options.vector !== undefined) { - const dims = this.knownDims(); + const dims = await this.queryVectorDims(); if (dims !== undefined && options.vector.length !== dims) { throw new Error( `Query vector dimension mismatch: index expects ${dims}, got ${options.vector.length}`,