feat(8.0): zero-config finalize + cut JS quantization (config.vector = recall + persistMode)
This commit is contained in:
parent
f4c5d9749f
commit
f8e0079d3f
12 changed files with 348 additions and 1739 deletions
359
src/brainy.ts
359
src/brainy.ts
|
|
@ -116,7 +116,7 @@ import type { MigrationPreview, MigrationResult, MigrateOptions } from './migrat
|
|||
import { AggregationIndex } from './aggregation/AggregationIndex.js'
|
||||
import { AggregateMaterializer } from './aggregation/materializer.js'
|
||||
import type { AggregateDefinition, AggregateQueryParams, AggregateResult } from './types/brainy.types.js'
|
||||
import { resolveJsHnswConfig } from './utils/recallPreset.js'
|
||||
import { resolveJsHnswConfig, DEFAULT_RECALL } from './utils/recallPreset.js'
|
||||
import * as fs from 'node:fs'
|
||||
import { Db, type DbHost, type HistoricalQueryHandle } from './db/db.js'
|
||||
import { GenerationStore } from './db/generationStore.js'
|
||||
|
|
@ -205,6 +205,8 @@ type ResolvedBrainyConfig = Required<
|
|||
| 'vector'
|
||||
| 'plugins'
|
||||
| 'integrations'
|
||||
| 'history'
|
||||
| 'eagerEmbeddings'
|
||||
>
|
||||
> &
|
||||
Pick<
|
||||
|
|
@ -215,6 +217,8 @@ type ResolvedBrainyConfig = Required<
|
|||
| 'vector'
|
||||
| 'plugins'
|
||||
| 'integrations'
|
||||
| 'history'
|
||||
| 'eagerEmbeddings'
|
||||
>
|
||||
|
||||
/**
|
||||
|
|
@ -387,6 +391,18 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
private _readyResolve: (() => void) | null = null
|
||||
private _readyReject: ((error: Error) => void) | null = null
|
||||
|
||||
// In-flight init() promise. Guards against concurrent initialization when
|
||||
// multiple operations race to lazily initialize the same instance — all
|
||||
// racers await the single in-flight run. Reset on failure so init() can
|
||||
// be retried after the underlying cause (e.g. a held writer lock) clears.
|
||||
private initPromise: Promise<void> | null = null
|
||||
|
||||
// Set once close() has torn the instance down. close() is terminal: a closed
|
||||
// instance must NOT silently lazy-re-initialize on the next operation (that
|
||||
// would resurrect released indexes and timers). The lazy-init convenience
|
||||
// applies only to instances that were never closed.
|
||||
private closed = false
|
||||
|
||||
// Lazy rebuild state (Production-scale lazy loading)
|
||||
// Prevents race conditions when multiple queries trigger rebuild simultaneously
|
||||
private lazyRebuildInProgress = false
|
||||
|
|
@ -542,13 +558,42 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Initialize Brainy - MUST be called before use
|
||||
* Initialize Brainy.
|
||||
*
|
||||
* **Calling this explicitly is optional.** Every public operation lazily
|
||||
* initializes the instance on first use (`new Brainy()` followed directly
|
||||
* by `add()` / `find()` / `transact()` just works). Call `init()` yourself
|
||||
* when you want to control *when* the startup cost is paid (e.g. during
|
||||
* server boot rather than on the first request) or to pass init-time
|
||||
* configuration overrides.
|
||||
*
|
||||
* Idempotent and concurrency-safe: repeat calls after success return
|
||||
* immediately; calls racing an in-flight initialization await that single
|
||||
* run (overrides are only applied by the run that starts initialization).
|
||||
*
|
||||
* @param overrides Optional configuration overrides for init
|
||||
*/
|
||||
async init(overrides?: Partial<BrainyConfig & { dimensions?: number }>): Promise<void> {
|
||||
if (this.initialized) {
|
||||
return
|
||||
}
|
||||
if (this.initPromise) {
|
||||
return this.initPromise
|
||||
}
|
||||
const run = this.performInit(overrides)
|
||||
this.initPromise = run
|
||||
try {
|
||||
await run
|
||||
} finally {
|
||||
this.initPromise = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The single initialization run guarded by `init()`. Never call directly —
|
||||
* always go through `init()` (or any public method, which lazily inits).
|
||||
*/
|
||||
private async performInit(overrides?: Partial<BrainyConfig & { dimensions?: number }>): Promise<void> {
|
||||
|
||||
// Apply any init-time configuration overrides
|
||||
if (overrides) {
|
||||
|
|
@ -566,7 +611,6 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
...this.config,
|
||||
...configOverrides,
|
||||
storage: mergedStorage,
|
||||
index: { ...this.config.index, ...configOverrides.index },
|
||||
verbose: configOverrides.verbose ?? this.config.verbose,
|
||||
silent: configOverrides.silent ?? this.config.silent
|
||||
}
|
||||
|
|
@ -702,32 +746,6 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
setMsgpackImplementation(msgpackProvider)
|
||||
}
|
||||
|
||||
// Provider: native SQ8 approximate distance (e.g. cortex's Rust SIMD) — swaps
|
||||
// the JS quantized-distance used in HNSW SQ8 reranking. Signature is
|
||||
// byte-compatible with the JS distanceSQ8; falls back to JS when absent.
|
||||
const sq8DistanceProvider = this.pluginRegistry.getProvider<
|
||||
(a: Uint8Array, aMin: number, aMax: number, b: Uint8Array, bMin: number, bMax: number) => number
|
||||
>('distance:sq8')
|
||||
if (sq8DistanceProvider) {
|
||||
const { setSQ8DistanceImplementation } = await import('./utils/vectorQuantization.js')
|
||||
setSQ8DistanceImplementation(sq8DistanceProvider)
|
||||
}
|
||||
|
||||
// Provider: native SQ4 approximate distance (cortex's Rust). Same swap
|
||||
// pattern as SQ8; signature is byte-compatible with the JS distanceSQ4
|
||||
// (4-bit quantization range, packed nibbles). Used in HNSW SQ4 reranking
|
||||
// when config.hnsw.quantization.bits === 4. Falls back to JS when absent.
|
||||
const sq4DistanceProvider = this.pluginRegistry.getProvider<
|
||||
(
|
||||
a: Uint8Array, aMin: number, aMax: number, aDim: number,
|
||||
b: Uint8Array, bMin: number, bMax: number, bDim: number
|
||||
) => number
|
||||
>('distance:sq4')
|
||||
if (sq4DistanceProvider) {
|
||||
const { setSQ4DistanceImplementation } = await import('./utils/vectorQuantization.js')
|
||||
setSQ4DistanceImplementation(sq4DistanceProvider)
|
||||
}
|
||||
|
||||
// Provider: sort:topK (e.g. cortex's native partial-sort / heap-select) — swaps the
|
||||
// JS result-ranking used by find() to pick the top `offset + limit` rows. The provider
|
||||
// returns indices into a scores array, ordered descending with stable ties, identical
|
||||
|
|
@ -831,11 +849,6 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
// Connect distributed components to storage
|
||||
await this.connectDistributedStorage()
|
||||
|
||||
// Warm up if configured
|
||||
if (this.config.warmup) {
|
||||
await this.warmup()
|
||||
}
|
||||
|
||||
// Register shutdown hooks for graceful count flushing (once globally)
|
||||
if (!Brainy.shutdownHooksRegisteredGlobally) {
|
||||
this.registerShutdownHooks()
|
||||
|
|
@ -875,12 +888,34 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
await this._vfs.init()
|
||||
this._vfsInitialized = true // Mark VFS as fully initialized
|
||||
|
||||
// Eager embedding initialization for cloud deployments
|
||||
// When eagerEmbeddings is true, initialize the WASM embedding engine now
|
||||
// instead of lazily on first embed() call. This moves the 90-140 second
|
||||
// WASM compilation to container startup rather than first request.
|
||||
// Recommended for: Cloud Run, Lambda, Fargate, Kubernetes
|
||||
if (this.config.eagerEmbeddings && !this.pluginRegistry.hasProvider('embeddings')) {
|
||||
// Eager embedding initialization.
|
||||
//
|
||||
// Adaptive default (8.0): the WASM embedding engine eagerly initializes
|
||||
// during init() WHENEVER it is the active embedder — i.e. no native
|
||||
// 'embeddings' provider has taken over — and the instance is a writer
|
||||
// (not reader-mode) outside of unit tests. The WASM module (≈93MB with
|
||||
// the embedded model) takes 90-140s to compile on throttled CPUs; paying
|
||||
// that during boot rather than on the first embed()-driven call is the
|
||||
// right default for the overwhelmingly common single-process server.
|
||||
//
|
||||
// Skipped automatically when:
|
||||
// - a native 'embeddings' provider is registered (it owns embeddings;
|
||||
// the WASM engine is dead weight),
|
||||
// - reader-mode (readers don't embed — they query existing vectors),
|
||||
// - unit-test mode (tests must stay fast and use the mock embedder).
|
||||
//
|
||||
// `eagerEmbeddings: false` is the explicit override to force lazy init
|
||||
// (first-embed) even when this instance is the active embedder.
|
||||
const isUnitTestMode =
|
||||
process.env.BRAINY_UNIT_TEST === 'true' ||
|
||||
(globalThis as { __BRAINY_UNIT_TEST__?: boolean }).__BRAINY_UNIT_TEST__ === true
|
||||
const eager = this.config.eagerEmbeddings ?? true
|
||||
if (
|
||||
eager &&
|
||||
!this.pluginRegistry.hasProvider('embeddings') &&
|
||||
this.config.mode !== 'reader' &&
|
||||
!isUnitTestMode
|
||||
) {
|
||||
console.log('Eager embedding initialization enabled...')
|
||||
await embeddingManager.init()
|
||||
console.log('Embedding engine ready')
|
||||
|
|
@ -1026,11 +1061,24 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Ensure Brainy is initialized
|
||||
* Ensure Brainy is initialized, lazily initializing on first use.
|
||||
*
|
||||
* Called at the top of every public operation, so `new Brainy()` followed
|
||||
* directly by `add()` / `find()` / any other call just works — forgetting
|
||||
* `init()` is not an error. Concurrent first operations share a single
|
||||
* initialization run (see `init()`); if initialization fails, the error
|
||||
* propagates to the operation that triggered it.
|
||||
*
|
||||
* `close()` is terminal: a closed instance throws here instead of silently
|
||||
* re-initializing — using a closed Brainy is a consumer bug, not a lazy-init
|
||||
* opportunity.
|
||||
*/
|
||||
private async ensureInitialized(): Promise<void> {
|
||||
if (this.closed) {
|
||||
throw new Error('Brainy instance is not initialized: it was closed via close(). Create a new instance.')
|
||||
}
|
||||
if (!this.initialized) {
|
||||
throw new Error('Brainy not initialized. Call init() first.')
|
||||
await this.init()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -5143,6 +5191,68 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
return this.generationStore.compact(options)
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Resolve the effective generational-history retention policy
|
||||
* from `config.history`, filling per-field defaults so a partial object
|
||||
* inherits the rest. This is the single read site for the policy referenced
|
||||
* by {@link autoCompactHistory}.
|
||||
*
|
||||
* Defaults (zero-config): keep the 100 most recent committed generations
|
||||
* AND everything committed within the last 7 days; auto-compact on
|
||||
* `flush()` / `close()`. A generation is reclaimed only when it is older
|
||||
* than BOTH bounds and no live `Db` pin protects it.
|
||||
*
|
||||
* @returns The resolved policy: retention bounds plus the `autoCompact` flag.
|
||||
*/
|
||||
private resolveHistoryPolicy(): {
|
||||
retainGenerations: number
|
||||
retainMs: number
|
||||
autoCompact: boolean
|
||||
} {
|
||||
const history = this.config.history
|
||||
return {
|
||||
retainGenerations: history?.retainGenerations ?? 100,
|
||||
retainMs: history?.retainMs ?? 604_800_000, // 7 days
|
||||
autoCompact: history?.autoCompact ?? true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Run history compaction with the resolved retention policy if
|
||||
* `config.history.autoCompact` is on (the default). Invoked from `flush()`
|
||||
* and `close()` so generational record-sets cannot accumulate unbounded
|
||||
* across a long-lived writer's lifetime.
|
||||
*
|
||||
* Read-only instances and an explicit `autoCompact: false` skip silently.
|
||||
* Pinned generations (live `Db` values) are never reclaimed — the safety
|
||||
* invariant lives in {@link GenerationStore.compact}. Failures are logged
|
||||
* and swallowed: compaction is housekeeping and must never fail a flush or
|
||||
* a clean shutdown.
|
||||
*/
|
||||
private async autoCompactHistory(): Promise<void> {
|
||||
// Nothing to compact on a read-only instance or before init wired up the
|
||||
// generation store (close() can run defensively on an uninitialized brain).
|
||||
if (this.isReadOnly || !this.generationStore) {
|
||||
return
|
||||
}
|
||||
const policy = this.resolveHistoryPolicy()
|
||||
if (!policy.autoCompact) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await this.generationStore.compact({
|
||||
retainGenerations: policy.retainGenerations,
|
||||
retainMs: policy.retainMs
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Auto-compaction of generational history failed (non-fatal): ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Replace this store's ENTIRE state from a snapshot directory
|
||||
* previously produced by `db.persist(path)`. Destructive: current
|
||||
|
|
@ -6858,6 +6968,11 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
this.generationStore.persistCounterNow()
|
||||
])
|
||||
|
||||
// 6. Auto-compact generational history per config.history (default on).
|
||||
// Runs after the flush so durable state is in place; respects live
|
||||
// Db pins and an explicit autoCompact: false.
|
||||
await this.autoCompactHistory()
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
|
||||
console.log(`All indexes flushed to disk in ${elapsed}ms`)
|
||||
|
|
@ -10167,14 +10282,6 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
return this.embedder(textToEmbed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Warm up the system
|
||||
*/
|
||||
private async warmup(): Promise<void> {
|
||||
// Warm up embedder
|
||||
await this.embed('warmup')
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicitly warm up the embedding engine
|
||||
*
|
||||
|
|
@ -10300,26 +10407,22 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
/**
|
||||
* Setup index — single unified vector graph.
|
||||
*
|
||||
* Brainy 8.0 ships filesystem-only storage (no cloud adapters), so persistence
|
||||
* mode defaults to `'immediate'` everywhere. Operators who want bulk-ingest
|
||||
* speed can set `config.vector.persistMode = 'deferred'`.
|
||||
* Persistence mode is auto-selected from the storage adapter (see
|
||||
* {@link resolveHNSWPersistMode}): `'immediate'` on filesystem storage,
|
||||
* `'deferred'` on memory storage. Operators can override with an explicit
|
||||
* `config.vector.persistMode` (e.g. `'deferred'` for bulk-ingest speed on
|
||||
* filesystem storage).
|
||||
*/
|
||||
private setupIndex(): JsHnswVectorIndex {
|
||||
// 8.0 config surface: config.vector.{recall, quantization, persistMode}.
|
||||
// 8.0 config surface: config.vector.{recall, persistMode}.
|
||||
// The recall preset translates to HNSW knobs (M / efConstruction / efSearch)
|
||||
// via resolveJsHnswConfig. No algorithm-internal knobs are exposed at the
|
||||
// public surface.
|
||||
// public surface. The JS index computes exact float32 distances throughout —
|
||||
// there is no quantization path.
|
||||
const recallKnobs = resolveJsHnswConfig(this.config.vector)
|
||||
const vectorCfg = this.config.vector
|
||||
const indexConfig = {
|
||||
...this.config.index,
|
||||
...recallKnobs,
|
||||
distanceFunction: this.distance,
|
||||
quantization: vectorCfg?.quantization ? {
|
||||
enabled: vectorCfg.quantization.enabled ?? false,
|
||||
bits: vectorCfg.quantization.bits ?? 8,
|
||||
rerankMultiplier: 3
|
||||
} : undefined
|
||||
distanceFunction: this.distance
|
||||
}
|
||||
|
||||
const persistMode = this.resolveHNSWPersistMode()
|
||||
|
|
@ -10346,8 +10449,18 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
const persistMode = this.resolveHNSWPersistMode()
|
||||
const vectorFactory = this.pluginRegistry.getProvider<(config: any, distance: DistanceFunction, options: any) => any>('vector')
|
||||
if (vectorFactory) {
|
||||
// Native providers receive the algorithm-neutral surface: the resolved
|
||||
// recall preset name (per the 8.0 provider contract they translate it
|
||||
// to their own internal knobs), plus the JS HNSW knob tuple for
|
||||
// providers that mirror the open-core implementation. Quantization at
|
||||
// scale is the native provider's own concern (e.g. DiskANN PQ) — Brainy
|
||||
// exposes no quantization knob.
|
||||
return vectorFactory(
|
||||
{ ...this.config.index, distanceFunction: this.distance },
|
||||
{
|
||||
...resolveJsHnswConfig(this.config.vector),
|
||||
recall: this.config.vector?.recall ?? DEFAULT_RECALL,
|
||||
distanceFunction: this.distance
|
||||
},
|
||||
this.distance,
|
||||
{ storage: this.storage, persistMode }
|
||||
)
|
||||
|
|
@ -10356,15 +10469,23 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Resolve HNSW persistence mode.
|
||||
* Extracted so both setupIndex() and the HNSW plugin factory path can use it.
|
||||
* Resolve the vector persistence mode from the storage adapter.
|
||||
*
|
||||
* Brainy 8.0 ships filesystem-only; the cloud-storage smart-default
|
||||
* (deferred for cloud, immediate for local) collapses to "always
|
||||
* immediate unless the user overrides via config.vector.persistMode".
|
||||
* Auto-selected when `config.vector.persistMode` is omitted:
|
||||
* - **filesystem** (and any persistent adapter): `'immediate'` — per-add
|
||||
* durability is the point of a persistent backend.
|
||||
* - **memory**: `'deferred'` — nothing survives the process anyway, so
|
||||
* per-add persistence writes are pure overhead; deferred batches them
|
||||
* into `flush()` / `close()`.
|
||||
*
|
||||
* An explicit `config.vector.persistMode` always wins (the operator
|
||||
* escape hatch for bulk-ingest pipelines on filesystem storage).
|
||||
*/
|
||||
private resolveHNSWPersistMode(): 'immediate' | 'deferred' {
|
||||
return this.config.vector?.persistMode ?? 'immediate'
|
||||
if (this.config.vector?.persistMode) {
|
||||
return this.config.vector.persistMode
|
||||
}
|
||||
return this.getStorageType() === 'memory' ? 'deferred' : 'immediate'
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -10390,59 +10511,40 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
)
|
||||
}
|
||||
|
||||
// Validate numeric configurations
|
||||
if (config?.index?.m && (config.index.m < 1 || config.index.m > 128)) {
|
||||
throw new Error(`Invalid index m parameter: ${config.index.m}. Must be between 1 and 128`)
|
||||
}
|
||||
|
||||
if (config?.index?.efConstruction && (config.index.efConstruction < 1 || config.index.efConstruction > 1000)) {
|
||||
throw new Error(`Invalid index efConstruction: ${config.index.efConstruction}. Must be between 1 and 1000`)
|
||||
}
|
||||
|
||||
if (config?.index?.efSearch && (config.index.efSearch < 1 || config.index.efSearch > 1000)) {
|
||||
throw new Error(`Invalid index efSearch: ${config.index.efSearch}. Must be between 1 and 1000`)
|
||||
}
|
||||
|
||||
// Auto-detect distributed mode based on environment and configuration
|
||||
const distributedConfig = this.autoDetectDistributed(config?.distributed)
|
||||
// Distributed mode is explicit opt-in only (config or BRAINY_DISTRIBUTED
|
||||
// env var) — never inferred from deployment heuristics.
|
||||
const distributedConfig = this.resolveDistributedConfig(config?.distributed)
|
||||
|
||||
return {
|
||||
storage: config?.storage || { type: 'auto' },
|
||||
index: config?.index || {},
|
||||
cache: config?.cache ?? true,
|
||||
distributed: distributedConfig,
|
||||
warmup: config?.warmup ?? false,
|
||||
realtime: config?.realtime ?? false,
|
||||
multiTenancy: config?.multiTenancy ?? false,
|
||||
telemetry: config?.telemetry ?? false,
|
||||
verbose: config?.verbose ?? false,
|
||||
silent: config?.silent ?? false,
|
||||
// New performance options with smart defaults
|
||||
disableAutoRebuild: config?.disableAutoRebuild ?? false, // false = auto-decide based on size
|
||||
disableMetrics: config?.disableMetrics ?? false,
|
||||
disableAutoOptimize: config?.disableAutoOptimize ?? false,
|
||||
batchWrites: config?.batchWrites ?? true,
|
||||
maxConcurrentOperations: config?.maxConcurrentOperations ?? 10,
|
||||
// Memory management options
|
||||
// false = auto-decide based on dataset size (inline vs lazy rebuild)
|
||||
disableAutoRebuild: config?.disableAutoRebuild ?? false,
|
||||
// Memory management escape hatches over the RAM-derived auto limits
|
||||
maxQueryLimit: config?.maxQueryLimit ?? undefined,
|
||||
reservedQueryMemory: config?.reservedQueryMemory ?? undefined,
|
||||
// Vector index configuration (8.0) — algorithm-neutral surface with
|
||||
// `recall` preset + `persistMode` (folded in from 7.x's hnswPersistMode).
|
||||
// See BRAINY-8.0-RENAME-COORDINATION § A.2 + § G.2.
|
||||
vector: config?.vector ?? undefined,
|
||||
// Embedding initialization - false = lazy init on first embed()
|
||||
eagerEmbeddings: config?.eagerEmbeddings ?? false,
|
||||
// Generational-history retention — defaults applied at the read site
|
||||
// (resolveHistoryPolicy) so a partial object inherits per-field defaults.
|
||||
history: config?.history ?? undefined,
|
||||
// Embedding initialization — left undefined when omitted so the adaptive
|
||||
// default resolves at the init() read site (eager when the WASM embedder
|
||||
// is the active one on a non-reader writer outside unit tests). Explicit
|
||||
// true/false always wins. See the eagerEmbeddings JSDoc in brainy.types.ts.
|
||||
eagerEmbeddings: config?.eagerEmbeddings ?? undefined,
|
||||
// Plugin configuration - undefined = auto-detect
|
||||
plugins: config?.plugins ?? undefined,
|
||||
// Integration Hub - undefined/false = disabled
|
||||
integrations: config?.integrations ?? undefined,
|
||||
// Migration — disabled by default, opt-in for automatic migration
|
||||
autoMigrate: config?.autoMigrate ?? false,
|
||||
// Subtype pairing enforcement (7.30.0) — opt-in.
|
||||
// false: only per-type rules registered via brain.requireSubtype() apply.
|
||||
// true: every public write path requires subtype on every type.
|
||||
// { except: [...] }: strict, but listed types may omit subtype.
|
||||
// Becomes the default in 8.0.0.
|
||||
// Migration — auto-run by default. Pending data migrations apply inline
|
||||
// during init() for small datasets (<10K entities); larger datasets
|
||||
// defer with a notice so the operator can schedule brain.migrate().
|
||||
autoMigrate: config?.autoMigrate ?? true,
|
||||
// Subtype required-by-default (8.0 — per BRAINY-8.0-SUBTYPE-CONTRACT § C-1).
|
||||
// Every write path now requires `subtype` on every type unless the consumer
|
||||
// opts out explicitly. Opt-out: `requireSubtype: false` (last-resort migration
|
||||
|
|
@ -10926,6 +11028,11 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
* This ensures deferred persistence mode data is saved
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
// Phase 0: Auto-compact generational history per config.history (default
|
||||
// on) BEFORE the generation store closes below. Respects live Db pins and
|
||||
// an explicit autoCompact: false; no-op on read-only instances.
|
||||
await this.autoCompactHistory()
|
||||
|
||||
// Phase 1: Flush ALL components in parallel to persist buffered data
|
||||
// This is critical when cortex native providers buffer data in Rust memory
|
||||
await Promise.all([
|
||||
|
|
@ -11033,26 +11140,34 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
}
|
||||
|
||||
this.initialized = false
|
||||
// close() is terminal: block lazy re-initialization on any subsequent
|
||||
// operation (ensureInitialized() throws once this is set).
|
||||
this.closed = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Intelligently auto-detect distributed configuration
|
||||
* Zero-config: Automatically determines best distributed settings
|
||||
*/
|
||||
private autoDetectDistributed(config?: BrainyConfig['distributed']): BrainyConfig['distributed'] {
|
||||
// If explicitly disabled, respect that
|
||||
if (config?.enabled === false) {
|
||||
return config
|
||||
}
|
||||
|
||||
// Auto-detect based on environment variables (common in production)
|
||||
const envEnabled = process.env.BRAINY_DISTRIBUTED === 'true' ||
|
||||
process.env.NODE_ENV === 'production' ||
|
||||
process.env.CLUSTER_SIZE ||
|
||||
process.env.KUBERNETES_SERVICE_HOST // Running in K8s
|
||||
|
||||
// If not explicitly configured but environment suggests distributed
|
||||
if (!config && envEnabled) {
|
||||
/**
|
||||
* Resolve distributed-cluster configuration. **Explicit opt-in only**:
|
||||
* either `config.distributed.enabled === true` or the
|
||||
* `BRAINY_DISTRIBUTED=true` environment variable. Once opted in, the
|
||||
* remaining fields default sensibly (64 shards, 3 replicas, raft, http,
|
||||
* hostname-derived nodeId).
|
||||
*
|
||||
* Earlier versions auto-enabled cluster mode from deployment heuristics
|
||||
* (`NODE_ENV=production`, a Kubernetes service host, `CLUSTER_SIZE`).
|
||||
* That violated zero-config safety: a single-process production
|
||||
* deployment — the overwhelmingly common case — would silently start a
|
||||
* coordinator, bind a port, and run raft against itself. Deployment
|
||||
* environment does not imply a multi-node Brainy cluster, so the
|
||||
* heuristics are gone.
|
||||
*/
|
||||
private resolveDistributedConfig(config?: BrainyConfig['distributed']): BrainyConfig['distributed'] {
|
||||
// Env-var opt-in for deployments that can't touch code (the ONLY
|
||||
// environment signal honoured — it names brainy explicitly).
|
||||
if (!config && process.env.BRAINY_DISTRIBUTED === 'true') {
|
||||
return {
|
||||
enabled: true,
|
||||
nodeId: process.env.HOSTNAME || process.env.NODE_ID || `node-${Date.now()}`,
|
||||
|
|
@ -11065,7 +11180,7 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
}
|
||||
}
|
||||
|
||||
// Merge with provided config, applying intelligent defaults
|
||||
// Explicit config: fill the per-field defaults.
|
||||
return config ? {
|
||||
...config,
|
||||
nodeId: config.nodeId || process.env.HOSTNAME || `node-${Date.now()}`,
|
||||
|
|
|
|||
|
|
@ -352,11 +352,6 @@ export function presetToBrainyConfig(preset: PresetConfig): any {
|
|||
if (preset.allowDirectReads) config.allowDirectReads = true
|
||||
if (preset.lazyLoadInReadOnlyMode) config.lazyLoadInReadOnlyMode = true
|
||||
}
|
||||
|
||||
// Handle cache settings
|
||||
if (preset.cache) {
|
||||
config.cache = preset.cache
|
||||
}
|
||||
|
||||
|
||||
return config
|
||||
}
|
||||
|
|
@ -96,10 +96,6 @@ export interface HNSWNoun {
|
|||
vector: Vector
|
||||
connections: Map<number, Set<string>> // level -> set of connected noun ids
|
||||
level: number // The highest layer this noun appears in
|
||||
// SQ8 quantized vector for approximate distance computation (B1 optimization)
|
||||
quantizedVector?: Uint8Array // 4x smaller than float32 vector
|
||||
codebookMin?: number // quantization codebook minimum
|
||||
codebookMax?: number // quantization codebook maximum
|
||||
// ✅ NO metadata field - stored separately for optimization
|
||||
}
|
||||
|
||||
|
|
@ -475,12 +471,6 @@ export interface HNSWConfig {
|
|||
ml: number // Maximum level
|
||||
useDiskBasedIndex?: boolean // Whether to use disk-based index
|
||||
maxConcurrentNeighborWrites?: number // Maximum concurrent neighbor updates during insert. Default: unlimited (full concurrency)
|
||||
// SQ8 vector quantization (4x memory reduction, ~0.4% accuracy loss)
|
||||
quantization?: {
|
||||
enabled: boolean // default: false — preserves current behavior exactly
|
||||
bits?: 8 | 4 // default: 8 (SQ8). SQ4 has a pure-JS implementation; cortex's distance:sq4 SIMD provider accelerates it.
|
||||
rerankMultiplier?: number // default: 3 — over-retrieve 3x, rerank with float32
|
||||
}
|
||||
// Vector storage mode
|
||||
vectorStorage?: 'memory' | 'lazy' // default: 'memory' — 'lazy' evicts vectors after insert
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,8 +14,6 @@ import { euclideanDistance, calculateDistancesBatch } from '../utils/index.js'
|
|||
import type { BaseStorage } from '../storage/baseStorage.js'
|
||||
import { getGlobalCache, UnifiedCache } from '../utils/unifiedCache.js'
|
||||
import { prodLog } from '../utils/logger.js'
|
||||
import { quantizeSQ8, distanceSQ8 } from '../utils/vectorQuantization.js'
|
||||
import type { SQ8QuantizedVector } from '../utils/vectorQuantization.js'
|
||||
import type { VectorIndexProvider } from '../plugin.js'
|
||||
import { ConnectionsCodec, compressedConnectionsKey } from './connectionsCodec.js'
|
||||
|
||||
|
|
@ -67,10 +65,8 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
|
|||
private dirtyNodes: Set<string> = new Set() // Nodes with unpersisted HNSW data
|
||||
private dirtySystem: boolean = false // Whether system data (entryPoint, maxLevel) needs persist
|
||||
|
||||
// SQ8 quantization support (B1 optimization)
|
||||
private quantizationEnabled: boolean = false
|
||||
private rerankMultiplier: number = 3
|
||||
// Lazy vector storage (B2 optimization)
|
||||
// Lazy vector storage (B2 optimization): evict the float32 vector to
|
||||
// storage after insert; reload on demand via getVectorSafe() + UnifiedCache.
|
||||
private vectorStorageMode: 'memory' | 'lazy' = 'memory'
|
||||
|
||||
constructor(
|
||||
|
|
@ -87,12 +83,6 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
|
|||
this.storage = options.storage || null
|
||||
this.persistMode = options.persistMode || 'immediate'
|
||||
|
||||
// SQ8 quantization config (default: disabled, preserves current behavior)
|
||||
if (config.quantization?.enabled) {
|
||||
this.quantizationEnabled = true
|
||||
this.rerankMultiplier = config.quantization.rerankMultiplier ?? 3
|
||||
}
|
||||
|
||||
// Vector storage mode (default: 'memory', preserves current behavior)
|
||||
this.vectorStorageMode = config.vectorStorage || 'memory'
|
||||
|
||||
|
|
@ -376,7 +366,7 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
|
|||
// Generate random level for this noun
|
||||
const nounLevel = this.getRandomLevel()
|
||||
|
||||
// Create new noun with optional SQ8 quantization
|
||||
// Create new noun
|
||||
const noun: HNSWNoun = {
|
||||
id,
|
||||
vector,
|
||||
|
|
@ -384,14 +374,6 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
|
|||
level: nounLevel
|
||||
}
|
||||
|
||||
// Quantize vector if enabled (B1: 4x storage reduction)
|
||||
if (this.quantizationEnabled) {
|
||||
const sq8 = quantizeSQ8(vector)
|
||||
noun.quantizedVector = sq8.quantized
|
||||
noun.codebookMin = sq8.min
|
||||
noun.codebookMax = sq8.max
|
||||
}
|
||||
|
||||
// Initialize empty connection sets for each level
|
||||
for (let level = 0; level <= nounLevel; level++) {
|
||||
noun.connections.set(level, new Set<string>())
|
||||
|
|
@ -664,16 +646,18 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
|
|||
}
|
||||
|
||||
/**
|
||||
* Search for nearest neighbors
|
||||
* Search for nearest neighbors.
|
||||
*
|
||||
* When SQ8 quantization is enabled and reranking is active:
|
||||
* - Phase 1: Over-retrieve k*rerankMultiplier candidates using SQ8 approximate distances
|
||||
* - Phase 2: Load full float32 vectors for candidates, compute exact distances, return top k
|
||||
* The JS HNSW index computes exact float32 distances throughout — there is
|
||||
* no approximate/rerank path. The `options.rerank` field exists only for the
|
||||
* shared {@link VectorIndexProvider} contract (a native provider may use it
|
||||
* for its own approximate-then-rerank pipeline); the JS index ignores it.
|
||||
*
|
||||
* @param queryVector Query vector
|
||||
* @param k Number of results to return
|
||||
* @param filter Optional filter function
|
||||
* @param options Additional search options
|
||||
* @param options Additional search options (`candidateIds` to restrict the
|
||||
* search to a pre-filtered set; `rerank` is provider-only, ignored here)
|
||||
*/
|
||||
public async search(
|
||||
queryVector: Vector,
|
||||
|
|
@ -737,11 +721,6 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
|
|||
|
||||
let currObj = entryPoint
|
||||
|
||||
// SQ8: Pre-quantize query vector for fast approximate distances during traversal
|
||||
if (this.quantizationEnabled) {
|
||||
this._querySQ8 = quantizeSQ8(queryVector)
|
||||
}
|
||||
|
||||
// OPTIMIZATION: Preload entry point vector
|
||||
await this.preloadVectors([entryPoint.id])
|
||||
|
||||
|
|
@ -809,14 +788,9 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
|
|||
}
|
||||
}
|
||||
|
||||
// Determine effective rerank multiplier
|
||||
const rerankActive = this.quantizationEnabled && (options?.rerank || this.rerankMultiplier > 1)
|
||||
const multiplier = options?.rerank?.multiplier ?? this.rerankMultiplier
|
||||
const effectiveK = rerankActive ? k * multiplier : k
|
||||
|
||||
// Search at level 0 with ef = effectiveK
|
||||
// If we have a filter, increase ef to compensate for filtered results
|
||||
const ef = filter ? Math.max(this.config.efSearch * 3, effectiveK * 3) : Math.max(this.config.efSearch, effectiveK)
|
||||
// Search at level 0. If we have a filter, increase ef to compensate for
|
||||
// filtered-out results.
|
||||
const ef = filter ? Math.max(this.config.efSearch * 3, k * 3) : Math.max(this.config.efSearch, k)
|
||||
const nearestNouns = await this.searchLayer(
|
||||
queryVector,
|
||||
currObj,
|
||||
|
|
@ -825,36 +799,7 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
|
|||
filter
|
||||
)
|
||||
|
||||
// Phase 2: Rerank with exact float32 distances if quantization is active (B3)
|
||||
if (rerankActive && nearestNouns.size > 0) {
|
||||
// Clear SQ8 cache before reranking (we need exact distances now)
|
||||
this._querySQ8 = null
|
||||
|
||||
const candidates = [...nearestNouns].slice(0, effectiveK)
|
||||
|
||||
// Load full float32 vectors for the candidate set
|
||||
const candidateIds = candidates.map(([id]) => id)
|
||||
await this.preloadVectors(candidateIds)
|
||||
|
||||
// Recompute exact distances
|
||||
const reranked: Array<[string, number]> = []
|
||||
for (const [id] of candidates) {
|
||||
const noun = this.nouns.get(id)
|
||||
if (!noun) continue
|
||||
const exactVector = await this.getVectorSafe(noun)
|
||||
const exactDist = this.distanceFunction(queryVector, exactVector)
|
||||
reranked.push([id, exactDist])
|
||||
}
|
||||
|
||||
// Sort by exact distance and return top k
|
||||
reranked.sort((a, b) => a[1] - b[1])
|
||||
return reranked.slice(0, k)
|
||||
}
|
||||
|
||||
// Clear SQ8 cache
|
||||
this._querySQ8 = null
|
||||
|
||||
// Convert to array and sort by distance
|
||||
// Exact float32 distances throughout — return the top k directly.
|
||||
return [...nearestNouns].slice(0, k)
|
||||
}
|
||||
|
||||
|
|
@ -1148,19 +1093,6 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
|
|||
* @returns number | Promise<number> - sync when cached, async when needs load
|
||||
*/
|
||||
private distanceSafe(queryVector: Vector, noun: HNSWNoun): number | Promise<number> {
|
||||
// SQ8 fast path: use quantized distance when available (B1 optimization)
|
||||
// This avoids loading full float32 vectors during graph traversal
|
||||
if (this.quantizationEnabled &&
|
||||
noun.quantizedVector &&
|
||||
noun.codebookMin !== undefined &&
|
||||
noun.codebookMax !== undefined &&
|
||||
this._querySQ8) {
|
||||
return distanceSQ8(
|
||||
this._querySQ8.quantized, this._querySQ8.min, this._querySQ8.max,
|
||||
noun.quantizedVector, noun.codebookMin, noun.codebookMax
|
||||
)
|
||||
}
|
||||
|
||||
// Try sync fast path
|
||||
const nounVector = this.getVectorSync(noun)
|
||||
|
||||
|
|
@ -1175,9 +1107,6 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
|
|||
)
|
||||
}
|
||||
|
||||
// Cached SQ8 quantization of the current query vector for distanceSafe fast path
|
||||
private _querySQ8: SQ8QuantizedVector | null = null
|
||||
|
||||
/**
|
||||
* Get all nodes at a specific level for clustering
|
||||
* This enables O(n) clustering using HNSW's natural hierarchy
|
||||
|
|
@ -1299,14 +1228,6 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
|
|||
level: hnswData.level
|
||||
}
|
||||
|
||||
// Restore SQ8 quantized data if quantization is enabled
|
||||
if (this.quantizationEnabled && nounData.vector.length > 0) {
|
||||
const sq8 = quantizeSQ8(nounData.vector)
|
||||
noun.quantizedVector = sq8.quantized
|
||||
noun.codebookMin = sq8.min
|
||||
noun.codebookMax = sq8.max
|
||||
}
|
||||
|
||||
// Restore connections from persisted data — compressed blob path
|
||||
// first, legacy JSON-array fallback for indexes written before
|
||||
// graph link compression landed.
|
||||
|
|
|
|||
|
|
@ -1206,20 +1206,14 @@ export interface BrainyConfig {
|
|||
}
|
||||
| StorageAdapter
|
||||
|
||||
// Index configuration
|
||||
index?: {
|
||||
m?: number // HNSW M parameter
|
||||
efConstruction?: number // HNSW construction parameter
|
||||
efSearch?: number // HNSW search parameter
|
||||
}
|
||||
|
||||
// Performance options
|
||||
cache?: boolean | { // Enable caching
|
||||
maxSize?: number
|
||||
ttl?: number
|
||||
}
|
||||
|
||||
// Distributed configuration
|
||||
/**
|
||||
* Distributed-cluster configuration. **Explicit opt-in only** — Brainy
|
||||
* never auto-enables cluster mode from environment heuristics (a single
|
||||
* production process is the 90th-percentile deployment). Set
|
||||
* `enabled: true` (or the `BRAINY_DISTRIBUTED=true` env var) to activate;
|
||||
* the remaining fields then default sensibly (64 shards, 3 replicas,
|
||||
* raft consensus, http transport, hostname-derived nodeId).
|
||||
*/
|
||||
distributed?: {
|
||||
enabled: boolean
|
||||
nodeId?: string
|
||||
|
|
@ -1231,23 +1225,18 @@ export interface BrainyConfig {
|
|||
transport?: 'tcp' | 'http' | 'udp'
|
||||
}
|
||||
|
||||
// Advanced options
|
||||
warmup?: boolean // Warm up on init
|
||||
realtime?: boolean // Enable real-time updates
|
||||
multiTenancy?: boolean // Enable service isolation
|
||||
telemetry?: boolean // Send anonymous usage stats
|
||||
|
||||
// Performance tuning options for production
|
||||
disableAutoRebuild?: boolean // Disable automatic index rebuilding on init
|
||||
disableMetrics?: boolean // Completely disable metrics collection
|
||||
disableAutoOptimize?: boolean // Disable automatic index optimization
|
||||
batchWrites?: boolean // Enable write batching for better performance
|
||||
maxConcurrentOperations?: number // Limit concurrent file operations
|
||||
/**
|
||||
* Disable the automatic index rebuild check during `init()`. By default
|
||||
* Brainy auto-decides from dataset size: small datasets rebuild missing
|
||||
* indexes inline, large datasets rebuild lazily on first query. Set `true`
|
||||
* only when an operator wants full manual control via `repairIndex()`.
|
||||
*/
|
||||
disableAutoRebuild?: boolean
|
||||
|
||||
/**
|
||||
* Vector index configuration (Brainy 8.0).
|
||||
*
|
||||
* Three knobs. No escape hatch. The algorithm-internal HNSW knobs
|
||||
* Two knobs. No escape hatch. The algorithm-internal HNSW knobs
|
||||
* (`M`, `efConstruction`, `efSearch`, `ml`, …) and DiskANN knobs
|
||||
* (`pqM`, `searchListSize`, `alpha`, …) are deliberately not exposed —
|
||||
* the `recall` preset covers the legitimate quality/latency tradeoff
|
||||
|
|
@ -1271,26 +1260,15 @@ export interface BrainyConfig {
|
|||
*/
|
||||
recall?: 'fast' | 'balanced' | 'accurate'
|
||||
|
||||
/**
|
||||
* Vector quantization. Independent of `recall` — it trades RAM for a
|
||||
* small recall hit at any preset. `bits: 8` is SQ8 (4× memory reduction,
|
||||
* ~0.4% recall loss). `bits: 4` is SQ4 (8× memory reduction, ~1-3% recall
|
||||
* loss). Both ship in the open-core path; cortex's `distance:sq8` /
|
||||
* `distance:sq4` SIMD providers accelerate them when available.
|
||||
*
|
||||
* Silently ignored on a native DiskANN-style provider path (it uses its
|
||||
* own PQ math instead).
|
||||
*/
|
||||
quantization?: {
|
||||
enabled?: boolean
|
||||
bits?: 8 | 4
|
||||
}
|
||||
|
||||
/**
|
||||
* Vector persistence mode. `'immediate'` writes per-noun graph state on
|
||||
* every `add()`; durable but slower. `'deferred'` writes only on
|
||||
* `flush()` / `close()`; faster bulk ingest. Defaults to `'immediate'`
|
||||
* on filesystem storage (the only persistent backend in 8.0).
|
||||
* `flush()` / `close()`; faster bulk ingest.
|
||||
*
|
||||
* **Auto-selected from the storage adapter when omitted:** `'immediate'`
|
||||
* on filesystem storage (durability is the point of a persistent
|
||||
* backend), `'deferred'` on memory storage (nothing survives the process
|
||||
* anyway, so per-add persistence writes are pure overhead).
|
||||
*
|
||||
* Brainy 7.x called this `hnswPersistMode` at the top level. The 8.0
|
||||
* surface folds it under `config.vector` alongside `recall`.
|
||||
|
|
@ -1298,16 +1276,60 @@ export interface BrainyConfig {
|
|||
persistMode?: 'immediate' | 'deferred'
|
||||
}
|
||||
|
||||
/**
|
||||
* Generational-history retention policy (8.0 MVCC).
|
||||
*
|
||||
* Every `transact()` produces an immutable generation record-set that
|
||||
* serves historical reads (`asOf()`, pinned `Db` values). Without
|
||||
* compaction those record-sets accumulate forever, so Brainy
|
||||
* **auto-compacts on every `flush()` and `close()`** with this policy.
|
||||
* A generation is reclaimed only when it is older than BOTH bounds
|
||||
* (and never while a live `Db` pin protects it):
|
||||
*
|
||||
* - `retainGenerations` — keep at least the N most recent committed
|
||||
* generations (default: `100`).
|
||||
* - `retainMs` — keep everything committed within the window
|
||||
* (default: 7 days).
|
||||
*
|
||||
* Escape hatches: raise either bound for longer time-travel windows,
|
||||
* or set `autoCompact: false` to manage history manually via
|
||||
* `brain.compactHistory()`. Long-term archives belong in
|
||||
* `db.persist(path)` snapshots, which compaction never touches.
|
||||
*/
|
||||
history?: {
|
||||
/** Keep at least this many recent committed generations (default: 100). */
|
||||
retainGenerations?: number
|
||||
/** Keep generations committed within this window in ms (default: 604800000 = 7 days). */
|
||||
retainMs?: number
|
||||
/** Run compaction automatically on flush()/close() (default: true). */
|
||||
autoCompact?: boolean
|
||||
}
|
||||
|
||||
// Memory management options
|
||||
maxQueryLimit?: number // Override auto-detected query result limit (max: 100000)
|
||||
reservedQueryMemory?: number // Memory reserved for queries in bytes (e.g., 1073741824 = 1GB)
|
||||
|
||||
// Embedding initialization
|
||||
// Controls when the WASM embedding engine is initialized
|
||||
// - false (default): Lazy initialization on first embed() call
|
||||
// - true: Eager initialization during brain.init()
|
||||
// Set to true for cloud deployments (Cloud Run, Lambda) where you want
|
||||
// WASM compilation to happen during container startup, not on first request
|
||||
/**
|
||||
* Controls when the WASM embedding engine is initialized.
|
||||
*
|
||||
* **Adaptive default (8.0):** when omitted, the engine eagerly initializes
|
||||
* during `init()` whenever the WASM embedder is the *active* one — i.e. no
|
||||
* native `'embeddings'` provider is registered — and this instance is a
|
||||
* writer (not `mode: 'reader'`) running outside unit tests. The WASM module
|
||||
* (≈93MB with the embedded model) takes 90-140s to compile on throttled
|
||||
* CPUs, so paying that during boot rather than on the first `embed()`-driven
|
||||
* call is the right default for a single-process server.
|
||||
*
|
||||
* The adaptive path skips itself automatically when a native embeddings
|
||||
* provider owns embeddings, in reader-mode (readers query existing vectors
|
||||
* and never embed), and in unit-test mode (kept fast via the mock embedder).
|
||||
*
|
||||
* - `true` — force eager init during `init()` (the adaptive default already
|
||||
* does this for the active-embedder writer case; set it explicitly to be
|
||||
* unambiguous).
|
||||
* - `false` — explicit override to force lazy init (first `embed()` call)
|
||||
* even when this instance is the active embedder.
|
||||
*/
|
||||
eagerEmbeddings?: boolean
|
||||
|
||||
// Plugin configuration
|
||||
|
|
@ -1330,8 +1352,10 @@ export interface BrainyConfig {
|
|||
integrations?: boolean | IntegrationsConfig
|
||||
|
||||
// Migration configuration
|
||||
// - false/undefined (default): Log warning if pending migrations exist, but don't auto-run
|
||||
// - true: Automatically run pending migrations during init() for small datasets (<10K entities)
|
||||
// - true/undefined (default): Automatically run pending data migrations during
|
||||
// init() for small datasets (<10K entities). Larger datasets defer with a
|
||||
// notice — call brain.migrate() explicitly (optionally with backupTo).
|
||||
// - false: Never auto-run; log a notice when pending migrations exist.
|
||||
autoMigrate?: boolean
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -17,8 +17,6 @@
|
|||
* descending by score, with stable ordering for ties (lower original index first). This
|
||||
* guarantees that wiring the hook in or out never changes which results a query returns or
|
||||
* in what order — only how fast the ranking is computed.
|
||||
*
|
||||
* @see setSQ8DistanceImplementation in ./vectorQuantization.js for the sibling provider-swap pattern.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,581 +0,0 @@
|
|||
/**
|
||||
* Vector Quantization Utilities
|
||||
*
|
||||
* Standalone SQ8 (Scalar Quantization, 8-bit) functions for HNSW vector compression.
|
||||
* Each vector is independently quantized using its own min/max codebook.
|
||||
*
|
||||
* Storage format per vector:
|
||||
* - quantized: Uint8Array (dimension bytes, 4x smaller than float32)
|
||||
* - min: number (codebook minimum)
|
||||
* - max: number (codebook maximum)
|
||||
*
|
||||
* Accuracy: SQ8 introduces ~0.4% error per dimension on normalized vectors.
|
||||
* Combined with reranking (3x over-retrieval + float32 rerank), recall@100
|
||||
* is expected to remain >99% for typical workloads.
|
||||
*/
|
||||
|
||||
import type { Vector } from '../coreTypes.js'
|
||||
|
||||
/**
|
||||
* Result of SQ8 quantization
|
||||
*/
|
||||
export interface SQ8QuantizedVector {
|
||||
quantized: Uint8Array
|
||||
min: number
|
||||
max: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for HNSW quantization
|
||||
*/
|
||||
export interface HNSWQuantizationConfig {
|
||||
enabled: boolean
|
||||
bits: 8 | 4
|
||||
rerankMultiplier: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Quantize a float32 vector to SQ8 (8-bit unsigned integers).
|
||||
*
|
||||
* Maps [min, max] of the vector to [0, 255].
|
||||
* For zero-range vectors (all values equal), all quantized values are 128.
|
||||
*
|
||||
* @param vector - Input float32 vector
|
||||
* @returns Quantized vector with codebook (min/max)
|
||||
*/
|
||||
export function quantizeSQ8(vector: Vector): SQ8QuantizedVector {
|
||||
const len = vector.length
|
||||
let min = vector[0]
|
||||
let max = vector[0]
|
||||
|
||||
for (let i = 1; i < len; i++) {
|
||||
const v = vector[i]
|
||||
if (v < min) min = v
|
||||
if (v > max) max = v
|
||||
}
|
||||
|
||||
const range = max - min
|
||||
const quantized = new Uint8Array(len)
|
||||
|
||||
if (range === 0) {
|
||||
// All values are identical — map to midpoint
|
||||
quantized.fill(128)
|
||||
} else {
|
||||
const scale = 255 / range
|
||||
for (let i = 0; i < len; i++) {
|
||||
quantized[i] = Math.round((vector[i] - min) * scale)
|
||||
}
|
||||
}
|
||||
|
||||
return { quantized, min, max }
|
||||
}
|
||||
|
||||
/**
|
||||
* Dequantize an SQ8 vector back to float32.
|
||||
*
|
||||
* @param quantized - Uint8Array of quantized values
|
||||
* @param min - Codebook minimum
|
||||
* @param max - Codebook maximum
|
||||
* @returns Reconstructed float32 vector
|
||||
*/
|
||||
export function dequantizeSQ8(quantized: Uint8Array, min: number, max: number): Vector {
|
||||
const len = quantized.length
|
||||
const result = new Array<number>(len)
|
||||
const range = max - min
|
||||
|
||||
if (range === 0) {
|
||||
result.fill(min)
|
||||
} else {
|
||||
const scale = range / 255
|
||||
for (let i = 0; i < len; i++) {
|
||||
result[i] = min + quantized[i] * scale
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Reference JS implementation of {@link distanceSQ8}: approximate cosine distance
|
||||
* between two SQ8-quantized vectors, operating directly on the uint8 arrays without
|
||||
* full dequantization. Cosine distance is `1 - (A·B / (|A| · |B|))`; for quantized
|
||||
* values `q_i` with codebook `(min, max)` the actual value is
|
||||
* `min + q_i * (max - min) / 255`, and the dot product / norms are computed on the
|
||||
* quantized values and rescaled by the codebook parameters.
|
||||
*
|
||||
* Exported so callers can explicitly restore the default after swapping in a native
|
||||
* implementation, and so cross-language parity tests can compare native output
|
||||
* against this baseline.
|
||||
*
|
||||
* @param a - First quantized vector.
|
||||
* @param aMin - First vector's codebook minimum.
|
||||
* @param aMax - First vector's codebook maximum.
|
||||
* @param b - Second quantized vector.
|
||||
* @param bMin - Second vector's codebook minimum.
|
||||
* @param bMax - Second vector's codebook maximum.
|
||||
* @returns Approximate cosine distance in [0, 2].
|
||||
*/
|
||||
export function distanceSQ8Js(
|
||||
a: Uint8Array,
|
||||
aMin: number,
|
||||
aMax: number,
|
||||
b: Uint8Array,
|
||||
bMin: number,
|
||||
bMax: number
|
||||
): number {
|
||||
const len = a.length
|
||||
|
||||
// Compute raw integer sums for efficiency
|
||||
// actual_a[i] = aMin + a[i] * aScale, where aScale = (aMax - aMin) / 255
|
||||
// actual_b[i] = bMin + b[i] * bScale, where bScale = (bMax - bMin) / 255
|
||||
//
|
||||
// dot = sum(actual_a[i] * actual_b[i])
|
||||
// = sum((aMin + a[i]*aScale) * (bMin + b[i]*bScale))
|
||||
// = n*aMin*bMin + aMin*bScale*sum(b[i]) + bMin*aScale*sum(a[i]) + aScale*bScale*sum(a[i]*b[i])
|
||||
//
|
||||
// normA^2 = sum(actual_a[i]^2)
|
||||
// = n*aMin^2 + 2*aMin*aScale*sum(a[i]) + aScale^2*sum(a[i]^2)
|
||||
|
||||
const aScale = (aMax - aMin) / 255
|
||||
const bScale = (bMax - bMin) / 255
|
||||
|
||||
let sumA = 0
|
||||
let sumB = 0
|
||||
let sumAB = 0
|
||||
let sumAA = 0
|
||||
let sumBB = 0
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
const ai = a[i]
|
||||
const bi = b[i]
|
||||
sumA += ai
|
||||
sumB += bi
|
||||
sumAB += ai * bi
|
||||
sumAA += ai * ai
|
||||
sumBB += bi * bi
|
||||
}
|
||||
|
||||
const dot =
|
||||
len * aMin * bMin +
|
||||
aMin * bScale * sumB +
|
||||
bMin * aScale * sumA +
|
||||
aScale * bScale * sumAB
|
||||
|
||||
const normASq =
|
||||
len * aMin * aMin +
|
||||
2 * aMin * aScale * sumA +
|
||||
aScale * aScale * sumAA
|
||||
|
||||
const normBSq =
|
||||
len * bMin * bMin +
|
||||
2 * bMin * bScale * sumB +
|
||||
bScale * bScale * sumBB
|
||||
|
||||
const normA = Math.sqrt(normASq)
|
||||
const normB = Math.sqrt(normBSq)
|
||||
|
||||
if (normA === 0 || normB === 0) {
|
||||
return 1.0 // Maximum distance for zero vectors
|
||||
}
|
||||
|
||||
const cosine = dot / (normA * normB)
|
||||
// Clamp to [-1, 1] to handle floating point imprecision
|
||||
return 1 - Math.max(-1, Math.min(1, cosine))
|
||||
}
|
||||
|
||||
/** Signature of the SQ8 approximate-distance function (quantized cosine distance). */
|
||||
export type SQ8DistanceFn = (
|
||||
a: Uint8Array,
|
||||
aMin: number,
|
||||
aMax: number,
|
||||
b: Uint8Array,
|
||||
bMin: number,
|
||||
bMax: number
|
||||
) => number
|
||||
|
||||
/**
|
||||
* Active SQ8 distance implementation. Defaults to the JS `distanceSQ8Js`; swapped to
|
||||
* a native SIMD implementation by {@link setSQ8DistanceImplementation} when a cortex
|
||||
* `distance:sq8` provider is registered. The native implementation is byte-compatible
|
||||
* (same quantized cosine formula), so results match within float tolerance.
|
||||
*/
|
||||
let activeSQ8Distance: SQ8DistanceFn = distanceSQ8Js
|
||||
|
||||
/**
|
||||
* Approximate cosine distance between two SQ8-quantized vectors, dispatched to the
|
||||
* active implementation (JS by default, native when a `distance:sq8` provider is
|
||||
* registered). Used in the HNSW SQ8 reranking hot path.
|
||||
*
|
||||
* @param a - First quantized vector.
|
||||
* @param aMin - First vector's codebook minimum.
|
||||
* @param aMax - First vector's codebook maximum.
|
||||
* @param b - Second quantized vector.
|
||||
* @param bMin - Second vector's codebook minimum.
|
||||
* @param bMax - Second vector's codebook maximum.
|
||||
* @returns Approximate cosine distance in [0, 2].
|
||||
*/
|
||||
export function distanceSQ8(
|
||||
a: Uint8Array,
|
||||
aMin: number,
|
||||
aMax: number,
|
||||
b: Uint8Array,
|
||||
bMin: number,
|
||||
bMax: number
|
||||
): number {
|
||||
return activeSQ8Distance(a, aMin, aMax, b, bMin, bMax)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the SQ8 approximate-distance implementation at runtime (e.g. cortex's Rust
|
||||
* SIMD `distance:sq8`). Called by `brainy.ts` when the provider is registered. Pass
|
||||
* {@link distanceSQ8Js} to restore the JS default.
|
||||
*
|
||||
* @param fn - Native implementation; must match {@link SQ8DistanceFn} byte-for-byte.
|
||||
*/
|
||||
export function setSQ8DistanceImplementation(fn: SQ8DistanceFn): void {
|
||||
activeSQ8Distance = fn
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize an SQ8 quantized vector to a compact binary format.
|
||||
*
|
||||
* Format: [min:float32][max:float32][quantized:uint8[]]
|
||||
* Total size: 8 + dimension bytes
|
||||
*
|
||||
* @param sq8 - Quantized vector
|
||||
* @returns ArrayBuffer with serialized data
|
||||
*/
|
||||
export function serializeSQ8(sq8: SQ8QuantizedVector): ArrayBuffer {
|
||||
const buffer = new ArrayBuffer(8 + sq8.quantized.length)
|
||||
const view = new DataView(buffer)
|
||||
view.setFloat32(0, sq8.min, true) // little-endian
|
||||
view.setFloat32(4, sq8.max, true)
|
||||
new Uint8Array(buffer, 8).set(sq8.quantized)
|
||||
return buffer
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize an SQ8 quantized vector from binary format.
|
||||
*
|
||||
* @param buffer - ArrayBuffer with serialized data
|
||||
* @returns Deserialized SQ8 quantized vector
|
||||
*/
|
||||
export function deserializeSQ8(buffer: ArrayBuffer): SQ8QuantizedVector {
|
||||
const view = new DataView(buffer)
|
||||
const min = view.getFloat32(0, true)
|
||||
const max = view.getFloat32(4, true)
|
||||
const quantized = new Uint8Array(buffer, 8)
|
||||
return { quantized, min, max }
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// SQ4 — 4-bit scalar quantization (2.5.0 #30)
|
||||
//
|
||||
// Each dimension is mapped to [0, 15] (16 levels) and packed two-per-byte:
|
||||
// high nibble = vector[2i], low nibble = vector[2i+1]. Achieves 8× compression
|
||||
// vs float32 (vs 4× for SQ8) at the cost of higher quantization error.
|
||||
//
|
||||
// The format + arithmetic are byte-for-byte identical to cortex's Rust
|
||||
// `quantize_sq4` / `dequantize_sq4` / `cosine_distance_sq4` (see
|
||||
// `cortex/native/src/quantization.rs`). When a `distance:sq4` provider is
|
||||
// registered (cortex's SIMD Rust implementation), `setSQ4DistanceImplementation`
|
||||
// swaps the JS reference in for the native one; cross-language parity tests
|
||||
// verify the swap is recall-equivalent within float tolerance.
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* @description 4-bit scalar-quantized vector. The `quantized` buffer is packed
|
||||
* two nibbles per byte (high nibble = `vector[2i]`, low nibble = `vector[2i+1]`).
|
||||
* Odd dimensions place the final value in the high nibble and leave the low
|
||||
* nibble zero. `dim` must be recorded explicitly because packed byte length
|
||||
* `ceil(dim/2)` doesn't encode whether the trailing low nibble is data or pad.
|
||||
*/
|
||||
export interface SQ4QuantizedVector {
|
||||
/** Packed nibbles. Length = `ceil(dim / 2)`. */
|
||||
quantized: Uint8Array
|
||||
/** Codebook minimum (used to reconstruct the value range on dequantization). */
|
||||
min: number
|
||||
/** Codebook maximum. */
|
||||
max: number
|
||||
/** Original vector dimensionality (needed to detect the odd-dim pad nibble). */
|
||||
dim: number
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Quantize a float vector to SQ4. Each dimension is mapped to
|
||||
* `[0, 15]` via `round((v - min) / range * 15)`, clamped, then packed
|
||||
* two-per-byte. Zero-range vectors (all values identical) map to the midpoint
|
||||
* `8` in every nibble — `0x88` in every byte.
|
||||
*
|
||||
* Byte-for-byte identical to cortex's `quantize_sq4` (Rust); cross-language
|
||||
* parity tests confirm this. Use {@link distanceSQ4} for approximate cosine
|
||||
* directly on the packed bytes — there's no need to {@link dequantizeSQ4}
|
||||
* before computing distances.
|
||||
*
|
||||
* @param vector - Input float vector (length must be ≥ 1).
|
||||
* @returns Packed SQ4 vector with codebook + original dimension.
|
||||
* @throws If `vector` is empty.
|
||||
*/
|
||||
export function quantizeSQ4(vector: Vector): SQ4QuantizedVector {
|
||||
const dim = vector.length
|
||||
if (dim === 0) {
|
||||
throw new Error('quantizeSQ4: vector cannot be empty')
|
||||
}
|
||||
|
||||
let min = vector[0]
|
||||
let max = vector[0]
|
||||
for (let i = 1; i < dim; i++) {
|
||||
const v = vector[i]
|
||||
if (v < min) min = v
|
||||
if (v > max) max = v
|
||||
}
|
||||
|
||||
const packedLen = (dim + 1) >>> 1
|
||||
const quantized = new Uint8Array(packedLen)
|
||||
const range = max - min
|
||||
|
||||
if (range === 0) {
|
||||
// All values identical — every nibble = 8 (midpoint of [0, 15]).
|
||||
quantized.fill(0x88)
|
||||
return { quantized, min, max, dim }
|
||||
}
|
||||
|
||||
const invRange = 15 / range
|
||||
let i = 0
|
||||
let byteIdx = 0
|
||||
while (i + 1 < dim) {
|
||||
const hi = clampNibble(Math.round((vector[i] - min) * invRange))
|
||||
const lo = clampNibble(Math.round((vector[i + 1] - min) * invRange))
|
||||
quantized[byteIdx++] = (hi << 4) | lo
|
||||
i += 2
|
||||
}
|
||||
if (i < dim) {
|
||||
// Odd dimension: final value in the high nibble, low nibble = 0 pad.
|
||||
const hi = clampNibble(Math.round((vector[i] - min) * invRange))
|
||||
quantized[byteIdx] = hi << 4
|
||||
}
|
||||
|
||||
return { quantized, min, max, dim }
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Dequantize an SQ4 packed buffer back to a float vector.
|
||||
* Reverses {@link quantizeSQ4}: each nibble is mapped back to
|
||||
* `min + nibble * (max - min) / 15`. The trailing pad nibble of an odd-`dim`
|
||||
* vector is correctly dropped (the result has length exactly `dim`).
|
||||
*
|
||||
* @param quantized - Packed SQ4 buffer (length `ceil(dim/2)`).
|
||||
* @param min - Codebook minimum from {@link quantizeSQ4}.
|
||||
* @param max - Codebook maximum from {@link quantizeSQ4}.
|
||||
* @param dim - Original vector dimensionality.
|
||||
* @returns Reconstructed float vector of length `dim`.
|
||||
*/
|
||||
export function dequantizeSQ4(
|
||||
quantized: Uint8Array,
|
||||
min: number,
|
||||
max: number,
|
||||
dim: number
|
||||
): Vector {
|
||||
const result = new Array<number>(dim)
|
||||
const range = max - min
|
||||
|
||||
if (range === 0) {
|
||||
for (let i = 0; i < dim; i++) result[i] = min
|
||||
return result
|
||||
}
|
||||
|
||||
const scale = range / 15
|
||||
let out = 0
|
||||
for (let b = 0; b < quantized.length && out < dim; b++) {
|
||||
const byte = quantized[b]
|
||||
result[out++] = min + ((byte >>> 4) & 0x0f) * scale
|
||||
if (out < dim) {
|
||||
result[out++] = min + (byte & 0x0f) * scale
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Reference JS implementation of {@link distanceSQ4}: approximate
|
||||
* cosine distance between two SQ4-quantized vectors. Unpacks nibbles inline,
|
||||
* then accumulates the same `(sumA, sumB, sumAA, sumBB, sumAB)` integers SQ8
|
||||
* uses and applies the SQ8 cosine-from-quantized formula with `15` (not `255`)
|
||||
* as the quantization range. Result is byte-equivalent to cortex's
|
||||
* `cosine_distance_sq4` (Rust) within float tolerance.
|
||||
*
|
||||
* Exported so callers can explicitly restore the JS default after a native
|
||||
* swap, and so cross-language parity tests compare native output against this
|
||||
* baseline.
|
||||
*/
|
||||
export function distanceSQ4Js(
|
||||
a: Uint8Array,
|
||||
aMin: number,
|
||||
aMax: number,
|
||||
aDim: number,
|
||||
b: Uint8Array,
|
||||
bMin: number,
|
||||
bMax: number,
|
||||
bDim: number
|
||||
): number {
|
||||
if (aDim !== bDim) {
|
||||
throw new Error(`distanceSQ4Js: dimension mismatch ${aDim} vs ${bDim}`)
|
||||
}
|
||||
if (aDim === 0) {
|
||||
throw new Error('distanceSQ4Js: vectors cannot be empty')
|
||||
}
|
||||
|
||||
let sumA = 0
|
||||
let sumB = 0
|
||||
let sumAB = 0
|
||||
let sumAA = 0
|
||||
let sumBB = 0
|
||||
|
||||
// Walk both packed buffers nibble-by-nibble, in lock-step.
|
||||
let i = 0
|
||||
let byteIdx = 0
|
||||
while (i + 1 < aDim) {
|
||||
const aByte = a[byteIdx]
|
||||
const bByte = b[byteIdx]
|
||||
const aHi = (aByte >>> 4) & 0x0f
|
||||
const aLo = aByte & 0x0f
|
||||
const bHi = (bByte >>> 4) & 0x0f
|
||||
const bLo = bByte & 0x0f
|
||||
|
||||
sumA += aHi + aLo
|
||||
sumB += bHi + bLo
|
||||
sumAB += aHi * bHi + aLo * bLo
|
||||
sumAA += aHi * aHi + aLo * aLo
|
||||
sumBB += bHi * bHi + bLo * bLo
|
||||
|
||||
i += 2
|
||||
byteIdx++
|
||||
}
|
||||
if (i < aDim) {
|
||||
// Odd dim: only the high nibble of the final byte is data.
|
||||
const aHi = (a[byteIdx] >>> 4) & 0x0f
|
||||
const bHi = (b[byteIdx] >>> 4) & 0x0f
|
||||
sumA += aHi
|
||||
sumB += bHi
|
||||
sumAB += aHi * bHi
|
||||
sumAA += aHi * aHi
|
||||
sumBB += bHi * bHi
|
||||
}
|
||||
|
||||
const aScale = (aMax - aMin) / 15
|
||||
const bScale = (bMax - bMin) / 15
|
||||
const n = aDim
|
||||
|
||||
const dot =
|
||||
n * aMin * bMin +
|
||||
aMin * bScale * sumB +
|
||||
bMin * aScale * sumA +
|
||||
aScale * bScale * sumAB
|
||||
|
||||
const normASq =
|
||||
n * aMin * aMin +
|
||||
2 * aMin * aScale * sumA +
|
||||
aScale * aScale * sumAA
|
||||
|
||||
const normBSq =
|
||||
n * bMin * bMin +
|
||||
2 * bMin * bScale * sumB +
|
||||
bScale * bScale * sumBB
|
||||
|
||||
const denom = Math.sqrt(normASq) * Math.sqrt(normBSq)
|
||||
if (denom === 0) return 1.0
|
||||
|
||||
const cosine = dot / denom
|
||||
return 1 - Math.max(-1, Math.min(1, cosine))
|
||||
}
|
||||
|
||||
/** Signature of the SQ4 approximate-distance function (quantized cosine distance). */
|
||||
export type SQ4DistanceFn = (
|
||||
a: Uint8Array,
|
||||
aMin: number,
|
||||
aMax: number,
|
||||
aDim: number,
|
||||
b: Uint8Array,
|
||||
bMin: number,
|
||||
bMax: number,
|
||||
bDim: number
|
||||
) => number
|
||||
|
||||
/**
|
||||
* Active SQ4 distance implementation. Defaults to the JS {@link distanceSQ4Js};
|
||||
* swapped to cortex's SIMD Rust implementation by {@link setSQ4DistanceImplementation}
|
||||
* when a `distance:sq4` provider is registered. The native implementation is
|
||||
* byte-compatible (same quantized cosine formula with `15` as the quantization
|
||||
* range), so results match within float tolerance.
|
||||
*/
|
||||
let activeSQ4Distance: SQ4DistanceFn = distanceSQ4Js
|
||||
|
||||
/**
|
||||
* @description Approximate cosine distance between two SQ4-quantized vectors,
|
||||
* dispatched to the active implementation (JS by default, native when
|
||||
* `distance:sq4` is registered). Used in the HNSW SQ4 reranking hot path when
|
||||
* `config.quantization.bits === 4`.
|
||||
*/
|
||||
export function distanceSQ4(
|
||||
a: Uint8Array,
|
||||
aMin: number,
|
||||
aMax: number,
|
||||
aDim: number,
|
||||
b: Uint8Array,
|
||||
bMin: number,
|
||||
bMax: number,
|
||||
bDim: number
|
||||
): number {
|
||||
return activeSQ4Distance(a, aMin, aMax, aDim, b, bMin, bMax, bDim)
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Replace the SQ4 approximate-distance implementation at runtime
|
||||
* (cortex's Rust SIMD `distance:sq4`). Called by `brainy.ts` when the provider
|
||||
* is registered. Pass {@link distanceSQ4Js} to restore the JS default.
|
||||
*/
|
||||
export function setSQ4DistanceImplementation(fn: SQ4DistanceFn): void {
|
||||
activeSQ4Distance = fn
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Serialize an SQ4 quantized vector to a compact binary format.
|
||||
*
|
||||
* Layout: `[min: float32][max: float32][dim: uint32 LE][packed: uint8[]]`.
|
||||
* Total size: `12 + ceil(dim/2)` bytes.
|
||||
*
|
||||
* The `dim` field is required (unlike SQ8) because the packed length alone
|
||||
* doesn't disambiguate even-vs-odd original dimension. The 4-byte `dim`
|
||||
* header keeps total overhead under 1% for typical 384-dim vectors.
|
||||
*/
|
||||
export function serializeSQ4(sq4: SQ4QuantizedVector): ArrayBuffer {
|
||||
const packedLen = sq4.quantized.length
|
||||
const buffer = new ArrayBuffer(12 + packedLen)
|
||||
const view = new DataView(buffer)
|
||||
view.setFloat32(0, sq4.min, true) // little-endian
|
||||
view.setFloat32(4, sq4.max, true)
|
||||
view.setUint32(8, sq4.dim, true)
|
||||
new Uint8Array(buffer, 12).set(sq4.quantized)
|
||||
return buffer
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Deserialize an SQ4 quantized vector from the binary format
|
||||
* written by {@link serializeSQ4}.
|
||||
*/
|
||||
export function deserializeSQ4(buffer: ArrayBuffer): SQ4QuantizedVector {
|
||||
const view = new DataView(buffer)
|
||||
const min = view.getFloat32(0, true)
|
||||
const max = view.getFloat32(4, true)
|
||||
const dim = view.getUint32(8, true)
|
||||
const quantized = new Uint8Array(buffer, 12)
|
||||
return { quantized, min, max, dim }
|
||||
}
|
||||
|
||||
/** Clamp a value to `[0, 15]` and return as an integer in the 4-bit range. */
|
||||
function clampNibble(n: number): number {
|
||||
if (n < 0) return 0
|
||||
if (n > 15) return 15
|
||||
return n | 0
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue