brainy/src/utils/metadataIndexChunking.ts
David Snelling 0ddc05a5bb feat: enforce data/metadata separation, numeric range queries, improved docs
- Store data opaquely in add() and update() instead of spreading object
  properties into top-level metadata. data is for semantic search (HNSW),
  metadata is for structured where-filter queries (MetadataIndex).
- Fix numeric range queries in MetadataIndex — use numeric-aware comparison
  instead of lexicographic string comparison for normalized values.
- Add data field to RelateParams and Relation types for relationship content.
- Add where.type → where.noun alias in metadata-only find() path.
- Rewrite README: focused ~350 lines from 791, quick start first, feature
  showcase with mini-snippets, organized doc links, no version callouts.
- Add DATA_MODEL.md and QUERY_OPERATORS.md reference docs.
- Remove 10 outdated/redundant doc files consolidated into API reference.
- Improve JSDoc on Entity, Relation, AddParams, FindParams, and core methods.
- Fix tests asserting data properties appear in metadata (data model violation).
- Deprecate verb.source/target in favor of from/to (public) and sourceId/targetId (storage).
2026-02-09 12:07:54 -08:00

963 lines
28 KiB
TypeScript

/**
* Metadata Index Chunking System with Roaring Bitmaps
*
* Implements Adaptive Chunked Sparse Indexing with Roaring Bitmaps for 500-900x faster multi-field queries.
* Reduces file count from 560k to ~89 files (630x reduction) with 90% memory reduction.
*
* Key Components:
* - BloomFilter: Probabilistic membership testing (fast negative lookups)
* - SparseIndex: Directory of chunks with zone maps (range query optimization)
* - ChunkManager: Chunk lifecycle management (create/split/merge)
* - RoaringBitmap32: Compressed bitmap data structure for blazing-fast set operations
* - AdaptiveChunkingStrategy: Field-specific optimization strategies
*
* Architecture:
* - Each high-cardinality field gets a sparse index (directory)
* - Values are grouped into chunks (~50 values per chunk)
* - Each chunk has a bloom filter for fast negative lookups
* - Zone maps enable range query optimization
* - Entity IDs stored as roaring bitmaps (integers) instead of Sets (strings)
* - EntityIdMapper handles UUID ↔ integer conversion
*/
import { StorageAdapter } from '../coreTypes.js'
import { prodLog } from './logger.js'
import { RoaringBitmap32 } from './roaring/index.js'
import type { EntityIdMapper } from './entityIdMapper.js'
// ============================================================================
// Numeric-Aware Comparison
// ============================================================================
/**
* Compare two normalized string values with numeric awareness.
* Since normalizeValue() converts numbers to strings (e.g., 50 → "50"),
* plain string comparison breaks numeric ordering ("50" > "100" is true
* lexicographically but wrong numerically). This function detects when
* both values are numeric strings and compares them as numbers.
*
* @returns negative if a < b, 0 if equal, positive if a > b
*/
export function compareNormalizedValues(a: string, b: string): number {
const numA = Number(a)
const numB = Number(b)
if (!isNaN(numA) && !isNaN(numB) && a !== '' && b !== '') {
return numA - numB
}
// Fall back to string comparison for non-numeric values
if (a < b) return -1
if (a > b) return 1
return 0
}
// ============================================================================
// Core Data Structures
// ============================================================================
/**
* Zone Map for range query optimization
* Tracks min/max values in a chunk for fast range filtering
*/
export interface ZoneMap {
min: any
max: any
count: number
hasNulls: boolean
}
/**
* Chunk Descriptor
* Metadata about a chunk including its location, zone map, and bloom filter
*/
export interface ChunkDescriptor {
chunkId: number
field: string
valueCount: number
idCount: number
zoneMap: ZoneMap
bloomFilterPath?: string
lastUpdated: number
splitThreshold: number
mergeThreshold: number
}
/**
* Sparse Index Data
* Directory structure mapping value ranges to chunks
*/
export interface SparseIndexData {
field: string
strategy: 'hash' | 'sorted' | 'adaptive'
chunks: ChunkDescriptor[]
totalValues: number
totalIds: number
lastUpdated: number
chunkSize: number // Target values per chunk
version: number // For schema evolution
}
/**
* Chunk Data with Roaring Bitmaps
* Actual storage of field:value -> IDs mappings using compressed bitmaps
*
* Uses RoaringBitmap32 for 500-900x faster intersections and 90% memory reduction
*/
export interface ChunkData {
chunkId: number
field: string
entries: Map<string, RoaringBitmap32> // value -> RoaringBitmap32<entityIntId>
lastUpdated: number
}
// ============================================================================
// BloomFilter - Production-Ready Implementation
// ============================================================================
/**
* Bloom Filter for probabilistic membership testing
*
* Uses multiple hash functions to achieve ~1% false positive rate.
* Memory efficient: ~10 bits per element for 1% FPR.
*
* Properties:
* - Never produces false negatives (if returns false, definitely not in set)
* - May produce false positives (~1% with default config)
* - Space efficient compared to hash sets
* - Fast O(k) lookup where k = number of hash functions
*/
export class BloomFilter {
private bits: Uint8Array
private numBits: number
private numHashFunctions: number
private itemCount: number = 0
/**
* Create a Bloom filter
* @param expectedItems Expected number of items to store
* @param falsePositiveRate Target false positive rate (default: 0.01 = 1%)
*/
constructor(expectedItems: number, falsePositiveRate: number = 0.01) {
// Calculate optimal bit array size: m = -n*ln(p) / (ln(2)^2)
// where n = expected items, p = false positive rate
this.numBits = Math.ceil(
(-expectedItems * Math.log(falsePositiveRate)) / (Math.LN2 * Math.LN2)
)
// Calculate optimal number of hash functions: k = (m/n) * ln(2)
this.numHashFunctions = Math.ceil((this.numBits / expectedItems) * Math.LN2)
// Clamp to reasonable bounds
this.numHashFunctions = Math.max(1, Math.min(10, this.numHashFunctions))
// Allocate bit array (8 bits per byte)
const numBytes = Math.ceil(this.numBits / 8)
this.bits = new Uint8Array(numBytes)
}
/**
* Add an item to the bloom filter
*/
add(item: string): void {
const hashes = this.getHashPositions(item)
for (const pos of hashes) {
this.setBit(pos)
}
this.itemCount++
}
/**
* Test if an item might be in the set
* @returns false = definitely not in set, true = might be in set
*/
mightContain(item: string): boolean {
const hashes = this.getHashPositions(item)
for (const pos of hashes) {
if (!this.getBit(pos)) {
return false // Definitely not in set
}
}
return true // Might be in set (or false positive)
}
/**
* Get multiple hash positions for an item
* Uses double hashing technique: h(i) = (h1 + i*h2) mod m
*/
private getHashPositions(item: string): number[] {
const hash1 = this.hash1(item)
const hash2 = this.hash2(item)
const positions: number[] = []
for (let i = 0; i < this.numHashFunctions; i++) {
const hash = (hash1 + i * hash2) % this.numBits
// Ensure positive
positions.push(hash < 0 ? hash + this.numBits : hash)
}
return positions
}
/**
* First hash function (FNV-1a variant)
*/
private hash1(str: string): number {
let hash = 2166136261
for (let i = 0; i < str.length; i++) {
hash ^= str.charCodeAt(i)
hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24)
}
return Math.abs(hash | 0)
}
/**
* Second hash function (DJB2)
*/
private hash2(str: string): number {
let hash = 5381
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) + hash + str.charCodeAt(i)
}
return Math.abs(hash | 0)
}
/**
* Set a bit in the bit array
*/
private setBit(position: number): void {
const byteIndex = Math.floor(position / 8)
const bitIndex = position % 8
this.bits[byteIndex] |= 1 << bitIndex
}
/**
* Get a bit from the bit array
*/
private getBit(position: number): boolean {
const byteIndex = Math.floor(position / 8)
const bitIndex = position % 8
return (this.bits[byteIndex] & (1 << bitIndex)) !== 0
}
/**
* Serialize to JSON for storage
*/
toJSON(): any {
return {
bits: Array.from(this.bits),
numBits: this.numBits,
numHashFunctions: this.numHashFunctions,
itemCount: this.itemCount
}
}
/**
* Deserialize from JSON
*/
static fromJSON(data: any): BloomFilter {
const filter = Object.create(BloomFilter.prototype)
filter.bits = new Uint8Array(data.bits)
filter.numBits = data.numBits
filter.numHashFunctions = data.numHashFunctions
filter.itemCount = data.itemCount
return filter
}
/**
* Get estimated false positive rate based on current fill
*/
getEstimatedFPR(): number {
const bitsSet = this.countSetBits()
const fillRatio = bitsSet / this.numBits
return Math.pow(fillRatio, this.numHashFunctions)
}
/**
* Count number of set bits
*/
private countSetBits(): number {
let count = 0
for (let i = 0; i < this.bits.length; i++) {
count += this.popcount(this.bits[i])
}
return count
}
/**
* Count set bits in a byte (population count)
*/
private popcount(byte: number): number {
byte = byte - ((byte >> 1) & 0x55)
byte = (byte & 0x33) + ((byte >> 2) & 0x33)
return ((byte + (byte >> 4)) & 0x0f)
}
}
// ============================================================================
// SparseIndex - Chunk Directory with Zone Maps
// ============================================================================
/**
* Sparse Index manages the directory of chunks for a field
*
* Inspired by ClickHouse MergeTree sparse primary index:
* - Maintains sorted list of chunk descriptors
* - Uses zone maps for range query optimization
* - Enables fast chunk selection without loading all data
*
* Query Flow:
* 1. Check zone maps to find candidate chunks
* 2. Load bloom filters for candidate chunks (fast negative lookup)
* 3. Load only the chunks that likely contain the value
*/
export class SparseIndex {
private data: SparseIndexData
private bloomFilters: Map<number, BloomFilter> = new Map()
constructor(field: string, chunkSize: number = 50) {
this.data = {
field,
strategy: 'adaptive',
chunks: [],
totalValues: 0,
totalIds: 0,
lastUpdated: Date.now(),
chunkSize,
version: 1
}
}
/**
* Find chunks that might contain a specific value
*/
findChunksForValue(value: any): number[] {
const candidates: number[] = []
for (const chunk of this.data.chunks) {
// Check zone map first (fast)
if (this.isValueInZoneMap(value, chunk.zoneMap)) {
// Check bloom filter if available (fast negative lookup)
const bloomFilter = this.bloomFilters.get(chunk.chunkId)
if (bloomFilter) {
if (bloomFilter.mightContain(String(value))) {
candidates.push(chunk.chunkId)
}
// If bloom filter says no, definitely skip this chunk
} else {
// No bloom filter, must check chunk
candidates.push(chunk.chunkId)
}
}
}
return candidates
}
/**
* Find chunks that overlap with a value range
*/
findChunksForRange(min?: any, max?: any): number[] {
const candidates: number[] = []
for (const chunk of this.data.chunks) {
if (this.doesRangeOverlap(min, max, chunk.zoneMap)) {
candidates.push(chunk.chunkId)
}
}
return candidates
}
/**
* Check if a value falls within a zone map's range
*/
private isValueInZoneMap(value: any, zoneMap: ZoneMap): boolean {
if (value === null || value === undefined) {
return zoneMap.hasNulls
}
const strValue = String(value)
const strMin = String(zoneMap.min)
const strMax = String(zoneMap.max)
return compareNormalizedValues(strValue, strMin) >= 0 && compareNormalizedValues(strValue, strMax) <= 0
}
/**
* Check if a range overlaps with a zone map
*/
private doesRangeOverlap(min: any, max: any, zoneMap: ZoneMap): boolean {
// Handle nulls
if ((min === null || min === undefined || max === null || max === undefined) && zoneMap.hasNulls) {
return true
}
// No range specified = match all
if (min === undefined && max === undefined) {
return true
}
// Check overlap using numeric-aware comparison
const strZoneMin = String(zoneMap.min)
const strZoneMax = String(zoneMap.max)
if (min !== undefined && max !== undefined) {
// Range: [min, max] overlaps with [zoneMin, zoneMax]
return !(compareNormalizedValues(String(max), strZoneMin) < 0 || compareNormalizedValues(String(min), strZoneMax) > 0)
} else if (min !== undefined) {
// >= min
return compareNormalizedValues(strZoneMax, String(min)) >= 0
} else if (max !== undefined) {
// <= max
return compareNormalizedValues(strZoneMin, String(max)) <= 0
}
return true
}
/**
* Register a chunk in the sparse index
*/
registerChunk(descriptor: ChunkDescriptor, bloomFilter?: BloomFilter): void {
this.data.chunks.push(descriptor)
if (bloomFilter) {
this.bloomFilters.set(descriptor.chunkId, bloomFilter)
}
// Update totals
this.data.totalValues += descriptor.valueCount
this.data.totalIds += descriptor.idCount
this.data.lastUpdated = Date.now()
// Keep chunks sorted by zone map min value for efficient range queries
this.sortChunks()
}
/**
* Update a chunk descriptor
*/
updateChunk(chunkId: number, updates: Partial<ChunkDescriptor>): void {
const index = this.data.chunks.findIndex(c => c.chunkId === chunkId)
if (index >= 0) {
this.data.chunks[index] = { ...this.data.chunks[index], ...updates }
this.data.lastUpdated = Date.now()
this.sortChunks()
}
}
/**
* Remove a chunk from the sparse index
*/
removeChunk(chunkId: number): void {
const index = this.data.chunks.findIndex(c => c.chunkId === chunkId)
if (index >= 0) {
const removed = this.data.chunks.splice(index, 1)[0]
this.data.totalValues -= removed.valueCount
this.data.totalIds -= removed.idCount
this.bloomFilters.delete(chunkId)
this.data.lastUpdated = Date.now()
}
}
/**
* Get chunk descriptor by ID
*/
getChunk(chunkId: number): ChunkDescriptor | undefined {
return this.data.chunks.find(c => c.chunkId === chunkId)
}
/**
* Get all chunk IDs
*/
getAllChunkIds(): number[] {
return this.data.chunks.map(c => c.chunkId)
}
/**
* Sort chunks by zone map min value
*/
private sortChunks(): void {
this.data.chunks.sort((a, b) => {
return compareNormalizedValues(String(a.zoneMap.min), String(b.zoneMap.min))
})
}
/**
* Get sparse index statistics
*/
getStats(): {
field: string
chunkCount: number
avgValuesPerChunk: number
avgIdsPerChunk: number
totalValues: number
totalIds: number
estimatedFPR: number
} {
const avgFPR = Array.from(this.bloomFilters.values())
.reduce((sum, bf) => sum + bf.getEstimatedFPR(), 0) / Math.max(1, this.bloomFilters.size)
return {
field: this.data.field,
chunkCount: this.data.chunks.length,
avgValuesPerChunk: this.data.totalValues / Math.max(1, this.data.chunks.length),
avgIdsPerChunk: this.data.totalIds / Math.max(1, this.data.chunks.length),
totalValues: this.data.totalValues,
totalIds: this.data.totalIds,
estimatedFPR: avgFPR
}
}
/**
* Serialize to JSON for storage
*/
toJSON(): any {
return {
...this.data,
bloomFilters: Array.from(this.bloomFilters.entries()).map(([id, bf]) => ({
chunkId: id,
filter: bf.toJSON()
}))
}
}
/**
* Deserialize from JSON
*/
static fromJSON(data: any): SparseIndex {
const index = Object.create(SparseIndex.prototype)
index.data = {
field: data.field,
strategy: data.strategy,
chunks: data.chunks,
totalValues: data.totalValues,
totalIds: data.totalIds,
lastUpdated: data.lastUpdated,
chunkSize: data.chunkSize,
version: data.version
}
index.bloomFilters = new Map()
// Restore bloom filters
if (data.bloomFilters) {
for (const { chunkId, filter } of data.bloomFilters) {
index.bloomFilters.set(chunkId, BloomFilter.fromJSON(filter))
}
}
return index
}
}
// ============================================================================
// ChunkManager - Chunk Lifecycle Management
// ============================================================================
/**
* ChunkManager handles chunk operations with Roaring Bitmap support
*
* Responsibilities:
* - Maintain optimal chunk sizes (~50 values per chunk)
* - Split chunks that grow too large (> 80 values)
* - Merge chunks that become too small (< 20 values)
* - Update zone maps and bloom filters
* - Coordinate with storage adapter
* - Manage roaring bitmap serialization/deserialization
* - Use EntityIdMapper for UUID ↔ integer conversion
*/
export class ChunkManager {
private storage: StorageAdapter
private chunkCache: Map<string, ChunkData> = new Map()
private nextChunkId: Map<string, number> = new Map() // field -> next chunk ID
private idMapper: EntityIdMapper
constructor(storage: StorageAdapter, idMapper: EntityIdMapper) {
this.storage = storage
this.idMapper = idMapper
}
/**
* Create a new chunk for a field with roaring bitmaps
*/
async createChunk(field: string, initialEntries?: Map<string, RoaringBitmap32>): Promise<ChunkData> {
const chunkId = this.getNextChunkId(field)
const chunk: ChunkData = {
chunkId,
field,
entries: initialEntries || new Map(),
lastUpdated: Date.now()
}
await this.saveChunk(chunk)
return chunk
}
/**
* Load a chunk from storage with roaring bitmap deserialization
*/
async loadChunk(field: string, chunkId: number): Promise<ChunkData | null> {
const cacheKey = `${field}:${chunkId}`
// Check cache first
if (this.chunkCache.has(cacheKey)) {
return this.chunkCache.get(cacheKey)!
}
// Load from storage
try {
const chunkPath = this.getChunkPath(field, chunkId)
const data = await this.storage.getMetadata(chunkPath)
if (data) {
// Cast NounMetadata to chunk data structure
const chunkData = data as unknown as any
// Deserialize: convert serialized roaring bitmaps back to RoaringBitmap32 objects
const chunk: ChunkData = {
chunkId: chunkData.chunkId as number,
field: chunkData.field as string,
entries: new Map(
Object.entries(chunkData.entries).map(([value, serializedBitmap]) => {
// Deserialize roaring bitmap from portable format
const bitmap = new RoaringBitmap32()
if (serializedBitmap && typeof serializedBitmap === 'object' && (serializedBitmap as any).buffer) {
// Deserialize from Buffer
bitmap.deserialize(Buffer.from((serializedBitmap as any).buffer), 'portable')
}
return [value, bitmap]
})
),
lastUpdated: chunkData.lastUpdated as number
}
this.chunkCache.set(cacheKey, chunk)
return chunk
}
} catch (error) {
prodLog.debug(`Failed to load chunk ${field}:${chunkId}:`, error)
}
return null
}
/**
* Save a chunk to storage with roaring bitmap serialization
*/
async saveChunk(chunk: ChunkData): Promise<void> {
const cacheKey = `${chunk.field}:${chunk.chunkId}`
// Update cache
this.chunkCache.set(cacheKey, chunk)
// Serialize: convert RoaringBitmap32 to portable format (Buffer)
// Add required 'noun' property for NounMetadata
const serializable = {
noun: 'IndexChunk', // Required by NounMetadata interface
chunkId: chunk.chunkId,
field: chunk.field,
entries: Object.fromEntries(
Array.from(chunk.entries.entries()).map(([value, bitmap]) => [
value,
{
buffer: Array.from(bitmap.serialize('portable')), // Serialize to portable format (Java/Go compatible)
size: bitmap.size
}
])
),
lastUpdated: chunk.lastUpdated
}
const chunkPath = this.getChunkPath(chunk.field, chunk.chunkId)
await this.storage.saveMetadata(chunkPath, serializable as any)
}
/**
* Add a value-ID mapping to a chunk using roaring bitmaps
*/
async addToChunk(chunk: ChunkData, value: string, id: string): Promise<void> {
// Convert UUID to integer using EntityIdMapper
const intId = this.idMapper.getOrAssign(id)
// Get or create roaring bitmap for this value
if (!chunk.entries.has(value)) {
chunk.entries.set(value, new RoaringBitmap32())
}
// Add integer ID to roaring bitmap
chunk.entries.get(value)!.add(intId)
chunk.lastUpdated = Date.now()
}
/**
* Remove an ID from a chunk using roaring bitmaps
*/
async removeFromChunk(chunk: ChunkData, value: string, id: string): Promise<void> {
const bitmap = chunk.entries.get(value)
if (bitmap) {
// Convert UUID to integer
const intId = this.idMapper.getInt(id)
if (intId !== undefined) {
bitmap.tryAdd(intId) // Remove is done via tryAdd (returns false if already exists)
bitmap.delete(intId) // Actually remove it
}
// Remove bitmap if empty
if (bitmap.isEmpty) {
chunk.entries.delete(value)
}
chunk.lastUpdated = Date.now()
}
}
/**
* Calculate zone map for a chunk with roaring bitmaps
*/
calculateZoneMap(chunk: ChunkData): ZoneMap {
const values = Array.from(chunk.entries.keys())
if (values.length === 0) {
return {
min: null,
max: null,
count: 0,
hasNulls: false
}
}
let min = values[0]
let max = values[0]
let hasNulls = false
let idCount = 0
for (const value of values) {
if (value === '__NULL__' || value === null || value === undefined) {
hasNulls = true
} else {
if (compareNormalizedValues(value, min) < 0) min = value
if (compareNormalizedValues(value, max) > 0) max = value
}
// Get count from roaring bitmap
const bitmap = chunk.entries.get(value)
if (bitmap) {
idCount += bitmap.size // RoaringBitmap32.size is O(1)
}
}
return {
min,
max,
count: idCount,
hasNulls
}
}
/**
* Create bloom filter for a chunk
*/
createBloomFilter(chunk: ChunkData): BloomFilter {
const valueCount = chunk.entries.size
const bloomFilter = new BloomFilter(Math.max(10, valueCount * 2), 0.01) // 1% FPR
for (const value of chunk.entries.keys()) {
bloomFilter.add(String(value))
}
return bloomFilter
}
/**
* Split a chunk if it's too large (with roaring bitmaps)
*/
async splitChunk(
chunk: ChunkData,
sparseIndex: SparseIndex
): Promise<{ chunk1: ChunkData; chunk2: ChunkData }> {
const values = Array.from(chunk.entries.keys()).sort()
const midpoint = Math.floor(values.length / 2)
// Create two new chunks with roaring bitmaps
const entries1 = new Map<string, RoaringBitmap32>()
const entries2 = new Map<string, RoaringBitmap32>()
for (let i = 0; i < values.length; i++) {
const value = values[i]
const bitmap = chunk.entries.get(value)!
if (i < midpoint) {
// Clone bitmap for first chunk
const newBitmap = new RoaringBitmap32(bitmap.toArray())
entries1.set(value, newBitmap)
} else {
// Clone bitmap for second chunk
const newBitmap = new RoaringBitmap32(bitmap.toArray())
entries2.set(value, newBitmap)
}
}
const chunk1 = await this.createChunk(chunk.field, entries1)
const chunk2 = await this.createChunk(chunk.field, entries2)
// Update sparse index
sparseIndex.removeChunk(chunk.chunkId)
const descriptor1: ChunkDescriptor = {
chunkId: chunk1.chunkId,
field: chunk1.field,
valueCount: entries1.size,
idCount: Array.from(entries1.values()).reduce((sum, bitmap) => sum + bitmap.size, 0),
zoneMap: this.calculateZoneMap(chunk1),
lastUpdated: Date.now(),
splitThreshold: 80,
mergeThreshold: 20
}
const descriptor2: ChunkDescriptor = {
chunkId: chunk2.chunkId,
field: chunk2.field,
valueCount: entries2.size,
idCount: Array.from(entries2.values()).reduce((sum, bitmap) => sum + bitmap.size, 0),
zoneMap: this.calculateZoneMap(chunk2),
lastUpdated: Date.now(),
splitThreshold: 80,
mergeThreshold: 20
}
sparseIndex.registerChunk(descriptor1, this.createBloomFilter(chunk1))
sparseIndex.registerChunk(descriptor2, this.createBloomFilter(chunk2))
// Delete old chunk
await this.deleteChunk(chunk.field, chunk.chunkId)
prodLog.debug(`Split chunk ${chunk.field}:${chunk.chunkId} into ${chunk1.chunkId} and ${chunk2.chunkId}`)
return { chunk1, chunk2 }
}
/**
* Delete a chunk
*/
async deleteChunk(field: string, chunkId: number): Promise<void> {
const cacheKey = `${field}:${chunkId}`
this.chunkCache.delete(cacheKey)
const chunkPath = this.getChunkPath(field, chunkId)
// null signals deletion to storage adapter
await this.storage.saveMetadata(chunkPath, null as any)
}
/**
* Get chunk storage path
*/
private getChunkPath(field: string, chunkId: number): string {
return `__chunk__${field}_${chunkId}`
}
/**
* Initialize nextChunkId counter from existing sparse index
* CRITICAL: Must be called when loading sparse index to prevent ID conflicts
* @param field Field name
* @param sparseIndex Loaded sparse index containing existing chunk descriptors
*/
initializeNextChunkId(field: string, sparseIndex: SparseIndex): void {
const existingChunkIds = sparseIndex.getAllChunkIds()
if (existingChunkIds.length > 0) {
// Find maximum chunk ID and set next to max + 1
const maxChunkId = Math.max(...existingChunkIds)
this.nextChunkId.set(field, maxChunkId + 1)
}
}
/**
* Get next available chunk ID for a field
*/
private getNextChunkId(field: string): number {
const current = this.nextChunkId.get(field) || 0
this.nextChunkId.set(field, current + 1)
return current
}
/**
* Clear chunk cache (for testing/maintenance)
*/
clearCache(): void {
this.chunkCache.clear()
}
}
// ============================================================================
// AdaptiveChunkingStrategy - Field-Specific Optimization
// ============================================================================
/**
* Determines optimal chunking strategy based on field characteristics
*/
export class AdaptiveChunkingStrategy {
/**
* Determine if a field should use chunking
*/
shouldUseChunking(fieldStats: {
uniqueValues: number
totalValues: number
distribution: 'uniform' | 'skewed' | 'sparse'
}): boolean {
// Use chunking for high-cardinality fields (> 1000 unique values)
if (fieldStats.uniqueValues > 1000) {
return true
}
// Use chunking for sparse distributions even with moderate cardinality
if (fieldStats.distribution === 'sparse' && fieldStats.uniqueValues > 500) {
return true
}
// Don't use chunking for low cardinality or highly skewed data
return false
}
/**
* Determine optimal chunk size for a field
*/
getOptimalChunkSize(fieldStats: {
uniqueValues: number
distribution: 'uniform' | 'skewed' | 'sparse'
avgIdsPerValue: number
}): number {
// Base chunk size
let chunkSize = 50
// Adjust for distribution
if (fieldStats.distribution === 'sparse') {
// Sparse: fewer values per chunk (more chunks, better pruning)
chunkSize = 30
} else if (fieldStats.distribution === 'skewed') {
// Skewed: more values per chunk (fewer chunks)
chunkSize = 100
}
// Adjust for ID density
if (fieldStats.avgIdsPerValue > 100) {
// High ID density: smaller chunks to avoid memory issues
chunkSize = Math.max(20, Math.floor(chunkSize * 0.6))
}
return chunkSize
}
/**
* Determine if a chunk should be split
*/
shouldSplit(chunk: { valueCount: number; idCount: number }, threshold: number): boolean {
return chunk.valueCount > threshold
}
/**
* Determine if chunks should be merged
*/
shouldMerge(chunks: Array<{ valueCount: number }>, threshold: number): boolean {
if (chunks.length < 2) return false
const totalValues = chunks.reduce((sum, c) => sum + c.valueCount, 0)
return totalValues < threshold && chunks.every(c => c.valueCount < threshold / 2)
}
}