diff --git a/README.md b/README.md index e5a9c07..bc81954 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,64 @@ console.log(schema.searchable_fields); client.close(); ``` +### Vector Search + +The Flux client provides typed convenience methods for all vector search modes: + +```typescript +import { FluxClient, SearchMode, buildSearchBody } from '@foxnose/sdk'; + +// Semantic search (auto-generated embeddings) +const results = await client.vectorSearch('articles', { + query: 'machine learning in healthcare', + top_k: 10, + similarity_threshold: 0.7, +}); + +// Custom embedding search +const results = await client.vectorFieldSearch('articles', { + field: 'content_embedding', + query_vector: [0.012, -0.034, 0.056 /* ... */], + top_k: 20, +}); + +// Hybrid text + vector search +const results = await client.hybridSearch('articles', { + query: 'ML applications', + find_text: { query: 'machine learning' }, + vector_weight: 0.7, + text_weight: 0.3, +}); + +// Boosted search (keywords boosted by vector similarity) +const results = await client.boostedSearch('articles', { + find_text: { query: 'python tutorial' }, + query: 'beginner programming guide', + boost_factor: 1.5, +}); + +// Extra parameters (where, sort) are forwarded to the API +const results = await client.vectorSearch('articles', { + query: 'climate change', + limit: 5, + sort: '-published_at', + where: { category: 'science' }, +}); +``` + +You can also use `buildSearchBody()` for full control with the raw `search()` method: + +```typescript +const body = buildSearchBody({ + search_mode: SearchMode.HYBRID, + find_text: { query: 'python' }, + vector_search: { query: 'programming tutorials', top_k: 10 }, + hybrid_config: { vector_weight: 0.6, text_weight: 0.4 }, + limit: 20, +}); +const results = await client.search('articles', body); +``` + ### API Folder Route Descriptions You can configure per-route descriptions when connecting a folder to an API. diff --git a/package.json b/package.json index 1ec284e..d3b1903 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@foxnose/sdk", - "version": "0.2.3", + "version": "0.3.0", "description": "Official FoxNose SDK for TypeScript and JavaScript", "license": "Apache-2.0", "type": "module", diff --git a/src/config.ts b/src/config.ts index bab8e51..27db171 100644 --- a/src/config.ts +++ b/src/config.ts @@ -19,7 +19,7 @@ export const DEFAULT_RETRY_CONFIG: Readonly = { methods: ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE'], }; -export const SDK_VERSION = '0.2.1'; +export const SDK_VERSION = '0.3.0'; export const DEFAULT_USER_AGENT = `foxnose-sdk-js/${SDK_VERSION}`; diff --git a/src/flux/client.ts b/src/flux/client.ts index 5ecc4ad..9f1de06 100644 --- a/src/flux/client.ts +++ b/src/flux/client.ts @@ -2,6 +2,14 @@ import type { AuthStrategy } from '../auth/types.js'; import type { RetryConfig } from '../config.js'; import { createConfig } from '../config.js'; import { HttpTransport } from '../http.js'; +import type { + HybridConfig, + SearchRequest, + VectorBoostConfig, + VectorFieldSearch, + VectorSearch, +} from './models.js'; +import { SearchMode, buildSearchBody, mergeExtra } from './models.js'; function cleanPrefix(prefix: string): string { const value = prefix.replace(/^\/+|\/+$/g, ''); @@ -24,6 +32,65 @@ export interface FluxClientOptions { defaultHeaders?: Record; } +export interface VectorSearchOptions { + query: string; + fields?: string[]; + top_k?: number; + similarity_threshold?: number; + limit?: number; + offset?: number; + [extra: string]: any; +} + +export interface VectorFieldSearchOptions { + field: string; + query_vector: number[]; + top_k?: number; + similarity_threshold?: number; + limit?: number; + offset?: number; + [extra: string]: any; +} + +export interface HybridSearchOptions { + query: string; + find_text: Record; + fields?: string[]; + top_k?: number; + similarity_threshold?: number; + vector_weight?: number; + text_weight?: number; + rerank_results?: boolean; + limit?: number; + offset?: number; + [extra: string]: any; +} + +export interface BoostedSearchOptions { + find_text: Record; + query?: string; + field?: string; + query_vector?: number[]; + top_k?: number; + similarity_threshold?: number; + boost_factor?: number; + boost_similarity_threshold?: number; + max_boost_results?: number; + limit?: number; + offset?: number; + [extra: string]: any; +} + +function stripUndefined(obj: Record): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + if (value !== undefined) { + result[key] = value; + } + } + return result; +} + /** * Client for FoxNose Flux delivery APIs. * @@ -72,6 +139,138 @@ export class FluxClient { return this.transport.request('POST', path, { jsonBody: body }); } + /** Semantic search using auto-generated embeddings. */ + async vectorSearch(folderPath: string, options: VectorSearchOptions): Promise { + const { query, fields, top_k = 10, similarity_threshold, limit, offset, ...rest } = options; + const extra = stripUndefined(rest); + const vs: VectorSearch = { query, fields, top_k, similarity_threshold }; + const req: SearchRequest = { + search_mode: SearchMode.VECTOR, + vector_search: vs, + limit, + offset, + }; + const body = mergeExtra(buildSearchBody(req), extra); + return this.search(folderPath, body); + } + + /** Search using custom pre-computed embeddings. */ + async vectorFieldSearch( + folderPath: string, + options: VectorFieldSearchOptions, + ): Promise { + const { + field, + query_vector, + top_k = 10, + similarity_threshold, + limit, + offset, + ...rest + } = options; + const extra = stripUndefined(rest); + const vfs: VectorFieldSearch = { field, query_vector, top_k, similarity_threshold }; + const req: SearchRequest = { + search_mode: SearchMode.VECTOR, + vector_field_search: vfs, + limit, + offset, + }; + const body = mergeExtra(buildSearchBody(req), extra); + return this.search(folderPath, body); + } + + /** Blended text + vector search with configurable weights. */ + async hybridSearch(folderPath: string, options: HybridSearchOptions): Promise { + const { + query, + find_text, + fields, + top_k = 10, + similarity_threshold, + vector_weight = 0.6, + text_weight = 0.4, + rerank_results = true, + limit, + offset, + ...rest + } = options; + const extra = stripUndefined(rest); + const vs: VectorSearch = { query, fields, top_k, similarity_threshold }; + const hybrid: HybridConfig = { vector_weight, text_weight, rerank_results }; + const req: SearchRequest = { + search_mode: SearchMode.HYBRID, + find_text, + vector_search: vs, + hybrid_config: hybrid, + limit, + offset, + }; + const body = mergeExtra(buildSearchBody(req), extra); + return this.search(folderPath, body); + } + + /** Text search with results boosted by vector similarity. */ + async boostedSearch(folderPath: string, options: BoostedSearchOptions): Promise { + const { + find_text, + query, + field, + query_vector, + top_k = 10, + similarity_threshold, + boost_factor = 1.5, + boost_similarity_threshold, + max_boost_results = 20, + limit, + offset, + ...rest + } = options; + const extra = stripUndefined(rest); + + const hasAuto = query != null; + const hasCustom = field != null || query_vector != null; + + if (hasAuto && hasCustom) { + throw new Error( + "Provide either 'query' for auto-generated embeddings " + + "or 'field' + 'query_vector' for custom embeddings, not both", + ); + } + + let vs: VectorSearch | undefined; + let vfs: VectorFieldSearch | undefined; + + if (hasAuto) { + vs = { query: query!, top_k, similarity_threshold }; + } else if (field != null && query_vector != null) { + vfs = { field, query_vector, top_k, similarity_threshold }; + } else { + throw new Error( + "Provide either 'query' for auto-generated embeddings " + + "or 'field' + 'query_vector' for custom embeddings", + ); + } + + const boostConfig: VectorBoostConfig = { + boost_factor, + similarity_threshold: boost_similarity_threshold, + max_boost_results, + }; + + const req: SearchRequest = { + search_mode: SearchMode.VECTOR_BOOSTED, + find_text, + vector_search: vs, + vector_field_search: vfs, + vector_boost_config: boostConfig, + limit, + offset, + }; + const body = mergeExtra(buildSearchBody(req), extra); + return this.search(folderPath, body); + } + async getRouter(): Promise { const path = `/${this.apiPrefix}/_router`; return this.transport.request('GET', path); diff --git a/src/flux/index.ts b/src/flux/index.ts index 09c4858..78441d7 100644 --- a/src/flux/index.ts +++ b/src/flux/index.ts @@ -1,2 +1,16 @@ export { FluxClient } from './client.js'; -export type { FluxClientOptions } from './client.js'; +export type { + FluxClientOptions, + VectorSearchOptions, + VectorFieldSearchOptions, + HybridSearchOptions, + BoostedSearchOptions, +} from './client.js'; +export { SearchMode, buildSearchBody, mergeExtra } from './models.js'; +export type { + VectorSearch, + VectorFieldSearch, + VectorBoostConfig, + HybridConfig, + SearchRequest, +} from './models.js'; diff --git a/src/flux/models.ts b/src/flux/models.ts new file mode 100644 index 0000000..0cad22c --- /dev/null +++ b/src/flux/models.ts @@ -0,0 +1,232 @@ +/** + * Types and validation for Flux vector search requests. + */ + +/** Search mode for the Flux search endpoint. */ +export const SearchMode = { + TEXT: 'text', + VECTOR: 'vector', + VECTOR_BOOSTED: 'vector_boosted', + HYBRID: 'hybrid', +} as const; + +export type SearchMode = (typeof SearchMode)[keyof typeof SearchMode]; + +/** Configuration for auto-generated embedding search. */ +export interface VectorSearch { + query: string; + fields?: string[]; + top_k?: number; + similarity_threshold?: number; +} + +/** Configuration for custom pre-computed embedding search. */ +export interface VectorFieldSearch { + field: string; + query_vector: number[]; + top_k?: number; + similarity_threshold?: number; +} + +/** Configuration for vector-boosted search mode. */ +export interface VectorBoostConfig { + boost_factor?: number; + similarity_threshold?: number; + max_boost_results?: number; +} + +/** Configuration for hybrid (text + vector) search mode. */ +export interface HybridConfig { + vector_weight?: number; + text_weight?: number; + rerank_results?: boolean; +} + +/** Full search request payload for the Flux search endpoint. */ +export interface SearchRequest { + search_mode?: SearchMode; + find_text?: Record; + find_phrase?: Record; + vector_search?: VectorSearch; + vector_field_search?: VectorFieldSearch; + vector_boost_config?: VectorBoostConfig; + hybrid_config?: HybridConfig; + limit?: number; + offset?: number; + [extra: string]: any; +} + +const SEARCH_REQUEST_KNOWN_KEYS = new Set([ + 'search_mode', + 'find_text', + 'find_phrase', + 'vector_search', + 'vector_field_search', + 'vector_boost_config', + 'hybrid_config', + 'limit', + 'offset', +]); + +function checkFinite(value: number, name: string): void { + if (!Number.isFinite(value)) { + throw new Error(`${name} must be a finite number`); + } +} + +function checkThreshold(value: number | undefined, name: string): void { + if (value != null) { + checkFinite(value, name); + if (value < 0 || value > 1) { + throw new Error(`${name} must be between 0.0 and 1.0`); + } + } +} + +function checkPositiveInt(value: number | undefined, name: string): void { + if (value != null) { + checkFinite(value, name); + if (!Number.isInteger(value) || value < 1) { + throw new Error(`${name} must be a positive integer (>= 1)`); + } + } +} + +function validateVectorSearch(vs: VectorSearch): void { + checkPositiveInt(vs.top_k, 'top_k'); + checkThreshold(vs.similarity_threshold, 'similarity_threshold'); +} + +function validateVectorFieldSearch(vfs: VectorFieldSearch): void { + if (!vfs.query_vector || vfs.query_vector.length === 0) { + throw new Error('query_vector must not be empty'); + } + for (let i = 0; i < vfs.query_vector.length; i++) { + if (!Number.isFinite(vfs.query_vector[i])) { + throw new Error(`query_vector[${i}] must be a finite number`); + } + } + checkPositiveInt(vfs.top_k, 'top_k'); + checkThreshold(vfs.similarity_threshold, 'similarity_threshold'); +} + +function validateBoostConfig(cfg: VectorBoostConfig): void { + if (cfg.boost_factor != null) { + checkFinite(cfg.boost_factor, 'boost_factor'); + if (cfg.boost_factor <= 0) { + throw new Error('boost_factor must be > 0'); + } + } + checkThreshold(cfg.similarity_threshold, 'similarity_threshold'); + checkPositiveInt(cfg.max_boost_results, 'max_boost_results'); +} + +function validateHybridConfig(cfg: HybridConfig): void { + if (cfg.vector_weight != null) { + checkFinite(cfg.vector_weight, 'vector_weight'); + if (cfg.vector_weight < 0 || cfg.vector_weight > 1) { + throw new Error('vector_weight must be between 0.0 and 1.0'); + } + } + if (cfg.text_weight != null) { + checkFinite(cfg.text_weight, 'text_weight'); + if (cfg.text_weight < 0 || cfg.text_weight > 1) { + throw new Error('text_weight must be between 0.0 and 1.0'); + } + } + const vw = cfg.vector_weight ?? 0.6; + const tw = cfg.text_weight ?? 0.4; + if (Math.abs(vw + tw - 1.0) > 1e-6) { + throw new Error(`vector_weight + text_weight must equal 1.0, got ${vw + tw}`); + } +} + +/** + * Validate a {@link SearchRequest} and return a clean body object (no undefined values). + * + * Enforces cross-field constraints: + * - `vector_search` and `vector_field_search` are mutually exclusive + * - Each search mode has specific required/forbidden fields + */ +export function buildSearchBody(req: SearchRequest): Record { + const mode = req.search_mode ?? SearchMode.TEXT; + const vs = req.vector_search; + const vfs = req.vector_field_search; + const boost = req.vector_boost_config; + const hybrid = req.hybrid_config; + const hasText = req.find_text != null || req.find_phrase != null; + + // Field-level validation + if (vs) validateVectorSearch(vs); + if (vfs) validateVectorFieldSearch(vfs); + if (boost) validateBoostConfig(boost); + if (hybrid) validateHybridConfig(hybrid); + + // Mutual exclusion + if (vs && vfs) { + throw new Error('vector_search and vector_field_search are mutually exclusive'); + } + + // Per-mode rules + if (mode === SearchMode.TEXT) { + if (vs) throw new Error('vector_search is not allowed in text search mode'); + if (vfs) throw new Error('vector_field_search is not allowed in text search mode'); + if (boost) throw new Error('vector_boost_config is not allowed in text search mode'); + if (hybrid) throw new Error('hybrid_config is not allowed in text search mode'); + } else if (mode === SearchMode.VECTOR) { + if (!vs && !vfs) { + throw new Error('vector search mode requires vector_search or vector_field_search'); + } + if (boost) throw new Error('vector_boost_config is not allowed in vector search mode'); + if (hybrid) throw new Error('hybrid_config is not allowed in vector search mode'); + } else if (mode === SearchMode.VECTOR_BOOSTED) { + if (!vs && !vfs) { + throw new Error('vector_boosted mode requires vector_search or vector_field_search'); + } + if (!hasText) throw new Error('vector_boosted mode requires find_text or find_phrase'); + if (hybrid) throw new Error('hybrid_config is not allowed in vector_boosted mode'); + } else if (mode === SearchMode.HYBRID) { + if (vfs) throw new Error('vector_field_search is not allowed in hybrid mode'); + if (!vs) throw new Error('hybrid mode requires vector_search'); + if (!hasText) throw new Error('hybrid mode requires find_text or find_phrase'); + if (boost) throw new Error('vector_boost_config is not allowed in hybrid mode'); + } else { + const validModes = Object.values(SearchMode).join(', '); + throw new Error(`Unknown search_mode: "${mode}". Valid modes: ${validModes}`); + } + + // Build clean body (strip undefined) + const body: Record = {}; + for (const [key, value] of Object.entries(req)) { + if (value !== undefined) { + body[key] = value; + } + } + if (!body.search_mode) { + body.search_mode = mode; + } + return body; +} + +/** + * Merge extra body params, rejecting keys that conflict with SearchRequest fields. + * @internal + */ +export function mergeExtra( + validated: Record, + extra: Record, +): Record { + const conflicts: string[] = []; + for (const key of Object.keys(extra)) { + if (SEARCH_REQUEST_KNOWN_KEYS.has(key)) { + conflicts.push(key); + } + } + if (conflicts.length > 0) { + throw new Error( + `extra keys conflict with SearchRequest fields: ${conflicts.sort().join(', ')}. ` + + 'Use the explicit parameters instead.', + ); + } + return { ...validated, ...extra }; +} diff --git a/src/index.ts b/src/index.ts index 772607c..f99a584 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,7 +33,21 @@ export type { // Flux Client export { FluxClient } from './flux/index.js'; -export type { FluxClientOptions } from './flux/index.js'; +export { SearchMode, buildSearchBody } from './flux/index.js'; +export type { + FluxClientOptions, + VectorSearchOptions, + VectorFieldSearchOptions, + HybridSearchOptions, + BoostedSearchOptions, +} from './flux/index.js'; +export type { + VectorSearch, + VectorFieldSearch, + VectorBoostConfig, + HybridConfig, + SearchRequest, +} from './flux/index.js'; // Models export type { diff --git a/tests/flux/client.test.ts b/tests/flux/client.test.ts index 6ed2048..27a736e 100644 --- a/tests/flux/client.test.ts +++ b/tests/flux/client.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FluxClient } from '../../src/flux/client.js'; +import { SearchMode, buildSearchBody, mergeExtra } from '../../src/flux/models.js'; import type { AuthStrategy, RequestData } from '../../src/auth/types.js'; const dummyAuth: AuthStrategy = { @@ -182,4 +183,462 @@ describe('FluxClient', () => { expect(headers['Authorization']).toBe('Simple pub:sec'); }); }); + + describe('vectorSearch', () => { + it('sends correct body', async () => { + const fetchMock = setupMockFetch({ results: [] }); + const client = createClient(); + await client.vectorSearch('articles', { query: 'semantic query', top_k: 5 }); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://env-123.fxns.io/v1/articles/_search'); + const body = JSON.parse(init?.body as string); + expect(body.search_mode).toBe('vector'); + expect(body.vector_search.query).toBe('semantic query'); + expect(body.vector_search.top_k).toBe(5); + expect(body.vector_field_search).toBeUndefined(); + }); + + it('forwards extra body params', async () => { + const fetchMock = setupMockFetch({ results: [] }); + const client = createClient(); + await client.vectorSearch('articles', { + query: 'hello', + sort: '-score', + where: { category: 'tech' }, + }); + + const body = JSON.parse(fetchMock.mock.calls[0][1]?.body as string); + expect(body.sort).toBe('-score'); + expect(body.where).toEqual({ category: 'tech' }); + }); + }); + + describe('vectorFieldSearch', () => { + it('sends correct body', async () => { + const fetchMock = setupMockFetch({ results: [] }); + const client = createClient(); + await client.vectorFieldSearch('articles', { + field: 'speaker_embedding', + query_vector: [0.1, 0.2, 0.3], + similarity_threshold: 0.8, + }); + + const body = JSON.parse(fetchMock.mock.calls[0][1]?.body as string); + expect(body.search_mode).toBe('vector'); + expect(body.vector_field_search.field).toBe('speaker_embedding'); + expect(body.vector_field_search.query_vector).toEqual([0.1, 0.2, 0.3]); + expect(body.vector_field_search.similarity_threshold).toBe(0.8); + }); + }); + + describe('hybridSearch', () => { + it('sends correct body', async () => { + const fetchMock = setupMockFetch({ results: [] }); + const client = createClient(); + await client.hybridSearch('articles', { + query: 'semantic query', + find_text: { query: 'keyword' }, + vector_weight: 0.7, + text_weight: 0.3, + }); + + const body = JSON.parse(fetchMock.mock.calls[0][1]?.body as string); + expect(body.search_mode).toBe('hybrid'); + expect(body.find_text).toEqual({ query: 'keyword' }); + expect(body.vector_search.query).toBe('semantic query'); + expect(body.hybrid_config.vector_weight).toBe(0.7); + expect(body.hybrid_config.text_weight).toBe(0.3); + }); + }); + + describe('boostedSearch', () => { + it('sends correct body with auto-embeddings', async () => { + const fetchMock = setupMockFetch({ results: [] }); + const client = createClient(); + await client.boostedSearch('articles', { + find_text: { query: 'keyword' }, + query: 'semantic boost', + boost_factor: 2.0, + max_boost_results: 10, + }); + + const body = JSON.parse(fetchMock.mock.calls[0][1]?.body as string); + expect(body.search_mode).toBe('vector_boosted'); + expect(body.find_text).toEqual({ query: 'keyword' }); + expect(body.vector_search.query).toBe('semantic boost'); + expect(body.vector_boost_config.boost_factor).toBe(2.0); + }); + + it('sends correct body with custom vector', async () => { + const fetchMock = setupMockFetch({ results: [] }); + const client = createClient(); + await client.boostedSearch('articles', { + find_text: { query: 'keyword' }, + field: 'emb', + query_vector: [0.1, 0.2], + }); + + const body = JSON.parse(fetchMock.mock.calls[0][1]?.body as string); + expect(body.vector_field_search.field).toBe('emb'); + expect(body.vector_search).toBeUndefined(); + }); + + it('rejects both query and field+vector', async () => { + const client = createClient(); + setupMockFetch({}); + await expect( + client.boostedSearch('articles', { + find_text: { query: 'keyword' }, + query: 'auto', + field: 'emb', + query_vector: [0.1], + }), + ).rejects.toThrow('not both'); + }); + + it('rejects neither query nor field+vector', async () => { + const client = createClient(); + setupMockFetch({}); + await expect( + client.boostedSearch('articles', { + find_text: { query: 'keyword' }, + }), + ).rejects.toThrow('Provide either'); + }); + }); + + describe('default values parity', () => { + it('vectorSearch defaults top_k to 10', async () => { + const fetchMock = setupMockFetch({ results: [] }); + const client = createClient(); + await client.vectorSearch('articles', { query: 'hello' }); + + const body = JSON.parse(fetchMock.mock.calls[0][1]?.body as string); + expect(body.vector_search.top_k).toBe(10); + }); + + it('hybridSearch defaults weights and rerank', async () => { + const fetchMock = setupMockFetch({ results: [] }); + const client = createClient(); + await client.hybridSearch('articles', { + query: 'hello', + find_text: { query: 'test' }, + }); + + const body = JSON.parse(fetchMock.mock.calls[0][1]?.body as string); + expect(body.hybrid_config.vector_weight).toBe(0.6); + expect(body.hybrid_config.text_weight).toBe(0.4); + expect(body.hybrid_config.rerank_results).toBe(true); + expect(body.vector_search.top_k).toBe(10); + }); + + it('boostedSearch defaults boost_factor and max_boost_results', async () => { + const fetchMock = setupMockFetch({ results: [] }); + const client = createClient(); + await client.boostedSearch('articles', { + find_text: { query: 'test' }, + query: 'hello', + }); + + const body = JSON.parse(fetchMock.mock.calls[0][1]?.body as string); + expect(body.vector_boost_config.boost_factor).toBe(1.5); + expect(body.vector_boost_config.max_boost_results).toBe(20); + expect(body.vector_search.top_k).toBe(10); + }); + }); + + describe('search backward compatibility', () => { + it('raw search still works with plain objects', async () => { + const fetchMock = setupMockFetch({ results: [] }); + const client = createClient(); + await client.search('articles', { find_text: { query: 'old style' }, limit: 5 }); + + const body = JSON.parse(fetchMock.mock.calls[0][1]?.body as string); + expect(body).toEqual({ find_text: { query: 'old style' }, limit: 5 }); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Vector search models — validation tests +// --------------------------------------------------------------------------- + +describe('buildSearchBody', () => { + it('validates text mode rejects vector configs', () => { + expect(() => + buildSearchBody({ + search_mode: SearchMode.TEXT, + vector_search: { query: 'hello' }, + }), + ).toThrow('not allowed in text'); + }); + + it('validates vector mode requires vector config', () => { + expect(() => buildSearchBody({ search_mode: SearchMode.VECTOR })).toThrow( + 'requires vector_search', + ); + }); + + it('validates mutual exclusion', () => { + expect(() => + buildSearchBody({ + search_mode: SearchMode.VECTOR, + vector_search: { query: 'hello' }, + vector_field_search: { field: 'emb', query_vector: [1.0] }, + }), + ).toThrow('mutually exclusive'); + }); + + it('validates hybrid rejects vector_field_search', () => { + expect(() => + buildSearchBody({ + search_mode: SearchMode.HYBRID, + find_text: { query: 'test' }, + vector_field_search: { field: 'emb', query_vector: [1.0] }, + }), + ).toThrow('not allowed in hybrid'); + }); + + it('validates hybrid requires find_text', () => { + expect(() => + buildSearchBody({ + search_mode: SearchMode.HYBRID, + vector_search: { query: 'hello' }, + }), + ).toThrow('requires find_text'); + }); + + it('validates vector_boosted requires find_text', () => { + expect(() => + buildSearchBody({ + search_mode: SearchMode.VECTOR_BOOSTED, + vector_search: { query: 'hello' }, + vector_boost_config: {}, + }), + ).toThrow('requires find_text'); + }); + + it('rejects NaN in similarity_threshold', () => { + expect(() => + buildSearchBody({ + search_mode: SearchMode.VECTOR, + vector_search: { query: 'hello', similarity_threshold: NaN }, + }), + ).toThrow('finite'); + }); + + it('rejects empty query_vector', () => { + expect(() => + buildSearchBody({ + search_mode: SearchMode.VECTOR, + vector_field_search: { field: 'emb', query_vector: [] }, + }), + ).toThrow('must not be empty'); + }); + + it('rejects Infinity in query_vector', () => { + expect(() => + buildSearchBody({ + search_mode: SearchMode.VECTOR, + vector_field_search: { field: 'emb', query_vector: [Infinity, 1.0] }, + }), + ).toThrow('finite'); + }); + + it('rejects boost_factor <= 0', () => { + expect(() => + buildSearchBody({ + search_mode: SearchMode.VECTOR_BOOSTED, + find_text: { query: 'test' }, + vector_search: { query: 'hello' }, + vector_boost_config: { boost_factor: 0 }, + }), + ).toThrow('must be > 0'); + }); + + it('rejects weights that dont sum to 1', () => { + expect(() => + buildSearchBody({ + search_mode: SearchMode.HYBRID, + find_text: { query: 'test' }, + vector_search: { query: 'hello' }, + hybrid_config: { vector_weight: 0.3, text_weight: 0.3 }, + }), + ).toThrow('must equal 1.0'); + }); + + it('rejects similarity_threshold > 1', () => { + expect(() => + buildSearchBody({ + search_mode: SearchMode.VECTOR, + vector_search: { query: 'hello', similarity_threshold: 1.5 }, + }), + ).toThrow('between 0.0 and 1.0'); + }); + + it('rejects text_weight > 1', () => { + expect(() => + buildSearchBody({ + search_mode: SearchMode.HYBRID, + find_text: { query: 'test' }, + vector_search: { query: 'hello' }, + hybrid_config: { vector_weight: 0.4, text_weight: 1.5 }, + }), + ).toThrow('between 0.0 and 1.0'); + }); + + it('rejects vector_weight > 1', () => { + expect(() => + buildSearchBody({ + search_mode: SearchMode.HYBRID, + find_text: { query: 'test' }, + vector_search: { query: 'hello' }, + hybrid_config: { vector_weight: 1.5, text_weight: -0.5 }, + }), + ).toThrow('between 0.0 and 1.0'); + }); + + it('text mode rejects vector_field_search', () => { + expect(() => + buildSearchBody({ + search_mode: SearchMode.TEXT, + vector_field_search: { field: 'emb', query_vector: [1.0] }, + }), + ).toThrow('not allowed in text'); + }); + + it('text mode rejects boost_config', () => { + expect(() => + buildSearchBody({ + search_mode: SearchMode.TEXT, + vector_boost_config: { boost_factor: 1.5 }, + }), + ).toThrow('not allowed in text'); + }); + + it('text mode rejects hybrid_config', () => { + expect(() => + buildSearchBody({ + search_mode: SearchMode.TEXT, + hybrid_config: { vector_weight: 0.6, text_weight: 0.4 }, + }), + ).toThrow('not allowed in text'); + }); + + it('vector mode rejects boost_config', () => { + expect(() => + buildSearchBody({ + search_mode: SearchMode.VECTOR, + vector_search: { query: 'hello' }, + vector_boost_config: { boost_factor: 1.5 }, + }), + ).toThrow('not allowed in vector'); + }); + + it('vector mode rejects hybrid_config', () => { + expect(() => + buildSearchBody({ + search_mode: SearchMode.VECTOR, + vector_search: { query: 'hello' }, + hybrid_config: { vector_weight: 0.6, text_weight: 0.4 }, + }), + ).toThrow('not allowed in vector'); + }); + + it('vector_boosted requires vector config', () => { + expect(() => + buildSearchBody({ + search_mode: SearchMode.VECTOR_BOOSTED, + find_text: { query: 'test' }, + }), + ).toThrow('requires vector_search'); + }); + + it('vector_boosted rejects hybrid_config', () => { + expect(() => + buildSearchBody({ + search_mode: SearchMode.VECTOR_BOOSTED, + find_text: { query: 'test' }, + vector_search: { query: 'hello' }, + hybrid_config: { vector_weight: 0.6, text_weight: 0.4 }, + }), + ).toThrow('not allowed in vector_boosted'); + }); + + it('defaults search_mode when omitted', () => { + const body = buildSearchBody({ find_text: { query: 'test' } }); + expect(body.search_mode).toBe('text'); + }); + + it('rejects fractional top_k', () => { + expect(() => + buildSearchBody({ + search_mode: SearchMode.VECTOR, + vector_search: { query: 'hello', top_k: 1.5 }, + }), + ).toThrow('positive integer'); + }); + + it('rejects NaN in top_k', () => { + expect(() => + buildSearchBody({ + search_mode: SearchMode.VECTOR, + vector_search: { query: 'hello', top_k: NaN }, + }), + ).toThrow('finite'); + }); + + it('rejects Infinity in max_boost_results', () => { + expect(() => + buildSearchBody({ + search_mode: SearchMode.VECTOR_BOOSTED, + find_text: { query: 'test' }, + vector_search: { query: 'hello' }, + vector_boost_config: { max_boost_results: Infinity }, + }), + ).toThrow('finite'); + }); + + it('rejects unknown search_mode', () => { + expect(() => + buildSearchBody({ + search_mode: 'invalid_mode' as any, + vector_search: { query: 'hello' }, + }), + ).toThrow('Unknown search_mode'); + }); + + it('builds valid vector request', () => { + const body = buildSearchBody({ + search_mode: SearchMode.VECTOR, + vector_search: { query: 'hello', top_k: 5 }, + limit: 10, + }); + expect(body.search_mode).toBe('vector'); + expect(body.vector_search.query).toBe('hello'); + expect(body.limit).toBe(10); + }); + + it('strips undefined values', () => { + const body = buildSearchBody({ + search_mode: SearchMode.VECTOR, + vector_search: { query: 'hello' }, + find_text: undefined, + }); + expect('find_text' in body).toBe(false); + }); +}); + +describe('mergeExtra', () => { + it('rejects conflicting keys', () => { + expect(() => mergeExtra({ search_mode: 'vector' }, { search_mode: 'text' })).toThrow( + 'conflict', + ); + }); + + it('merges non-conflicting keys', () => { + const result = mergeExtra({ search_mode: 'vector' }, { where: { x: 1 } }); + expect(result).toEqual({ search_mode: 'vector', where: { x: 1 } }); + }); });