Skip to main content

SochDB Bulk Operations

High-throughput paths for loading large datasets into SochDB โ€” vector indexes and relational tables โ€” while avoiding per-call FFI overhead.

Versions on this page

Examples use the native Python engine sochdb 2.0.3 (PyO3), the pure-Python sochdb SDK 0.5.9 (ctypes), the Node.js SDK @sochdb/sochdb 0.5.3, and the sochdb-bulk CLI from core engine 2.0.3.

Deep Dive: See the Bulk Operations Reference for CLI internals and advanced usage.

Why Use Bulk Operations?โ€‹

Inserting vectors one at a time crosses the Python/Rust (or Node/Rust) boundary for every call. The overhead comes from:

  • An O(Nยทd) memcpy per call crossing the language boundary
  • Host-language allocation tax (Python reference counting / GC pressure, V8 array boxing)
  • GIL contention in multi-threaded scenarios (Python)

The bulk paths eliminate this by handing the engine one large, contiguous buffer per operation and releasing the GIL while the index builds:

  1. In-process zero-copy โ€” pass a single C-contiguous float32 NumPy array; the engine reads it directly with no copy.
  2. Accumulate-then-flush โ€” buffer many chunks in pre-allocated arrays, then build the graph in one call.
  3. Out-of-process โ€” for datasets too large for RAM, memory-map a file and build with the sochdb-bulk CLI.
Which Python package?

SochDB ships two importable packages both named sochdb:

  • The native engine (sochdb 2.0.3, PyO3) exposes HnswIndex, build_index_from_numpy, build_index_from_file, TableDatabase, etc. โ€” used for the zero-copy index-build examples below.
  • The pure-Python SDK (sochdb 0.5.9, ctypes) exposes Database, Namespace, Collection, VectorIndex, and BatchAccumulator.

Each example notes which package its imports come from.

The fastest way to build an HNSW index from in-memory embeddings is the native zero-copy path. It requires a 2D float32 array in C-contiguous (row-major) order.

# sochdb 2.0.3 (native PyO3 engine)
import numpy as np
from sochdb import build_index_from_numpy

# 10K x 768D embeddings, float32, C-contiguous
embeddings = np.random.randn(10000, 768).astype(np.float32)

# m / ef_construction default to recommended_hnsw_params(D) when omitted
index = build_index_from_numpy(embeddings, metric="cosine")

index.save("my_index.hnsw")
print(len(index), "vectors indexed")

build_index_from_numpy(embeddings, *, m=None, ef_construction=None, metric="cosine", ids=None) builds entirely in-process โ€” no disk I/O, no subprocess. When m or ef_construction are left as None they are filled from recommended_hnsw_params(D) (dimension-aware: M=16 for D <= 128, M=24 for 129โ€“512, M=32 for 513+; ef_construction = max(200, m*8)).

Contiguity is mandatory for the zero-copy path

Slicing, transposing, or stacking can produce a non-C-contiguous array. If insert_batch raises ValueError: Non-contiguous array, wrap the array: vectors = np.ascontiguousarray(vectors, dtype=np.float32).

Accumulate-then-flush with BatchAccumulatorโ€‹

When embeddings arrive in chunks (e.g. from a data loader), the BatchAccumulator from the pure-Python SDK (sochdb 0.5.9) collects them into pre-allocated buffers with zero FFI calls, then builds the index in a single flush().

# sochdb 0.5.9 (pure-Python ctypes SDK)
from sochdb.vector import VectorIndex, BatchAccumulator

index = VectorIndex(dimension=1536) # m=32, ef_construction=256 by default

acc = BatchAccumulator(index, estimated_size=50_000)
for batch_ids, batch_vecs in data_loader: # uint64[], float32[N, 1536]
acc.add(batch_ids, batch_vecs) # pure numpy memcpy, no FFI
acc.flush() # single bulk insert_batch() FFI call

print(len(index), "vectors indexed")

BatchAccumulator(index, *, estimated_size=0) appends chunks via acc.add(ids, vectors) (a uint64 ID array plus a 2D float32 array) or acc.add_single(id, vector) into geometrically-grown buffers. acc.flush() performs the single bulk insert_batch() FFI call and returns the inserted count; acc.count(), acc.save(dir), and acc.load(dir) are also available. The accumulator reports a typical 4โ€“5x speedup over incremental single inserts.

VectorIndex itself also exposes insert_batch(ids, vectors) -> int for one-shot insertion when all data is in a single array.

Out-of-Process Build (Large / Out-of-Memory Datasets)โ€‹

For datasets that do not fit in RAM, build from a file. The native engine spawns the sochdb-bulk CLI, which memory-maps the input.

# sochdb 2.0.3 (native PyO3 engine)
from sochdb import build_index_from_file

stats = build_index_from_file(
"embeddings.npy", # .npy or raw .f32
"index.hnsw",
dimension=768, # auto-detected for .npy
m=16,
ef_construction=100,
batch_size=1000,
)
print(stats) # {"vectors", "elapsed_secs", "rate", "output_size_mb", "command"}

