Skip to content

Commit 5a07164

Browse files
committed
Stream writeVectors for the no-cluster path
Add a streaming fast path: when `binary` is set explicitly and no clustering is requested, pack and flush one row group at a time via parquetWriteRows instead of materializing the whole dataset. Peak RSS goes from O(N) to O(rowGroup), ~57% lower at 200k x 384-dim, more as N grows. Auto-binary and clustering still need the full dataset, so they keep the buffered path. Drop the redundant hypvector.count metadata: it equals the parquet footer's num_rows, which readers already use via parseKvMetadata. Bump hyparquet-writer to 0.16.1 for parquetWriteRows.
1 parent 4be0b9f commit 5a07164

5 files changed

Lines changed: 256 additions & 67 deletions

File tree

README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,6 @@ Key-value metadata:
197197
| `hypvector.metric` | `cosine` \| `dot` \| `euclidean` |
198198
| `hypvector.normalized` | `true` if vectors were L2-normalized on write |
199199
| `hypvector.binary` | `true` if the `vector_bin` column is present |
200-
| `hypvector.count` | number of vectors |
201200
| `hypvector.clusters` | number of k-means clusters (0 if not clustered) |
202201
| `hypvector.centroids` | base64-encoded centroid binary codes (`clusters × dim/8` bytes); present when `clusters > 0` |
203202
| `hypvector.clusterCounts` | base64-encoded `Uint32Array` of per-cluster row counts; present when `clusters > 0` |

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
"dependencies": {
4747
"hyparquet": "1.26.0",
4848
"hyparquet-compressors": "1.1.1",
49-
"hyparquet-writer": "0.15.3"
49+
"hyparquet-writer": "0.16.1"
5050
},
5151
"devDependencies": {
5252
"@types/node": "25.9.1",

scripts/test-encoding.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ function b64(bytes) {
7171
const kvMetadata = [
7272
{ key: 'hypvector.version', value: '0' }, { key: 'hypvector.dimension', value: String(dim) },
7373
{ key: 'hypvector.metric', value: 'cosine' }, { key: 'hypvector.normalized', value: 'true' },
74-
{ key: 'hypvector.binary', value: 'true' }, { key: 'hypvector.count', value: String(ids.length) },
74+
{ key: 'hypvector.binary', value: 'true' },
7575
{ key: 'hypvector.clusters', value: String(centroids.length) },
7676
{ key: 'hypvector.centroids', value: b64(centBuf) },
7777
{ key: 'hypvector.clusterCounts', value: b64(new Uint8Array(counts.buffer)) },

src/writeVectors.js

Lines changed: 179 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { parquetWrite, schemaFromColumnData } from 'hyparquet-writer'
1+
import { parquetWrite, parquetWriteRows, schemaFromColumnData } from 'hyparquet-writer'
22
import { binaryKMeans, reorderClustersByHamming } from './cluster.js'
33
import {
44
defaultAutoBinaryThreshold,
@@ -13,9 +13,9 @@ import {
1313
import { encodeBase64, l2Normalize, packBinary, packFloat32 } from './utils.js'
1414

1515
/**
16-
* @import { WriteVectorsOptions } from './types.js'
17-
* @import { ColumnSource } from 'hyparquet-writer'
18-
* @import { SchemaElement } from 'hyparquet'
16+
* @import { VectorRecord, WriteVectorsOptions } from './types.js'
17+
* @import { ColumnSource, Writer } from 'hyparquet-writer'
18+
* @import { CompressionCodec, KeyValue, SchemaElement } from 'hyparquet'
1919
*/
2020

2121
/**
@@ -24,7 +24,7 @@ import { encodeBase64, l2Normalize, packBinary, packFloat32 } from './utils.js'
2424
* Columns:
2525
* - `id`: STRING (caller-supplied id, coerced to string)
2626
* - `vector`: FIXED_LEN_BYTE_ARRAY(4 * dimension) raw little-endian float32 bytes
27-
* - `vector_bin`: FIXED_LEN_BYTE_ARRAY(dim/8) written when `binary: true`
27+
* - `vector_bin`: FIXED_LEN_BYTE_ARRAY(dim/8), written when `binary: true`
2828
*
2929
* When `clusters > 0`, rows are reordered by binary cluster id and the
3030
* centroids plus per-cluster row counts go into KV metadata.
@@ -53,55 +53,68 @@ export async function writeVectors({
5353
throw new Error('writeVectors: clusters > 0 requires binary !== false')
5454
}
5555

56-
// Auto mode (`binary` / `clusters` omitted): pack binary codes opportunistically
57-
// so we can decide once N is known. The dim/8 bytes per vector are negligible
58-
// compared to the float32 buffer we're already materializing.
59-
const autoBinary = binary === undefined
60-
const collectBinary = autoBinary || binary === true || clusters !== undefined && clusters > 0
61-
6256
const binaryBytes = dimension + 7 >> 3
57+
const willCluster = clusters !== undefined && clusters > 0
58+
59+
// Streaming fast path: when `binary` is set explicitly and no clustering is
60+
// requested, the schema is fully determined up front and rows are emitted in
61+
// input order, so each row-group-sized batch can be packed and flushed
62+
// without ever holding the whole dataset. Peak memory is one row group, not
63+
// O(N). Auto-binary (binary omitted) needs N to choose the column set, and
64+
// clustering needs a global k-means + row reorder, so both fall through to
65+
// the buffered path below.
66+
if (binary !== undefined && !willCluster) {
67+
return streamVectors({
68+
writer,
69+
vectors,
70+
dimension,
71+
binaryBytes,
72+
binary,
73+
normalize,
74+
metric,
75+
codec,
76+
rowGroupSize: rowGroupSize ?? defaultRowGroupSize,
77+
pageSize: pageSize ?? (binary ? defaultBinaryPageSize : undefined),
78+
})
79+
}
80+
81+
// Buffered path: auto-binary and clustering both need the whole dataset in
82+
// memory: auto-binary to count N before choosing the column set, clustering
83+
// to k-means the binary codes and reorder rows so each cluster is contiguous.
84+
const autoBinary = binary === undefined
6385

6486
/** @type {string[]} */
6587
const ids = []
6688
/** @type {Uint8Array[]} */
6789
const packed = []
68-
/** @type {Uint8Array[] | null} */
69-
let packedBin = collectBinary ? [] : null
90+
/** @type {Uint8Array[]} */
91+
const packedBin = []
7092

7193
for await (const record of vectors) {
72-
const { id, vector } = record
73-
if (!vector || vector.length !== dimension) {
74-
throw new Error(`vector for id=${id} has length ${vector?.length}, expected ${dimension}`)
75-
}
76-
const v = normalize
77-
? l2Normalize(vector)
78-
: vector instanceof Float32Array ? vector : Float32Array.from(vector)
79-
ids.push(String(id))
94+
const v = toFloat32(record.vector, dimension, normalize, record.id)
95+
ids.push(String(record.id))
8096
packed.push(packFloat32(v))
81-
if (packedBin) packedBin.push(packBinary(v, dimension))
97+
packedBin.push(packBinary(v, dimension))
8298
}
8399

84100
// Resolve auto defaults now that we know N. Auto-clusters only fires
85-
// when the caller also let `binary` auto explicit `binary: true` means
101+
// when the caller also let `binary` auto; explicit `binary: true` means
86102
// "add the column, don't reshuffle rows".
87103
if (autoBinary) binary = ids.length >= defaultAutoBinaryThreshold
88104
binary = binary === true
89-
if (!binary) packedBin = null
90105
const clusterCount = clusters ?? (autoBinary && binary ? Math.max(1, Math.round(Math.sqrt(ids.length) / 2)) : 0)
91-
if (clusterCount > 0 && !binary) {
92-
// Clustering operates on binary codes; require the binary column too.
93-
// Only reachable when caller explicitly set clusters > 0 in auto-binary
94-
// mode; the explicit `clusters>0 && binary===false` case threw above.
95-
binary = true
96-
}
106+
// Clustering operates on the binary codes, so it implies the binary column
107+
// even when auto-binary would have left it off at small N (explicit
108+
// `clusters > 0` with a sub-threshold corpus).
109+
if (clusterCount > 0) binary = true
97110

98111
const effectivePageSize = pageSize ?? (binary ? defaultBinaryPageSize : undefined)
99112

100113
/** @type {Uint8Array[] | null} */
101114
let centroids = null
102115
/** @type {Uint32Array | null} */
103116
let clusterCounts = null
104-
if (clusterCount > 0 && packedBin) {
117+
if (clusterCount > 0) {
105118
const { assignments, centroids: cs } = binaryKMeans(
106119
packedBin, binaryBytes, clusterCount, clusterIterations, clusterSeed
107120
)
@@ -119,15 +132,8 @@ export async function writeVectors({
119132
permuteInPlace(sorted, [ids, packed, packedBin])
120133
}
121134

122-
const kvMetadata = [
123-
{ key: 'hypvector.version', value: String(hypVectorVersion) },
124-
{ key: 'hypvector.dimension', value: String(dimension) },
125-
{ key: 'hypvector.metric', value: metric },
126-
{ key: 'hypvector.normalized', value: String(normalize) },
127-
{ key: 'hypvector.binary', value: String(binary) },
128-
{ key: 'hypvector.count', value: String(ids.length) },
129-
{ key: 'hypvector.clusters', value: String(centroids ? centroids.length : 0) },
130-
]
135+
const kvMetadata = baseKvMetadata({ dimension, metric, normalize, binary })
136+
kvMetadata.push({ key: 'hypvector.clusters', value: String(centroids ? centroids.length : 0) })
131137
if (centroids && clusterCounts) {
132138
// Pack centroids as one contiguous Uint8Array, then base64-encode.
133139
const buf = new Uint8Array(centroids.length * binaryBytes)
@@ -146,39 +152,19 @@ export async function writeVectors({
146152
{ name: defaultIdColumn, data: ids },
147153
{ name: defaultVectorColumn, data: packed },
148154
]
149-
/** @type {Record<string, SchemaElement>} */
150-
const schemaOverrides = {
151-
[defaultVectorColumn]: {
152-
name: defaultVectorColumn,
153-
type: 'FIXED_LEN_BYTE_ARRAY',
154-
type_length: dimension * 4,
155-
repetition_type: 'REQUIRED',
156-
},
157-
}
158-
if (packedBin) {
159-
columnData.push({ name: defaultBinaryColumn, data: packedBin })
160-
schemaOverrides[defaultBinaryColumn] = {
161-
name: defaultBinaryColumn,
162-
type: 'FIXED_LEN_BYTE_ARRAY',
163-
type_length: binaryBytes,
164-
repetition_type: 'REQUIRED',
165-
}
166-
}
167-
/** @type {ColumnSource[]} */
168-
const schemaInput = columnData.map(c => c.name === defaultIdColumn ? { ...c, type: /** @type {const} */ 'STRING' } : c)
169-
const schema = schemaFromColumnData({ columnData: schemaInput, schemaOverrides })
155+
if (binary) columnData.push({ name: defaultBinaryColumn, data: packedBin })
170156

171157
// When clustering, each cluster becomes its own row group so phase-1
172158
// binary scans and phase-2 candidate fetches stay within a single column
173-
// chunk per cluster — drops fetches roughly proportional to clusters
159+
// chunk per cluster, dropping fetches roughly proportional to clusters
174160
// probed. Caller-supplied rowGroupSize wins if explicitly passed.
175161
const effectiveRowGroupSize = rowGroupSize ?? (
176162
clusterCounts ? Array.from(clusterCounts) : defaultRowGroupSize
177163
)
178164

179165
await parquetWrite({
180166
writer,
181-
schema,
167+
schema: vectorSchema({ dimension, binary, binaryBytes }),
182168
rowGroupSize: effectiveRowGroupSize,
183169
kvMetadata,
184170
columnData,
@@ -187,6 +173,136 @@ export async function writeVectors({
187173
})
188174
}
189175

176+
/**
177+
* Streaming writer for the no-cluster, explicit-binary case. Packs and flushes
178+
* one row-group-sized batch at a time through {@link parquetWriteRows}, so peak
179+
* memory is bounded by the row-group size rather than the dataset size. The
180+
* schema and KV metadata are fully known up front (row count is recovered from
181+
* the parquet footer's `num_rows`, so nothing here depends on N).
182+
*
183+
* @param {object} options
184+
* @param {Writer} options.writer
185+
* @param {Iterable<VectorRecord> | AsyncIterable<VectorRecord>} options.vectors
186+
* @param {number} options.dimension
187+
* @param {number} options.binaryBytes
188+
* @param {boolean} options.binary
189+
* @param {boolean} options.normalize
190+
* @param {string} options.metric
191+
* @param {CompressionCodec} options.codec
192+
* @param {number | number[]} options.rowGroupSize
193+
* @param {number} [options.pageSize]
194+
* @returns {Promise<void>}
195+
*/
196+
async function streamVectors({ writer, vectors, dimension, binaryBytes, binary, normalize, metric, codec, rowGroupSize, pageSize }) {
197+
/** @type {Omit<ColumnSource, 'data'>[]} */
198+
const columns = [{ name: defaultIdColumn }, { name: defaultVectorColumn }]
199+
if (binary) columns.push({ name: defaultBinaryColumn })
200+
201+
const kvMetadata = baseKvMetadata({ dimension, metric, normalize, binary })
202+
kvMetadata.push({ key: 'hypvector.clusters', value: '0' })
203+
204+
/**
205+
* Map each input record to a parquet row, packing on the fly so only one
206+
* row group's worth of packed bytes is ever live at once.
207+
* @returns {AsyncGenerator<Record<string, string | Uint8Array>>}
208+
*/
209+
async function* rows() {
210+
for await (const record of vectors) {
211+
const v = toFloat32(record.vector, dimension, normalize, record.id)
212+
/** @type {Record<string, string | Uint8Array>} */
213+
const row = {
214+
[defaultIdColumn]: String(record.id),
215+
[defaultVectorColumn]: packFloat32(v),
216+
}
217+
if (binary) row[defaultBinaryColumn] = packBinary(v, dimension)
218+
yield row
219+
}
220+
}
221+
222+
await parquetWriteRows({
223+
writer,
224+
rows: rows(),
225+
columns,
226+
schema: vectorSchema({ dimension, binary, binaryBytes }),
227+
rowGroupSize,
228+
kvMetadata,
229+
codec,
230+
...pageSize !== undefined ? { pageSize } : {},
231+
})
232+
}
233+
234+
/**
235+
* Validate one record's vector and return it as a Float32Array, L2-normalized
236+
* when requested. Reuses the caller's Float32Array in place when possible.
237+
*
238+
* @param {Float32Array | number[]} vector
239+
* @param {number} dimension
240+
* @param {boolean} normalize
241+
* @param {string | number} id
242+
* @returns {Float32Array}
243+
*/
244+
function toFloat32(vector, dimension, normalize, id) {
245+
if (!vector || vector.length !== dimension) {
246+
throw new Error(`vector for id=${id} has length ${vector?.length}, expected ${dimension}`)
247+
}
248+
return normalize
249+
? l2Normalize(vector)
250+
: vector instanceof Float32Array ? vector : Float32Array.from(vector)
251+
}
252+
253+
/**
254+
* KV metadata shared by both write paths: everything knowable without N. The
255+
* vector count is intentionally omitted: it's exactly the parquet footer's
256+
* `num_rows`, which readers already use (see parseKvMetadata).
257+
*
258+
* @param {{ dimension: number, metric: string, normalize: boolean, binary: boolean }} options
259+
* @returns {KeyValue[]}
260+
*/
261+
function baseKvMetadata({ dimension, metric, normalize, binary }) {
262+
return [
263+
{ key: 'hypvector.version', value: String(hypVectorVersion) },
264+
{ key: 'hypvector.dimension', value: String(dimension) },
265+
{ key: 'hypvector.metric', value: metric },
266+
{ key: 'hypvector.normalized', value: String(normalize) },
267+
{ key: 'hypvector.binary', value: String(binary) },
268+
]
269+
}
270+
271+
/**
272+
* Build the parquet schema for the vector columns. Independent of row count and
273+
* data values (types are forced via overrides / the id STRING hint), so it
274+
* works for both the buffered and streaming paths.
275+
*
276+
* @param {{ dimension: number, binary: boolean, binaryBytes: number }} options
277+
* @returns {SchemaElement[]}
278+
*/
279+
function vectorSchema({ dimension, binary, binaryBytes }) {
280+
/** @type {Record<string, SchemaElement>} */
281+
const schemaOverrides = {
282+
[defaultVectorColumn]: {
283+
name: defaultVectorColumn,
284+
type: 'FIXED_LEN_BYTE_ARRAY',
285+
type_length: dimension * 4,
286+
repetition_type: 'REQUIRED',
287+
},
288+
}
289+
/** @type {ColumnSource[]} */
290+
const columnData = [
291+
{ name: defaultIdColumn, type: 'STRING', data: [] },
292+
{ name: defaultVectorColumn, data: [] },
293+
]
294+
if (binary) {
295+
columnData.push({ name: defaultBinaryColumn, data: [] })
296+
schemaOverrides[defaultBinaryColumn] = {
297+
name: defaultBinaryColumn,
298+
type: 'FIXED_LEN_BYTE_ARRAY',
299+
type_length: binaryBytes,
300+
repetition_type: 'REQUIRED',
301+
}
302+
}
303+
return schemaFromColumnData({ columnData, schemaOverrides })
304+
}
305+
190306
/**
191307
* Build a row index array [0..n) sorted by the given comparator.
192308
*

0 commit comments

Comments
 (0)