1- import { parquetWrite , schemaFromColumnData } from 'hyparquet-writer'
1+ import { parquetWrite , parquetWriteRows , schemaFromColumnData } from 'hyparquet-writer'
22import { binaryKMeans , reorderClustersByHamming } from './cluster.js'
33import {
44 defaultAutoBinaryThreshold ,
@@ -13,9 +13,9 @@ import {
1313import { 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