build_index_from_file(input_path, output_path, *, dimension=None, m=16, ef_construction=100, batch_size=1000, quiet=False) returns a dict of build statistics. It requires the sochdb-bulk binary on PATH or in the workspace target/ directory.

Prefer in-process for in-memory data

build_index_from_numpy() is significantly faster than the subprocess path when your embeddings already fit in memory, because it avoids serializing to disk and spawning a process. Only reach for build_index_from_file() when the dataset is too large for RAM.

bulk_build_index() is deprecated

The legacy bulk_build_index(embeddings, output, *, ids=None, m=16, ef_construction=100) still exists in the native package but emits a DeprecationWarning. It now delegates to the in-process build and saves the index. Use build_index_from_numpy() instead.

Bulk Table Loading with TableDatabase.load_csvโ€‹

The native engine's columnar TableDatabase (sochdb 2.0.3) can bulk-load a CSV directly in Rust, releasing the GIL during the insert.

# sochdb 2.0.3 (native PyO3 engine)
from sochdb import TableDatabase

db = TableDatabase.open("data.sdb")

# Register the schema first; column types: int64, uint64, float64, text, binary, bool
db.register_table("events", [
("user_id", "uint64"),
("score", "float64"),
("label", "text"),
])

# CSV must have a header row whose columns match the registered schema, in order
rows = db.load_csv("events", "events.csv")
print(f"Loaded {rows} rows")

load_csv(table, csv_path) -> int reads and converts the CSV entirely in Rust, parsing each field according to the registered column type, and commits in batches (every 100K rows). The table must already be registered with register_table(...). Returns the number of rows inserted.

TableDatabase is native-only

TableDatabase lives only in the native sochdb 2.0.3 package. It is a builder/columnar store with no SQL execute method. The pure-Python 0.5.9 SDK does not have a TableDatabase class โ€” for SQL-style tables there, use db.execute(...) against the KV-backed SQL subset.

Node.js Batch Insertโ€‹

The Node.js SDK (@sochdb/sochdb 0.5.3) batches vectors through a single FFI call. Note that EmbeddedDatabase.open() is synchronous.

import { HnswIndex } from '@sochdb/sochdb';

const index = new HnswIndex({ dimension: 768 }); // maxConnections=32, efConstruction=256

const ids = ['a', 'b', 'c'];
const vectors: number[][] = [
/* 768-length number[] per row */
];

// Single FFI call; vectors are flattened into a contiguous Float32Array internally
index.insertBatch(ids, vectors);

console.log(index.length, 'vectors indexed');
String IDs are hashed internally

HnswIndex accepts string IDs but hashes them to a numeric key; the reverse mapping returns only the low 64 bits, so a string round-trip can be lossy. Use Collection/insertMany (which maintains its own string-to-numeric map) when you need to recover original string IDs.

sochdb-bulk CLIโ€‹

The sochdb-bulk binary builds and queries indexes from files. It ships with the core engine (the sochdb-tools crate) and is also bundled in the Python wheel.

# Build from raw f32 file
sochdb-bulk build-index \
--input embeddings.bin \
--output index.hnsw \
--dimension 768

# Build from NumPy .npy file (dimension auto-detected)
sochdb-bulk build-index \
--input embeddings.npy \
--output index.hnsw

# With custom HNSW parameters
sochdb-bulk build-index \
--input data.f32 \
--output index.hnsw \
--dimension 768 \
--max-connections 32 \
--ef-construction 200 \
--threads 8

# Query an index
sochdb-bulk query \
--index index.hnsw \
--query query.f32 \
--k 10

# Get index info
sochdb-bulk info --index index.hnsw

build-indexโ€‹

sochdb-bulk build-index [OPTIONS] --input <FILE> --output <FILE>

Options:
-i, --input <FILE> Input vector file (raw f32 or .npy)
-o, --output <FILE> Output index file
-d, --dimension <DIM> Vector dimension (auto-detected for .npy)
-f, --format <FORMAT> Input format: raw_f32, npy (auto-detected)
--ids <FILE> Optional ID file (raw u64)
-m, --max-connections <N> HNSW M parameter [default: 16]
-e, --ef-construction <N> HNSW ef_construction [default: 100]
--batch-size <N> Batch size for insertion [default: 1000]
-t, --threads <N> Number of threads (0 = auto) [default: 0]
--direct-read Use direct read instead of mmap
--prefault Prefault mmap pages for memory residency
--quiet Suppress progress bar
-v, --verbose Enable verbose logging

queryโ€‹

sochdb-bulk query [OPTIONS] --index <FILE> --query <FILE>

Options:
-i, --index <FILE> Index file
-q, --query <FILE> Query vector file (single vector, raw f32)
-k, --k <N> Number of neighbors [default: 10]
-e, --ef <N> Search ef parameter

infoโ€‹

sochdb-bulk info --index <FILE>

Input Formatsโ€‹

The simplest and fastest format โ€” raw bytes, memory-mappable.

File layout:

  • vectors.f32 โ€” N ร— D ร— 4 bytes of row-major float32 data
  • vectors.json (optional) โ€” metadata: {"n": 10000, "dim": 768, "metric": "cosine"}
  • ids.u64 (optional) โ€” N ร— 8 bytes of uint64 IDs

