@@ -6,12 +6,19 @@ import { l2Normalize, parseKvMetadata } from './utils.js'
66
77/**
88 * @import { SearchResult, SearchVectorsOptions } from './types.js'
9+ * @import { AsyncBuffer, FileMetaData } from 'hyparquet'
910 */
1011
1112/**
1213 * Find the top-k nearest neighbors to a query vector.
1314 *
14- * Three paths, in order of preference:
15+ * `source` may be a single URL/path/AsyncBuffer or an array of them. With
16+ * an array, each file is searched in parallel and results are heap-merged
17+ * to the global top-K. Per-source `metadata` and `binary` (from
18+ * `prefetchBinary`) may be passed as same-length arrays; pass `undefined`
19+ * in any slot to fall back to the default behavior for that source.
20+ *
21+ * Three paths are picked per source, in order of preference:
1522 * - Clustered + binary + rerank (file has centroids): phase 1 scans only
1623 * the top-N nearest clusters' row ranges (skipping whole row groups),
1724 * phase 2 fetches the candidate float32 vectors and reranks.
@@ -26,7 +33,7 @@ import { l2Normalize, parseKvMetadata } from './utils.js'
2633export async function searchVectors ( {
2734 query,
2835 source,
29- metadata : providedMetadata ,
36+ metadata,
3037 topK = 10 ,
3138 metric,
3239 rerankFactor = 10 ,
@@ -39,6 +46,74 @@ export async function searchVectors({
3946 if ( source === undefined || source === null ) {
4047 throw new Error ( 'searchVectors: `source` is required (URL, file path, or AsyncBuffer)' )
4148 }
49+ const sources = Array . isArray ( source ) ? source : [ source ]
50+ if ( sources . length === 0 ) {
51+ throw new Error ( 'searchVectors: `source` array is empty' )
52+ }
53+ if ( Array . isArray ( metadata ) && metadata . length !== sources . length ) {
54+ throw new Error ( `searchVectors: \`metadata\` array length ${ metadata . length } does not match \`source\` array length ${ sources . length } ` )
55+ }
56+ if ( Array . isArray ( binary ) && binary . length !== sources . length ) {
57+ throw new Error ( `searchVectors: \`binary\` array length ${ binary . length } does not match \`source\` array length ${ sources . length } ` )
58+ }
59+ const metadatas = Array . isArray ( metadata ) ? metadata : sources . map ( ( ) => /** @type {FileMetaData | undefined } */ ( metadata ) )
60+ const binaries = Array . isArray ( binary ) ? binary : sources . map ( ( ) => /** @type {Uint8Array | undefined } */ ( binary ) )
61+
62+ const multi = sources . length > 1
63+ const perSource = await Promise . all ( sources . map ( ( src , i ) => searchOne ( {
64+ query,
65+ source : src ,
66+ metadata : metadatas [ i ] ,
67+ binary : binaries [ i ] ,
68+ topK,
69+ metric,
70+ rerankFactor,
71+ probe,
72+ signal,
73+ asyncBufferFactory,
74+ compressors,
75+ sourceIndex : multi ? i : undefined ,
76+ } ) ) )
77+
78+ if ( ! multi ) return perSource [ 0 ] . results
79+
80+ // Merge: each per-source array is already sorted best-first under the same
81+ // direction (we assert below). Flatten, sort, slice — N is small.
82+ const direction = perSource [ 0 ] . direction
83+ for ( let i = 1 ; i < perSource . length ; i += 1 ) {
84+ if ( perSource [ i ] . direction !== direction ) {
85+ throw new Error ( 'searchVectors: sources have inconsistent metric directions' )
86+ }
87+ }
88+ const merged = perSource . flatMap ( p => p . results )
89+ merged . sort ( ( a , b ) => direction * ( a . score - b . score ) )
90+ return merged . slice ( 0 , topK )
91+ }
92+
93+ /**
94+ * Search a single source. Returns the sorted results plus the score
95+ * direction (1 = ascending / lower-is-better for euclidean, -1 = descending
96+ * for cosine/dot). The direction lets the caller merge cross-source results
97+ * without re-parsing metadata.
98+ *
99+ * @param {object } opts
100+ * @param {Float32Array | number[] } opts.query
101+ * @param {string | AsyncBuffer } opts.source
102+ * @param {FileMetaData } [opts.metadata]
103+ * @param {Uint8Array } [opts.binary]
104+ * @param {number } opts.topK
105+ * @param {import('./types.js').DistanceMetric } [opts.metric]
106+ * @param {number } opts.rerankFactor
107+ * @param {number } [opts.probe]
108+ * @param {AbortSignal } [opts.signal]
109+ * @param {(options: { source: string, signal?: AbortSignal }) => Promise<AsyncBuffer> } [opts.asyncBufferFactory]
110+ * @param {import('hyparquet').Compressors } [opts.compressors]
111+ * @param {number } [opts.sourceIndex]
112+ * @returns {Promise<{ results: SearchResult[], direction: 1 | -1 }> }
113+ */
114+ async function searchOne ( {
115+ query, source, metadata : providedMetadata , binary, topK, metric, rerankFactor, probe, signal, asyncBufferFactory, compressors, sourceIndex,
116+ } ) {
42117 const file = typeof source === 'string'
43118 ? await ( asyncBufferFactory ?? defaultAsyncBufferFactory ) ( { source, signal } )
44119 : source
@@ -58,12 +133,18 @@ export async function searchVectors({
58133 scoringMetric = 'dot'
59134 }
60135
61- if ( meta . hasBinary && rerankFactor > 0 ) {
62- return searchRerank ( {
136+ const results = meta . hasBinary && rerankFactor > 0
137+ ? await searchRerank ( {
63138 file, metadata, meta, queryF32, scoringMetric, reportedMetric : requestedMetric , topK, rerankFactor, probe, binary, compressors,
64139 } )
140+ : await searchExact ( {
141+ file, metadata, meta, queryF32, scoringMetric, reportedMetric : requestedMetric , topK, compressors,
142+ } )
143+
144+ if ( sourceIndex !== undefined ) {
145+ for ( const r of results ) r . sourceIndex = sourceIndex
65146 }
66- return searchExact ( {
67- file , metadata , meta , queryF32 , scoringMetric , reportedMetric : requestedMetric , topK , compressors ,
68- } )
147+
148+ const direction = requestedMetric === 'euclidean' ? 1 : - 1
149+ return { results , direction }
69150}
0 commit comments