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
500 lines
17 KiB
TypeScript
500 lines
17 KiB
TypeScript
/**
|
|
* SQ8 Quantization Tests
|
|
*
|
|
* Tests for:
|
|
* - SQ8 quantize/dequantize round-trip accuracy
|
|
* - SQ8 distance vs float32 distance correlation
|
|
* - Serialization/deserialization round-trip
|
|
* - Search with quantization enabled: recall vs exact
|
|
* - Two-phase rerank: improved recall over single-phase SQ8
|
|
* - Config defaults: quantization disabled by default (no behavior change)
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
import { v4 as uuidv4 } from 'uuid'
|
|
import {
|
|
quantizeSQ8,
|
|
dequantizeSQ8,
|
|
distanceSQ8,
|
|
distanceSQ8Js,
|
|
setSQ8DistanceImplementation,
|
|
serializeSQ8,
|
|
deserializeSQ8
|
|
} from '../../../src/utils/vectorQuantization.js'
|
|
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)
|
|
}
|
|
|
|
describe('SQ8 distance provider hook (setSQ8DistanceImplementation)', () => {
|
|
const q1 = quantizeSQ8([0.1, 0.5, -0.3, 0.8])
|
|
const q2 = quantizeSQ8([0.2, 0.4, -0.1, 0.7])
|
|
|
|
// Always restore the JS default so a swapped impl never leaks into other tests.
|
|
afterEach(() => setSQ8DistanceImplementation(distanceSQ8Js))
|
|
|
|
it('dispatches to the JS implementation by default (identical result)', () => {
|
|
const viaDispatcher = distanceSQ8(q1.quantized, q1.min, q1.max, q2.quantized, q2.min, q2.max)
|
|
const viaJs = distanceSQ8Js(q1.quantized, q1.min, q1.max, q2.quantized, q2.min, q2.max)
|
|
expect(viaDispatcher).toBe(viaJs)
|
|
expect(viaDispatcher).toBeGreaterThanOrEqual(0)
|
|
expect(viaDispatcher).toBeLessThanOrEqual(2)
|
|
})
|
|
|
|
it('routes through a swapped implementation with the cortex 6-arg signature', () => {
|
|
const calls: Array<[Uint8Array, number, number, Uint8Array, number, number]> = []
|
|
setSQ8DistanceImplementation((a, aMin, aMax, b, bMin, bMax) => {
|
|
calls.push([a, aMin, aMax, b, bMin, bMax])
|
|
return 0.4242
|
|
})
|
|
const result = distanceSQ8(q1.quantized, q1.min, q1.max, q2.quantized, q2.min, q2.max)
|
|
expect(result).toBe(0.4242)
|
|
expect(calls).toHaveLength(1)
|
|
// Exactly the arguments cortex's native cosineDistanceSq8(a, aMin, aMax, b, bMin, bMax) expects.
|
|
expect(calls[0][0]).toBe(q1.quantized)
|
|
expect(calls[0][1]).toBe(q1.min)
|
|
expect(calls[0][3]).toBe(q2.quantized)
|
|
expect(calls[0][5]).toBe(q2.max)
|
|
})
|
|
|
|
it('restores the JS default when set back', () => {
|
|
setSQ8DistanceImplementation(() => -999)
|
|
expect(distanceSQ8(q1.quantized, q1.min, q1.max, q2.quantized, q2.min, q2.max)).toBe(-999)
|
|
setSQ8DistanceImplementation(distanceSQ8Js)
|
|
expect(distanceSQ8(q1.quantized, q1.min, q1.max, q2.quantized, q2.min, q2.max)).toBe(
|
|
distanceSQ8Js(q1.quantized, q1.min, q1.max, q2.quantized, q2.min, q2.max)
|
|
)
|
|
})
|
|
})
|
|
|
|
// Helper: cosine distance for float32 vectors (reference implementation)
|
|
function cosineDistance(a: number[], b: number[]): number {
|
|
let dot = 0
|
|
let normA = 0
|
|
let normB = 0
|
|
for (let i = 0; i < a.length; i++) {
|
|
dot += a[i] * b[i]
|
|
normA += a[i] * a[i]
|
|
normB += b[i] * b[i]
|
|
}
|
|
const denom = Math.sqrt(normA) * Math.sqrt(normB)
|
|
if (denom === 0) return 1.0
|
|
return 1 - dot / denom
|
|
}
|
|
|
|
describe('SQ8 Quantization', () => {
|
|
// =================================================================
|
|
// 1. QUANTIZE / DEQUANTIZE ROUND-TRIP
|
|
// =================================================================
|
|
describe('quantize/dequantize round-trip', () => {
|
|
it('should round-trip a normalized vector with low error', () => {
|
|
const original = randomVector(384)
|
|
const sq8 = quantizeSQ8(original)
|
|
const restored = dequantizeSQ8(sq8.quantized, sq8.min, sq8.max)
|
|
|
|
expect(restored.length).toBe(original.length)
|
|
|
|
// Per-dimension error should be small (max 1/255 of range)
|
|
const range = sq8.max - sq8.min
|
|
const maxError = range / 255
|
|
for (let i = 0; i < original.length; i++) {
|
|
expect(Math.abs(restored[i] - original[i])).toBeLessThanOrEqual(maxError + 1e-7)
|
|
}
|
|
})
|
|
|
|
it('should handle a zero-range vector (all identical values)', () => {
|
|
const original = new Array(128).fill(0.5)
|
|
const sq8 = quantizeSQ8(original)
|
|
|
|
expect(sq8.min).toBe(0.5)
|
|
expect(sq8.max).toBe(0.5)
|
|
// All quantized values should be 128 (midpoint)
|
|
for (let i = 0; i < sq8.quantized.length; i++) {
|
|
expect(sq8.quantized[i]).toBe(128)
|
|
}
|
|
|
|
const restored = dequantizeSQ8(sq8.quantized, sq8.min, sq8.max)
|
|
for (let i = 0; i < restored.length; i++) {
|
|
expect(restored[i]).toBe(0.5)
|
|
}
|
|
})
|
|
|
|
it('should handle negative values', () => {
|
|
const original = [-1.0, -0.5, 0, 0.5, 1.0]
|
|
const sq8 = quantizeSQ8(original)
|
|
|
|
expect(sq8.min).toBe(-1.0)
|
|
expect(sq8.max).toBe(1.0)
|
|
// Min maps to 0, max maps to 255
|
|
expect(sq8.quantized[0]).toBe(0) // -1.0
|
|
expect(sq8.quantized[4]).toBe(255) // 1.0
|
|
|
|
const restored = dequantizeSQ8(sq8.quantized, sq8.min, sq8.max)
|
|
expect(Math.abs(restored[0] - (-1.0))).toBeLessThan(0.01)
|
|
expect(Math.abs(restored[4] - 1.0)).toBeLessThan(0.01)
|
|
})
|
|
|
|
it('should produce Uint8Array output in [0, 255] range', () => {
|
|
const original = randomVector(512)
|
|
const sq8 = quantizeSQ8(original)
|
|
|
|
expect(sq8.quantized).toBeInstanceOf(Uint8Array)
|
|
expect(sq8.quantized.length).toBe(512)
|
|
for (let i = 0; i < sq8.quantized.length; i++) {
|
|
expect(sq8.quantized[i]).toBeGreaterThanOrEqual(0)
|
|
expect(sq8.quantized[i]).toBeLessThanOrEqual(255)
|
|
}
|
|
})
|
|
|
|
it('should achieve 4x storage reduction (uint8 vs float32)', () => {
|
|
const dim = 384
|
|
const original = randomVector(dim)
|
|
const sq8 = quantizeSQ8(original)
|
|
|
|
const float32Size = dim * 4 // 4 bytes per float32
|
|
const sq8Size = sq8.quantized.byteLength + 8 // uint8 array + 2 floats for min/max
|
|
const ratio = float32Size / sq8Size
|
|
|
|
// Should be close to 4x (actually slightly less due to min/max overhead)
|
|
expect(ratio).toBeGreaterThan(3.9)
|
|
})
|
|
})
|
|
|
|
// =================================================================
|
|
// 2. SQ8 DISTANCE VS FLOAT32 DISTANCE
|
|
// =================================================================
|
|
describe('SQ8 distance accuracy', () => {
|
|
it('should correlate strongly with float32 cosine distance', () => {
|
|
const dim = 384
|
|
const numPairs = 100
|
|
const errors: number[] = []
|
|
|
|
for (let i = 0; i < numPairs; i++) {
|
|
const a = randomVector(dim)
|
|
const b = randomVector(dim)
|
|
|
|
const exactDist = cosineDistance(a, b)
|
|
|
|
const sq8A = quantizeSQ8(a)
|
|
const sq8B = quantizeSQ8(b)
|
|
const approxDist = distanceSQ8(
|
|
sq8A.quantized, sq8A.min, sq8A.max,
|
|
sq8B.quantized, sq8B.min, sq8B.max
|
|
)
|
|
|
|
errors.push(Math.abs(exactDist - approxDist))
|
|
}
|
|
|
|
// Mean absolute error should be very small
|
|
const meanError = errors.reduce((sum, e) => sum + e, 0) / errors.length
|
|
expect(meanError).toBeLessThan(0.02) // Less than 2% average error
|
|
|
|
// Max error should be bounded
|
|
const maxError = Math.max(...errors)
|
|
expect(maxError).toBeLessThan(0.1) // Less than 10% worst case
|
|
})
|
|
|
|
it('should return 0 for identical vectors', () => {
|
|
const v = randomVector(128)
|
|
const sq8 = quantizeSQ8(v)
|
|
const dist = distanceSQ8(
|
|
sq8.quantized, sq8.min, sq8.max,
|
|
sq8.quantized, sq8.min, sq8.max
|
|
)
|
|
expect(dist).toBeCloseTo(0, 5)
|
|
})
|
|
|
|
it('should preserve relative ordering of distances', () => {
|
|
const dim = 128
|
|
const query = randomVector(dim)
|
|
// Create a vector close to query and one far away
|
|
const close = query.map(v => v + (Math.random() * 0.1 - 0.05))
|
|
const far = randomVector(dim)
|
|
|
|
const exactClose = cosineDistance(query, close)
|
|
const exactFar = cosineDistance(query, far)
|
|
|
|
const sq8Query = quantizeSQ8(query)
|
|
const sq8Close = quantizeSQ8(close)
|
|
const sq8Far = quantizeSQ8(far)
|
|
|
|
const approxClose = distanceSQ8(
|
|
sq8Query.quantized, sq8Query.min, sq8Query.max,
|
|
sq8Close.quantized, sq8Close.min, sq8Close.max
|
|
)
|
|
const approxFar = distanceSQ8(
|
|
sq8Query.quantized, sq8Query.min, sq8Query.max,
|
|
sq8Far.quantized, sq8Far.min, sq8Far.max
|
|
)
|
|
|
|
// Relative ordering should be preserved
|
|
if (exactClose < exactFar) {
|
|
expect(approxClose).toBeLessThan(approxFar)
|
|
}
|
|
})
|
|
|
|
it('should handle zero vectors gracefully', () => {
|
|
const zero = new Array(64).fill(0)
|
|
const nonZero = randomVector(64)
|
|
|
|
const sq8Zero = quantizeSQ8(zero)
|
|
const sq8NonZero = quantizeSQ8(nonZero)
|
|
|
|
const dist = distanceSQ8(
|
|
sq8Zero.quantized, sq8Zero.min, sq8Zero.max,
|
|
sq8NonZero.quantized, sq8NonZero.min, sq8NonZero.max
|
|
)
|
|
// Zero vectors should return max distance (1.0)
|
|
expect(dist).toBeCloseTo(1.0, 1)
|
|
})
|
|
})
|
|
|
|
// =================================================================
|
|
// 3. SERIALIZATION / DESERIALIZATION
|
|
// =================================================================
|
|
describe('serialization round-trip', () => {
|
|
it('should serialize and deserialize SQ8 data with float32 precision', () => {
|
|
const original = randomVector(384)
|
|
const sq8 = quantizeSQ8(original)
|
|
const buffer = serializeSQ8(sq8)
|
|
const restored = deserializeSQ8(buffer)
|
|
|
|
// min/max are stored as float32, so some precision loss is expected
|
|
expect(restored.min).toBeCloseTo(sq8.min, 5)
|
|
expect(restored.max).toBeCloseTo(sq8.max, 5)
|
|
expect(restored.quantized.length).toBe(sq8.quantized.length)
|
|
for (let i = 0; i < sq8.quantized.length; i++) {
|
|
expect(restored.quantized[i]).toBe(sq8.quantized[i])
|
|
}
|
|
})
|
|
|
|
it('should produce compact binary format (8 + dim bytes)', () => {
|
|
const dim = 384
|
|
const sq8 = quantizeSQ8(randomVector(dim))
|
|
const buffer = serializeSQ8(sq8)
|
|
|
|
// 4 bytes for min (float32) + 4 bytes for max (float32) + dim bytes
|
|
expect(buffer.byteLength).toBe(8 + dim)
|
|
})
|
|
})
|
|
|
|
// =================================================================
|
|
// 4. HNSW SEARCH WITH QUANTIZATION
|
|
// =================================================================
|
|
describe('HNSW search with quantization', () => {
|
|
let index: JsHnswVectorIndex
|
|
let storage: MemoryStorage
|
|
const dim = 32 // Small dimension for fast tests
|
|
let ids: string[]
|
|
|
|
beforeEach(async () => {
|
|
storage = new MemoryStorage()
|
|
index = new JsHnswVectorIndex(
|
|
{
|
|
M: 8,
|
|
efConstruction: 100,
|
|
efSearch: 50,
|
|
ml: 8,
|
|
quantization: { enabled: true, rerankMultiplier: 3 }
|
|
},
|
|
euclideanDistance,
|
|
{ useParallelization: false, storage }
|
|
)
|
|
ids = []
|
|
})
|
|
|
|
it('should return correct results with quantization enabled', async () => {
|
|
// Insert entities with UUID IDs
|
|
const vectors: number[][] = []
|
|
for (let i = 0; i < 50; i++) {
|
|
const id = uuidv4()
|
|
ids.push(id)
|
|
const v = randomVector(dim)
|
|
vectors.push(v)
|
|
await index.addItem({ id, vector: v })
|
|
}
|
|
|
|
// Search with a known vector
|
|
const queryIdx = 5
|
|
const results = await index.search(vectors[queryIdx], 5)
|
|
|
|
// The exact vector should be the closest (distance ~0)
|
|
expect(results.length).toBeGreaterThan(0)
|
|
expect(results[0][0]).toBe(ids[queryIdx])
|
|
expect(results[0][1]).toBeCloseTo(0, 1)
|
|
})
|
|
|
|
it('should find the exact match as top result', async () => {
|
|
const targetId = uuidv4()
|
|
const target = randomVector(dim)
|
|
await index.addItem({ id: targetId, vector: target })
|
|
|
|
// Add noise vectors
|
|
for (let i = 0; i < 30; i++) {
|
|
await index.addItem({ id: uuidv4(), vector: randomVector(dim) })
|
|
}
|
|
|
|
const results = await index.search(target, 1)
|
|
expect(results.length).toBe(1)
|
|
expect(results[0][0]).toBe(targetId)
|
|
})
|
|
|
|
it('should respect k parameter', async () => {
|
|
for (let i = 0; i < 100; i++) {
|
|
await index.addItem({ id: uuidv4(), vector: randomVector(dim) })
|
|
}
|
|
|
|
const results5 = await index.search(randomVector(dim), 5)
|
|
const results10 = await index.search(randomVector(dim), 10)
|
|
|
|
expect(results5.length).toBe(5)
|
|
// With quantization on a small graph (100 items), HNSW may occasionally
|
|
// return slightly fewer than k due to approximation in distance calculations
|
|
expect(results10.length).toBeGreaterThanOrEqual(8)
|
|
expect(results10.length).toBeLessThanOrEqual(10)
|
|
})
|
|
})
|
|
|
|
// =================================================================
|
|
// 5. TWO-PHASE RERANK
|
|
// =================================================================
|
|
describe('two-phase rerank', () => {
|
|
it('should accept rerank options in search', async () => {
|
|
const storage = new MemoryStorage()
|
|
const index = new JsHnswVectorIndex(
|
|
{
|
|
M: 8,
|
|
efConstruction: 100,
|
|
efSearch: 50,
|
|
ml: 8,
|
|
quantization: { enabled: true, rerankMultiplier: 1 } // Disable default rerank
|
|
},
|
|
euclideanDistance,
|
|
{ useParallelization: false, storage }
|
|
)
|
|
|
|
const dim = 32
|
|
for (let i = 0; i < 30; i++) {
|
|
await index.addItem({ id: uuidv4(), vector: randomVector(dim) })
|
|
}
|
|
|
|
// Search with explicit rerank
|
|
const results = await index.search(
|
|
randomVector(dim),
|
|
5,
|
|
undefined,
|
|
{ rerank: { multiplier: 3 } }
|
|
)
|
|
|
|
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])
|
|
}
|
|
})
|
|
|
|
it('reranked results should be sorted by exact distance', async () => {
|
|
const storage = new MemoryStorage()
|
|
const index = new JsHnswVectorIndex(
|
|
{
|
|
M: 8,
|
|
efConstruction: 100,
|
|
efSearch: 50,
|
|
ml: 8,
|
|
quantization: { enabled: true, rerankMultiplier: 3 }
|
|
},
|
|
euclideanDistance,
|
|
{ useParallelization: false, storage }
|
|
)
|
|
|
|
const dim = 32
|
|
for (let i = 0; i < 50; i++) {
|
|
await index.addItem({ id: uuidv4(), vector: randomVector(dim) })
|
|
}
|
|
|
|
const query = randomVector(dim)
|
|
const results = await index.search(query, 10)
|
|
|
|
// Results should be sorted by exact distance (after reranking)
|
|
for (let i = 1; i < results.length; i++) {
|
|
expect(results[i][1]).toBeGreaterThanOrEqual(results[i - 1][1])
|
|
}
|
|
})
|
|
})
|
|
|
|
// =================================================================
|
|
// 6. CONFIG DEFAULTS
|
|
// =================================================================
|
|
describe('configuration defaults', () => {
|
|
it('should disable quantization by default', async () => {
|
|
const storage = new MemoryStorage()
|
|
const defaultIndex = new JsHnswVectorIndex(
|
|
{ M: 4, efConstruction: 50, efSearch: 20 },
|
|
euclideanDistance,
|
|
{ useParallelization: false, storage }
|
|
)
|
|
|
|
const dim = 16
|
|
for (let i = 0; i < 10; i++) {
|
|
await defaultIndex.addItem({ id: uuidv4(), vector: randomVector(dim) })
|
|
}
|
|
|
|
// Search should work identically to pre-quantization behavior
|
|
const results = await defaultIndex.search(randomVector(dim), 5)
|
|
expect(results.length).toBe(5)
|
|
|
|
// Distances should be exact euclidean (not SQ8 approximate)
|
|
for (const [, dist] of results) {
|
|
expect(typeof dist).toBe('number')
|
|
expect(dist).toBeGreaterThanOrEqual(0)
|
|
}
|
|
})
|
|
|
|
it('should use default rerankMultiplier of 3', async () => {
|
|
const storage = new MemoryStorage()
|
|
const index = new JsHnswVectorIndex(
|
|
{
|
|
M: 4,
|
|
efConstruction: 50,
|
|
efSearch: 20,
|
|
quantization: { enabled: true }
|
|
},
|
|
euclideanDistance,
|
|
{ useParallelization: false, storage }
|
|
)
|
|
|
|
// The index should be created without errors
|
|
// Default rerankMultiplier should be 3 (tested implicitly via search)
|
|
const dim = 16
|
|
for (let i = 0; i < 10; i++) {
|
|
await index.addItem({ id: uuidv4(), vector: randomVector(dim) })
|
|
}
|
|
|
|
const results = await index.search(randomVector(dim), 3)
|
|
expect(results.length).toBe(3)
|
|
})
|
|
|
|
it('should default vectorStorage to memory mode', 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(16)
|
|
await index.addItem({ id, vector: v })
|
|
|
|
// In memory mode, vector should be immediately searchable
|
|
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)
|
|
})
|
|
})
|
|
})
|