feat: mmap-vector backend wiring — HNSWIndex consumes vectorStore:mmap (2.4.0 #2)
Cortex already registers the vectorStore:mmap provider (its Rust NativeMmapVectorStore), but brainy has never consumed it — preloadVectors and getVectorSafe still go straight to storage.getNounVector for every id, even when an mmap layer is available. This wires the consumer end. Architecture: - NEW MmapVectorBackend (src/hnsw/mmapVectorBackend.ts) — bridges brainy's UUID-keyed vector reads to a int-slot mmap file via the vectorStore:mmap provider. Slots are addressed by the stable int id from the post-2.4.0 #1 EntityIdMapper (the foundation this depends on). Auto-grows the file (doubling) when a write lands beyond capacity, so HNSWIndex never has to think about sizing. The class never touches per-entity storage — it owns only the mmap layer. - HNSWIndex changes — adds a vectorBackend field + a setVectorBackend setter. The vector read paths (preloadVectors, getVectorSafe) try the mmap layer first; on a storage fallback hit, they LAZILY write back into the mmap slot. An upgraded install converges to the zero-copy fast path under live traffic — no big-bang migration step. The legacy per-entity path is preserved and still used when no backend is set. - brainy.ts wiring — a new private wireMmapVectorBackend() runs once during init, after plugin activation + metadataIndex setup. It activates the backend only when (a) the vectorStore:mmap provider is registered, (b) the storage adapter resolves a real local path via getBinaryBlobPath(), and (c) the metadata index exposes its idMapper. Cloud adapters return null on (b) and the backend is silently skipped; HNSWIndex's behaviour is then identical to pre-2.4.0. - Provider interfaces in plugin.ts — VectorStoreMmapProvider and VectorStoreMmapInstance document the contract cortex's class fulfils (the class IS the provider — static factory methods). Brainy depends on the interfaces, not on cortex; the structural match is verified when cortex 2.4.0 picks up this brainy release. Tests (1428 total, +11 vs pre-2.4.0): - tests/unit/hnsw/mmap-vector-backend.test.ts — 6 unit tests with an in-memory mock provider. Covers round-trip, batch reads with interleaved misses, slot stability (no re-slotting on overwrite), file growth without data loss, idempotent open, and null returns for unwritten slots. The real perf integration with cortex's NativeMmapVectorStore is exercised when cortex 2.4.0 wires this in. - tests/unit/utils/entity-id-mapper-stability.test.ts — moved here from tests/regression/ (which is NOT in the unit-config include glob, so the five #23 tests were not actually being run by npm test). The unit config matches tests/unit/**/*.test.ts. The 2.4.0 #2 follow-up will be the chunked-segment layout for remote storage adapters (S3 / R2 / GCS) where a single growing file doesn't fit immutable objects. For 2.4.0 release: local-FS only.
This commit is contained in:
parent
b2408cb5a6
commit
d4cb26c604
6 changed files with 640 additions and 4 deletions
|
|
@ -18,6 +18,7 @@ import { prodLog } from '../utils/logger.js'
|
|||
import { quantizeSQ8, distanceSQ8 } from '../utils/vectorQuantization.js'
|
||||
import type { SQ8QuantizedVector } from '../utils/vectorQuantization.js'
|
||||
import type { HnswProvider } from '../plugin.js'
|
||||
import { MmapVectorBackend } from './mmapVectorBackend.js'
|
||||
|
||||
// Default HNSW parameters
|
||||
const DEFAULT_CONFIG: HNSWConfig = {
|
||||
|
|
@ -49,6 +50,13 @@ export class HNSWIndex implements HnswProvider {
|
|||
private unifiedCache: UnifiedCache // Shared cache with Graph and Metadata indexes
|
||||
// Always-adaptive caching - no "mode" concept, system adapts automatically
|
||||
|
||||
// Optional mmap-vector backend (2.4.0 #2). When set, the read paths
|
||||
// (preloadVectors, getVectorSafe) try the mmap layer before storage, and
|
||||
// on a storage hit they write back into the mmap slot — a lazy migration
|
||||
// that converges upgraded installs to the fast path under live traffic.
|
||||
// Null on cloud storage adapters (no local path) and pre-injection init.
|
||||
private vectorBackend: MmapVectorBackend | null = null
|
||||
|
||||
// COW (Copy-on-Write) support
|
||||
private cowEnabled: boolean = false
|
||||
private cowModifiedNodes: Set<string> = new Set()
|
||||
|
|
@ -101,6 +109,22 @@ export class HNSWIndex implements HnswProvider {
|
|||
this.useParallelization = useParallelization
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Inject (or detach) the mmap-vector backend. When set, the
|
||||
* vector read paths (`preloadVectors`, `getVectorSafe`) try the mmap layer
|
||||
* before storage and lazily write back on storage hits — converging an
|
||||
* upgraded install to the zero-copy fast path without a migration step.
|
||||
* Setting to null reverts to the existing per-entity read path.
|
||||
*
|
||||
* Lifecycle: brainy.ts wires this after plugin activation when (a) the
|
||||
* `vectorStore:mmap` provider is registered AND (b) the storage adapter
|
||||
* resolves a real local path via `getBinaryBlobPath()`. Cloud adapters
|
||||
* leave it null; HNSWIndex's behaviour is then identical to pre-2.4.0.
|
||||
*/
|
||||
public setVectorBackend(backend: MmapVectorBackend | null): void {
|
||||
this.vectorBackend = backend
|
||||
}
|
||||
|
||||
/**
|
||||
* Get whether parallelization is enabled
|
||||
*/
|
||||
|
|
@ -1073,7 +1097,23 @@ export class HNSWIndex implements HnswProvider {
|
|||
const cacheKey = `hnsw:vector:${noun.id}`
|
||||
|
||||
const vector = await this.unifiedCache.get(cacheKey, async () => {
|
||||
// Cache miss - load from storage
|
||||
// Try the mmap-vector backend first when wired — zero-copy slot read,
|
||||
// no per-entity object hydration.
|
||||
if (this.vectorBackend) {
|
||||
const fromMmap = this.vectorBackend.readByUuid(noun.id)
|
||||
if (fromMmap) {
|
||||
this.unifiedCache.set(
|
||||
cacheKey,
|
||||
fromMmap,
|
||||
'hnsw',
|
||||
fromMmap.length * 4,
|
||||
50
|
||||
)
|
||||
return fromMmap
|
||||
}
|
||||
}
|
||||
|
||||
// Storage fallback — the canonical source of truth.
|
||||
if (!this.storage) {
|
||||
throw new Error('Storage not available for vector loading')
|
||||
}
|
||||
|
|
@ -1083,6 +1123,18 @@ export class HNSWIndex implements HnswProvider {
|
|||
throw new Error(`Vector not found for noun ${noun.id}`)
|
||||
}
|
||||
|
||||
// Lazy migration: write back into the mmap slot so the next read is
|
||||
// mmap-fast. Idempotent + resumable; a write failure is non-fatal —
|
||||
// the field still serves this read, and the next storage hit re-tries
|
||||
// the migration. (See mmapVectorBackend.ts for the contract.)
|
||||
if (this.vectorBackend) {
|
||||
try {
|
||||
this.vectorBackend.writeByUuid(noun.id, loaded)
|
||||
} catch (error) {
|
||||
prodLog.debug(`MmapVectorBackend write-back failed for ${noun.id}`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// Add to UnifiedCache with cost-aware eviction
|
||||
// This competes fairly with Graph and Metadata indexes
|
||||
this.unifiedCache.set(
|
||||
|
|
@ -1140,7 +1192,50 @@ export class HNSWIndex implements HnswProvider {
|
|||
private async preloadVectors(nodeIds: string[]): Promise<void> {
|
||||
if (nodeIds.length === 0) return
|
||||
|
||||
// Use UnifiedCache's request coalescing to prevent duplicate loads
|
||||
// mmap-vector backend fast path: batched O(1) slot reads + lazy
|
||||
// write-back migration on miss. Cache populated from both layers so
|
||||
// subsequent in-session reads are O(1) memory.
|
||||
if (this.vectorBackend) {
|
||||
// Filter out ids already cached so we never re-read.
|
||||
const uncachedIds: string[] = []
|
||||
for (const id of nodeIds) {
|
||||
if (this.unifiedCache.getSync(`hnsw:vector:${id}`) === undefined) {
|
||||
uncachedIds.push(id)
|
||||
}
|
||||
}
|
||||
if (uncachedIds.length === 0) return
|
||||
|
||||
const fromMmap = this.vectorBackend.readBatchByUuid(uncachedIds)
|
||||
const misses: string[] = []
|
||||
for (let i = 0; i < uncachedIds.length; i++) {
|
||||
const id = uncachedIds[i]
|
||||
const v = fromMmap[i]
|
||||
if (v) {
|
||||
this.unifiedCache.set(`hnsw:vector:${id}`, v, 'hnsw', v.length * 4, 50)
|
||||
} else {
|
||||
misses.push(id)
|
||||
}
|
||||
}
|
||||
if (misses.length === 0) return
|
||||
|
||||
// Storage fallback for the misses, with concurrent lazy write-back.
|
||||
await Promise.all(misses.map(async (id) => {
|
||||
if (!this.storage) return
|
||||
const vector = await this.storage.getNounVector(id)
|
||||
if (!vector) return
|
||||
if (this.vectorBackend) {
|
||||
try {
|
||||
this.vectorBackend.writeByUuid(id, vector)
|
||||
} catch (error) {
|
||||
prodLog.debug(`MmapVectorBackend write-back failed for ${id}`, error)
|
||||
}
|
||||
}
|
||||
this.unifiedCache.set(`hnsw:vector:${id}`, vector, 'hnsw', vector.length * 4, 50)
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
// Legacy path: per-entity storage load via UnifiedCache request coalescing.
|
||||
const promises = nodeIds.map(async (id) => {
|
||||
const cacheKey = `hnsw:vector:${id}`
|
||||
return this.unifiedCache.get(cacheKey, async () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue