Step 3 of the brainy 8.0 rename scaffolding. The class name now matches its role: the JS HNSW implementation of the VectorIndexProvider contract. Per planning § 2.3: this is NOT cosmetic — leaving HNSWIndex when the public contract is VectorIndexProvider creates a confusing read at the implementation layer. CHANGES Repo-wide mechanical rename: HNSWIndex → JsHnswVectorIndex across 52 references in src/ + 5 references in tests/ via sed. Class declaration, imports, JSDoc, comments, and identifiers all updated. src/index.ts - Primary public export: JsHnswVectorIndex (the new name). - Backwards-compat alias: `export const HNSWIndex = JsHnswVectorIndex` with @deprecated note. Removed in the final 8.0 cleanup commit. NO-OP scope No behavioural change. The class is exactly the same; only the name moved. Tests + build green. VERIFICATION - npx tsc --noEmit: clean - npm test: 1468 / 1468 unit
296 lines
9.7 KiB
TypeScript
296 lines
9.7 KiB
TypeScript
/**
|
|
* Lazy Vector Loading Tests (B2 optimization)
|
|
*
|
|
* Tests for:
|
|
* - Vectors evicted from memory after addItem() in lazy mode
|
|
* - Search returns correct results after vector eviction
|
|
* - Memory mode retains vectors (default behavior)
|
|
* - Lazy mode requires storage adapter
|
|
* - Combined lazy + quantization mode
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach } from 'vitest'
|
|
import { v4 as uuidv4 } from 'uuid'
|
|
import { JsHnswVectorIndex } from '../../../src/hnsw/hnswIndex.js'
|
|
import { euclideanDistance } from '../../../src/utils/index.js'
|
|
import { MemoryStorage } from '../../../src/storage/adapters/memoryStorage.js'
|
|
|
|
// Helper: generate a random vector of given dimension
|
|
function randomVector(dim: number): number[] {
|
|
return Array.from({ length: dim }, () => Math.random() * 2 - 1)
|
|
}
|
|
|
|
// Helper: save a vector to storage so lazy loading can retrieve it
|
|
// Both noun (vector) and metadata must be saved for getNounVector() to work
|
|
async function saveVector(storage: MemoryStorage, id: string, vector: number[]): Promise<void> {
|
|
await storage.saveNoun({
|
|
id,
|
|
vector,
|
|
connections: new Map(),
|
|
level: 0
|
|
})
|
|
await storage.saveNounMetadata(id, {
|
|
noun: 'thing',
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now()
|
|
})
|
|
}
|
|
|
|
describe('Lazy Vector Loading (B2)', () => {
|
|
const dim = 32
|
|
|
|
// =================================================================
|
|
// 1. LAZY MODE: VECTOR EVICTION
|
|
// =================================================================
|
|
describe('vector eviction in lazy mode', () => {
|
|
let index: JsHnswVectorIndex
|
|
let storage: MemoryStorage
|
|
|
|
beforeEach(async () => {
|
|
storage = new MemoryStorage()
|
|
index = new JsHnswVectorIndex(
|
|
{
|
|
M: 8,
|
|
efConstruction: 100,
|
|
efSearch: 50,
|
|
ml: 8,
|
|
vectorStorage: 'lazy'
|
|
},
|
|
euclideanDistance,
|
|
{ useParallelization: false, storage }
|
|
)
|
|
})
|
|
|
|
it('should still be searchable after vector eviction', async () => {
|
|
const targetId = uuidv4()
|
|
const target = randomVector(dim)
|
|
|
|
// Store noun in storage so lazy loading can find it
|
|
await saveVector(storage, targetId, target)
|
|
await index.addItem({ id: targetId, vector: target })
|
|
|
|
// Add more entities
|
|
for (let i = 0; i < 20; i++) {
|
|
const id = uuidv4()
|
|
const v = randomVector(dim)
|
|
await saveVector(storage, id, v)
|
|
await index.addItem({ id, vector: v })
|
|
}
|
|
|
|
// Search should still find the target
|
|
const results = await index.search(target, 5)
|
|
expect(results.length).toBeGreaterThan(0)
|
|
|
|
// The closest result should be the target (distance ~0)
|
|
const targetResult = results.find(([id]) => id === targetId)
|
|
expect(targetResult).toBeDefined()
|
|
expect(targetResult![1]).toBeCloseTo(0, 1)
|
|
})
|
|
|
|
it('should return correct top-k results in lazy mode', async () => {
|
|
for (let i = 0; i < 50; i++) {
|
|
const id = uuidv4()
|
|
const v = randomVector(dim)
|
|
await saveVector(storage, id, v)
|
|
await index.addItem({ id, vector: v })
|
|
}
|
|
|
|
const query = randomVector(dim)
|
|
const results = await index.search(query, 10)
|
|
|
|
// With lazy mode and small graph (50 items), HNSW may return slightly fewer
|
|
// than k if some vectors are evicted and unreachable during graph traversal
|
|
expect(results.length).toBeGreaterThanOrEqual(8)
|
|
expect(results.length).toBeLessThanOrEqual(10)
|
|
|
|
// Results should be sorted by distance
|
|
for (let i = 1; i < results.length; i++) {
|
|
expect(results[i][1]).toBeGreaterThanOrEqual(results[i - 1][1])
|
|
}
|
|
})
|
|
})
|
|
|
|
// =================================================================
|
|
// 2. MEMORY MODE: VECTORS RETAINED (DEFAULT)
|
|
// =================================================================
|
|
describe('memory mode retains vectors (default)', () => {
|
|
it('should keep vectors in memory by default', async () => {
|
|
const storage = new MemoryStorage()
|
|
const index = new JsHnswVectorIndex(
|
|
{ M: 4, efConstruction: 50, efSearch: 20 },
|
|
euclideanDistance,
|
|
{ useParallelization: false, storage }
|
|
)
|
|
|
|
const id = uuidv4()
|
|
const v = randomVector(dim)
|
|
await index.addItem({ id, vector: v })
|
|
|
|
// Search should work without needing storage
|
|
const results = await index.search(v, 1)
|
|
expect(results.length).toBe(1)
|
|
expect(results[0][0]).toBe(id)
|
|
expect(results[0][1]).toBeCloseTo(0, 5)
|
|
})
|
|
|
|
it('should work without storage adapter in memory mode', async () => {
|
|
// No storage adapter provided
|
|
const index = new JsHnswVectorIndex(
|
|
{ M: 4, efConstruction: 50, efSearch: 20 },
|
|
euclideanDistance,
|
|
{ useParallelization: false }
|
|
)
|
|
|
|
const id = uuidv4()
|
|
const v = randomVector(dim)
|
|
await index.addItem({ id, vector: v })
|
|
|
|
const results = await index.search(v, 1)
|
|
expect(results.length).toBe(1)
|
|
expect(results[0][0]).toBe(id)
|
|
})
|
|
})
|
|
|
|
// =================================================================
|
|
// 3. LAZY + NO STORAGE: GRACEFUL BEHAVIOR
|
|
// =================================================================
|
|
describe('lazy mode without storage', () => {
|
|
it('should not evict vectors when no storage adapter is configured', async () => {
|
|
// vectorStorage: 'lazy' but no storage — vectors should stay in memory
|
|
const index = new JsHnswVectorIndex(
|
|
{
|
|
M: 4,
|
|
efConstruction: 50,
|
|
efSearch: 20,
|
|
vectorStorage: 'lazy'
|
|
},
|
|
euclideanDistance,
|
|
{ useParallelization: false }
|
|
)
|
|
|
|
const id = uuidv4()
|
|
const v = randomVector(dim)
|
|
await index.addItem({ id, vector: v })
|
|
|
|
// Should still work because vectors aren't evicted without storage
|
|
const results = await index.search(v, 1)
|
|
expect(results.length).toBe(1)
|
|
expect(results[0][0]).toBe(id)
|
|
})
|
|
})
|
|
|
|
// =================================================================
|
|
// 4. LAZY + QUANTIZATION COMBINED
|
|
// =================================================================
|
|
describe('lazy mode with quantization', () => {
|
|
it('should use SQ8 for traversal and load full vectors for rerank', async () => {
|
|
const storage = new MemoryStorage()
|
|
const index = new JsHnswVectorIndex(
|
|
{
|
|
M: 8,
|
|
efConstruction: 100,
|
|
efSearch: 50,
|
|
ml: 8,
|
|
vectorStorage: 'lazy',
|
|
quantization: { enabled: true, rerankMultiplier: 3 }
|
|
},
|
|
euclideanDistance,
|
|
{ useParallelization: false, storage }
|
|
)
|
|
|
|
const targetId = uuidv4()
|
|
const target = randomVector(dim)
|
|
await saveVector(storage, targetId, target)
|
|
await index.addItem({ id: targetId, vector: target })
|
|
|
|
for (let i = 0; i < 30; i++) {
|
|
const id = uuidv4()
|
|
const v = randomVector(dim)
|
|
await saveVector(storage, id, v)
|
|
await index.addItem({ id, vector: v })
|
|
}
|
|
|
|
// Search with reranking — should load full vectors for rerank phase
|
|
const results = await index.search(target, 5)
|
|
expect(results.length).toBe(5)
|
|
|
|
// The target should be the closest match
|
|
const targetResult = results.find(([id]) => id === targetId)
|
|
expect(targetResult).toBeDefined()
|
|
})
|
|
|
|
it('should return results sorted by exact distance after rerank', async () => {
|
|
const storage = new MemoryStorage()
|
|
const index = new JsHnswVectorIndex(
|
|
{
|
|
M: 8,
|
|
efConstruction: 100,
|
|
efSearch: 50,
|
|
ml: 8,
|
|
vectorStorage: 'lazy',
|
|
quantization: { enabled: true, rerankMultiplier: 3 }
|
|
},
|
|
euclideanDistance,
|
|
{ useParallelization: false, storage }
|
|
)
|
|
|
|
for (let i = 0; i < 40; i++) {
|
|
const id = uuidv4()
|
|
const v = randomVector(dim)
|
|
await saveVector(storage, id, v)
|
|
await index.addItem({ id, vector: v })
|
|
}
|
|
|
|
const query = randomVector(dim)
|
|
const results = await index.search(query, 10)
|
|
|
|
// Results should be sorted by exact distance (rerank ensures this)
|
|
for (let i = 1; i < results.length; i++) {
|
|
expect(results[i][1]).toBeGreaterThanOrEqual(results[i - 1][1])
|
|
}
|
|
})
|
|
})
|
|
|
|
// =================================================================
|
|
// 5. MULTIPLE SEARCHES IN LAZY MODE
|
|
// =================================================================
|
|
describe('multiple searches in lazy mode', () => {
|
|
it('should handle repeated searches correctly', async () => {
|
|
const storage = new MemoryStorage()
|
|
const index = new JsHnswVectorIndex(
|
|
{
|
|
M: 8,
|
|
efConstruction: 100,
|
|
efSearch: 50,
|
|
ml: 8,
|
|
vectorStorage: 'lazy'
|
|
},
|
|
euclideanDistance,
|
|
{ useParallelization: false, storage }
|
|
)
|
|
|
|
const entries: Array<{ id: string; vector: number[] }> = []
|
|
for (let i = 0; i < 25; i++) {
|
|
const id = uuidv4()
|
|
const v = randomVector(dim)
|
|
entries.push({ id, vector: v })
|
|
await saveVector(storage, id, v)
|
|
await index.addItem({ id, vector: v })
|
|
}
|
|
|
|
// Run multiple searches — each should return results and be consistent
|
|
for (let q = 0; q < 5; q++) {
|
|
const entry = entries[q * 5]
|
|
const results = await index.search(entry.vector, 5)
|
|
|
|
expect(results.length).toBe(5)
|
|
// Results should be sorted by distance
|
|
for (let i = 1; i < results.length; i++) {
|
|
expect(results[i][1]).toBeGreaterThanOrEqual(results[i - 1][1])
|
|
}
|
|
// At least the closest result should have a reasonably small distance
|
|
expect(results[0][1]).toBeLessThan(5)
|
|
}
|
|
})
|
|
})
|
|
})
|