Creating raw f32 from Python:

import numpy as np

embeddings = np.load("embeddings.npy").astype(np.float32)
# Row-major float32 bytes; ensure C-contiguous first
np.ascontiguousarray(embeddings).tofile("embeddings.f32")

NumPy .npyโ€‹

Standard NumPy format, auto-detected from the extension.

Requirements:

  • dtype: float32 (<f4)
  • order: C-order (fortran_order: False)
  • shape: 2D (N, D)
import numpy as np
embeddings = np.random.randn(10000, 768).astype(np.float32)
np.save("embeddings.npy", embeddings)

Building the CLI from Sourceโ€‹

# Build release binary
cargo build --release -p sochdb-tools

# Binary location
./target/release/sochdb-bulk --help

# Run benchmarks
cargo bench -p sochdb-tools

# Install to PATH
cargo install --path sochdb-tools

Bundling with the Python Packageโ€‹

The Python wheel can bundle the native binary:

cd sochdb-python-sdk

# Build and install binary for current platform
python build_native.py

# Then build the wheel
pip wheel .

The binary is installed to src/sochdb/_bin/<platform>/sochdb-bulk.

Performance Tipsโ€‹

  1. Pass C-contiguous float32 โ€” this is what enables the zero-copy insert_batch path; non-contiguous arrays are rejected.
  2. Prefer build_index_from_numpy() for in-memory data โ€” it avoids disk I/O and subprocess spawn.
  3. Use BatchAccumulator for streamed chunks โ€” buffer in numpy, build once.
  4. Use raw f32 for out-of-core builds โ€” fastest to parse and memory-mappable.
  5. Use all CPU cores โ€” set --threads 0 (CLI) for auto-detection; the in-process build releases the GIL and uses Rayon.
  6. Pre-normalize vectors if using cosine similarity, and store on NVMe for very large indexes.

Troubleshootingโ€‹

"Could not find sochdb-bulk binary"โ€‹

The file-based build paths (build_index_from_file, the sochdb-bulk CLI) require the binary. The SDK searches, in order:

  1. System PATH:
    cargo install --path sochdb-tools
    # Or: export PATH="$PATH:/path/to/target/release"
  2. Cargo target directory (development):
    cargo build --release -p sochdb-tools
    # Auto-detected when running from the workspace
  3. Bundled in the wheel:
    pip install sochdb
    # Binary at: site-packages/sochdb/_bin/<platform>/sochdb-bulk

If you only need in-memory builds, use build_index_from_numpy() โ€” it needs no external binary.

Platform Supportโ€‹

The bundled binary supports:

PlatformWheel TagNotes
Linux x86_64manylinux_2_17_x86_64glibc >= 2.17
Linux aarch64manylinux_2_17_aarch64ARM servers
macOSmacosx_11_0_universal2Intel + Apple Silicon
Windowswin_amd64Windows 10+ x64

"Dimension mismatch"โ€‹

  • For the in-process path, the array's second axis must equal the index dimension โ€” insert_batch raises ValueError otherwise.
  • For raw f32 CLI input: pass -d 768 (or provide a sidecar meta.json).
  • For .npy: the dimension is auto-detected from the header.

"Non-contiguous array"โ€‹

insert_batch/insert_batch_with_ids require C-contiguous float32. Fix with:

import numpy as np
vectors = np.ascontiguousarray(vectors, dtype=np.float32)

"Out of memory"โ€‹

For very large datasets (10M+ vectors):

  • Use the out-of-process sochdb-bulk build with a smaller --batch-size and mmap input.
  • Build per-shard indexes (see MultiShardHnswIndex in the native package, a Python threaded scatter-gather wrapper for 100Mโ€“1B scale).
  • Use a 64-bit system with sufficient RAM.

"GLIBC_2.xx not found" (Linux)โ€‹

Your system glibc is older than the wheel requires:

ldd --version  # Check glibc version; needs 2.17 or higher

Solutions:

  1. Use a newer distro (Ubuntu 14.04+, CentOS 7+).
  2. Use a container with newer glibc.
  3. Build from source with your system's glibc.

Architectureโ€‹

The in-process path passes a NumPy buffer straight into Rust; the out-of-process path memory-maps a file and lets the CLI build:

In-process (build_index_from_numpy / insert_batch)
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ NumPy float32 (N, D) โ”‚ C-contiguous, no copy
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โ”‚ zero-copy pointer, GIL released
โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ Rust HNSW build โ”‚ Rayon-parallel insertion
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Out-of-process (build_index_from_file / sochdb-bulk CLI)
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ embeddings.npy/.f32 โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โ”‚ subprocess.run()
โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ sochdb-bulk CLI โ”‚ mmap file, HNSW insertion, save
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ index.hnsw โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The key insight: subprocess spawn is O(1), while per-call FFI marshalling is O(Nยทd) per batch. The in-process zero-copy path is fastest for in-memory data; the subprocess path wins when the dataset cannot fit in RAM.

See the Python SDK Guide and HNSW Vector Search for index configuration details.