Skip to content

Commit 2172dab

Browse files
committed
searchVectors accepts a source array for multi-file query
Widen the source / metadata / binary options to accept either a single value or a same-length array. Each source is searched in parallel and the global top-K is heap-merged. When more than one source is supplied, each result carries a sourceIndex pointing back at its origin. // single source — unchanged searchVectors({ source: 'logs/today.parquet', query, topK }) // multi-source — new searchVectors({ source: dailyFiles, metadata: parsedMetas, // optional, same length binary: prefetchedBins, // optional, same length query, topK, }) Single-source callers see no behavioral change. Length-mismatched metadata or binary arrays throw. All sources must share the same metric direction (mixed cosine/dot vs euclidean is rejected at merge time). This is the foundation for sharded log queries — one parquet per day / session / tenant, queried opportunistically as a group.
1 parent 0c72836 commit 2172dab

3 files changed

Lines changed: 164 additions & 11 deletions

File tree

src/searchVectors.js

Lines changed: 88 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -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'
2633
export 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
}

src/types.d.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,18 +34,34 @@ export interface ReadVectorsOptions {
3434

3535
export interface SearchVectorsOptions {
3636
query: Float32Array | number[] // the query vector
37-
source: string | AsyncBuffer // URL, file path, or an already-opened AsyncBuffer
38-
metadata?: FileMetaData // optional pre-parsed parquet metadata; skips the footer fetch on every call. Reuse across queries for best throughput.
37+
38+
/**
39+
* One source or an array of sources. Each source is a URL, file path, or
40+
* an already-opened AsyncBuffer. With an array, each file is searched in
41+
* parallel and the global top-K is returned with `result.sourceIndex` set.
42+
* All sources must share the same dimension and metric.
43+
*/
44+
source: string | AsyncBuffer | Array<string | AsyncBuffer>
45+
46+
/**
47+
* Pre-parsed parquet metadata; skips the footer fetch on every call.
48+
* Reuse across queries for best throughput. When `source` is an array,
49+
* pass a same-length array of (FileMetaData | undefined) — undefined
50+
* slots fall back to fetching the footer for that source.
51+
*/
52+
metadata?: FileMetaData | Array<FileMetaData | undefined>
53+
3954
topK?: number // number of nearest neighbors to return (default: 10)
4055
metric?: DistanceMetric // override the stored metric
4156

4257
/**
4358
* Pre-fetched binary column from `prefetchBinary`. When provided, phase 1
4459
* Hamming scan runs entirely from memory and the binary parquet fetches
4560
* are skipped. The buffer must be `count × dim/8` bytes in the same row
46-
* order as the file. Reuse across queries.
61+
* order as the file. Reuse across queries. When `source` is an array,
62+
* pass a same-length array of (Uint8Array | undefined).
4763
*/
48-
binary?: Uint8Array
64+
binary?: Uint8Array | Array<Uint8Array | undefined>
4965

5066
/**
5167
* When the file has a binary column, controls two-phase search:
@@ -87,6 +103,7 @@ export interface SearchResult {
87103
id: string | number
88104
score: number // similarity score (higher = better for cosine/dot, lower = better for euclidean)
89105
rowIndex: number // original row index in the source parquet
106+
sourceIndex?: number // index into the `source` array; set only when multi-source search was used
90107
metadata?: Record<string, any>
91108
}
92109

test/searchVectors.test.js

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,61 @@ describe('searchVectors', () => {
112112
}
113113
})
114114

115+
it('accepts a single-element source array as equivalent to a bare source', async () => {
116+
const dimension = 32
117+
const vectors = makeVectors(80, dimension, 5)
118+
const writer = fileWriter(TEST_FILE)
119+
await writeVectors({ writer, vectors, dimension })
120+
121+
const query = vectors[12].vector
122+
const bare = await searchVectors({ source: TEST_FILE, query, topK: 3 })
123+
const wrapped = await searchVectors({ source: [TEST_FILE], query, topK: 3 })
124+
expect(wrapped.map(r => r.id)).toEqual(bare.map(r => r.id))
125+
// sourceIndex is only attached when truly multi-source (length > 1) to keep
126+
// results identical between the bare and array-of-one forms.
127+
expect(bare[0].sourceIndex).toBeUndefined()
128+
expect(wrapped[0].sourceIndex).toBeUndefined()
129+
})
130+
131+
it('merges results across multiple sources matching a single-file search', async () => {
132+
const dimension = 32
133+
const fileA = 'test/files/search-a.parquet'
134+
const fileB = 'test/files/search-b.parquet'
135+
const all = makeVectors(100, dimension, 9)
136+
const halfA = all.slice(0, 50)
137+
const halfB = all.slice(50)
138+
139+
await writeVectors({ writer: fileWriter(TEST_FILE), vectors: all, dimension, normalize: true })
140+
await writeVectors({ writer: fileWriter(fileA), vectors: halfA, dimension, normalize: true })
141+
await writeVectors({ writer: fileWriter(fileB), vectors: halfB, dimension, normalize: true })
142+
143+
try {
144+
const query = all[37].vector
145+
const combined = await searchVectors({ source: TEST_FILE, query, topK: 5 })
146+
const split = await searchVectors({ source: [fileA, fileB], query, topK: 5 })
147+
148+
// Same IDs in the same order — vectors[37] lands in fileA so the top hit must be from source 0.
149+
expect(split.map(r => r.id)).toEqual(combined.map(r => r.id))
150+
expect(split[0].sourceIndex).toBe(0)
151+
for (let i = 0; i < combined.length; i += 1) {
152+
expect(split[i].score).toBeCloseTo(combined[i].score, 5)
153+
}
154+
} finally {
155+
if (existsSync(fileA)) unlinkSync(fileA)
156+
if (existsSync(fileB)) unlinkSync(fileB)
157+
}
158+
})
159+
160+
it('rejects metadata/binary arrays whose length does not match the source array', async () => {
161+
const dimension = 16
162+
const vectors = makeVectors(20, dimension, 1)
163+
await writeVectors({ writer: fileWriter(TEST_FILE), vectors, dimension })
164+
165+
await expect(searchVectors({
166+
source: [TEST_FILE, TEST_FILE], query: vectors[0].vector, topK: 1, metadata: [undefined],
167+
})).rejects.toThrow(/metadata.*length/)
168+
})
169+
115170
it('produces the same top-1 with and without rerank', async () => {
116171
const dimension = 64
117172
const vectors = makeVectors(200, dimension, 23)

0 commit comments

Comments
 (0)