perf(8.0): HNSW removeItem is O(in-degree) via a reverse-adjacency index

removeItem() scanned the ENTIRE corpus on every delete to find nodes that
referenced the removed id (and to repair edges left asymmetric by pruning), so a
delete was O(N) and a bulk delete O(N²) — on the open-core JS vector path that
serves when no native provider is registered.

Maintain a reverse-adjacency index (`target → level → set of nodes that link to
target`), so removeItem touches only the removed node's actual in-neighbors:
O(in-degree) per delete, O(N·degree) for a bulk delete. The index is lazily
built, maintained incrementally at every forward-edge mutation (add-link and
prune), and invalidated (rebuilt on next use) by the bulk paths (cold-load
restore + clear). New tests assert the three invariants that matter for a
reverse index: no dangling references to deleted nodes, the incrementally-
maintained index exactly equals a fresh rebuild from the live adjacency, and
search still returns the survivors' true nearest neighbours (exact vs brute force).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
David Snelling 2026-06-29 11:06:22 -07:00
parent 9bfba637da
commit 72df5572b7
2 changed files with 259 additions and 30 deletions

View file

@ -32,6 +32,14 @@ const DEFAULT_CONFIG: HNSWConfig = {
*/ */
export class JsHnswVectorIndex implements VectorIndexProvider { export class JsHnswVectorIndex implements VectorIndexProvider {
private nouns: Map<string, HNSWNoun> = new Map() private nouns: Map<string, HNSWNoun> = new Map()
/**
* Reverse adjacency: `target id → (level → set of node ids that link TO target)`.
* Lets `removeItem` find a node's in-neighbors in O(in-degree) instead of scanning
* the whole corpus (which made bulk delete O(N²)). Lazily built on first need and
* maintained incrementally on link/prune/remove; `null` means "stale rebuild on
* next use" (set by every bulk path: cold-load restore + `clear()`).
*/
private incoming: Map<string, Map<number, Set<string>>> | null = null
private entryPointId: string | null = null private entryPointId: string | null = null
private maxLevel = 0 private maxLevel = 0
// Track high-level nodes for O(1) entry point selection // Track high-level nodes for O(1) entry point selection
@ -245,6 +253,9 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
hnswData: { level: number; connections: Record<string, string[]> }, hnswData: { level: number; connections: Record<string, string[]> },
noun: HNSWNoun noun: HNSWNoun
): Promise<void> { ): Promise<void> {
// Bulk load sets connections directly (bypassing the link path that maintains
// the reverse index) — mark it stale so the next removeItem rebuilds it.
this.incoming = null
const storageWithBlob = this.storage as unknown as { const storageWithBlob = this.storage as unknown as {
loadBinaryBlob?: (key: string) => Promise<Buffer | null> loadBinaryBlob?: (key: string) => Promise<Buffer | null>
} }
@ -489,12 +500,14 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
noun.connections.get(level)!.add(neighborId) noun.connections.get(level)!.add(neighborId)
this.addIncoming(neighborId, level, id) // forward edge id → neighborId
// Add reverse connection // Add reverse connection
if (!neighbor.connections.has(level)) { if (!neighbor.connections.has(level)) {
neighbor.connections.set(level, new Set<string>()) neighbor.connections.set(level, new Set<string>())
} }
neighbor.connections.get(level)!.add(id) neighbor.connections.get(level)!.add(id)
this.addIncoming(id, level, neighborId) // forward edge neighborId → id
// Ensure neighbor doesn't have too many connections // Ensure neighbor doesn't have too many connections
if (neighbor.connections.get(level)!.size > this.config.M) { if (neighbor.connections.get(level)!.size > this.config.M) {
@ -841,6 +854,44 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
return [...nearestNouns].slice(0, k) return [...nearestNouns].slice(0, k)
} }
/**
* Build (or return the cached) reverse-adjacency index. O(N + E) the first time
* after a bulk load/clear; O(1) thereafter while it stays live.
*/
private ensureIncoming(): Map<string, Map<number, Set<string>>> {
if (this.incoming !== null) return this.incoming
const inc = new Map<string, Map<number, Set<string>>>()
for (const [nodeId, node] of this.nouns) {
for (const [level, targets] of node.connections) {
for (const target of targets) {
let byLevel = inc.get(target)
if (!byLevel) { byLevel = new Map(); inc.set(target, byLevel) }
let set = byLevel.get(level)
if (!set) { set = new Set(); byLevel.set(level, set) }
set.add(nodeId)
}
}
}
this.incoming = inc
return inc
}
/** Record a new forward edge `source → target` at `level` (no-op while the index is stale). */
private addIncoming(target: string, level: number, source: string): void {
if (this.incoming === null) return
let byLevel = this.incoming.get(target)
if (!byLevel) { byLevel = new Map(); this.incoming.set(target, byLevel) }
let set = byLevel.get(level)
if (!set) { set = new Set(); byLevel.set(level, set) }
set.add(source)
}
/** Record that the forward edge `source → target` at `level` is gone. */
private removeIncoming(target: string, level: number, source: string): void {
if (this.incoming === null) return
this.incoming.get(target)?.get(level)?.delete(source)
}
/** /**
* Remove an item from the index * Remove an item from the index
*/ */
@ -852,41 +903,38 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
const noun = this.nouns.get(id)! const noun = this.nouns.get(id)!
// Remove connections to this noun from all neighbors // Reverse-adjacency lets us touch ONLY the nodes that actually reference `id`
for (const [level, connections] of noun.connections.entries()) { // (its in-neighbors) rather than scanning the whole corpus — turning a delete
for (const neighborId of connections) { // from O(N) into O(in-degree) and a bulk delete from O(N²) into O(N·degree).
// Snapshot each referrer set because pruneConnections mutates the index.
const neighbor = this.nouns.get(neighborId) const incoming = this.ensureIncoming()
if (!neighbor) { const referrers = incoming.get(id)
// Skip neighbors that don't exist (expected during rapid additions/deletions) if (referrers) {
continue for (const [level, refSet] of referrers) {
} for (const refId of Array.from(refSet)) {
if (neighbor.connections.has(level)) { const ref = this.nouns.get(refId)
neighbor.connections.get(level)!.delete(id) if (ref && ref.connections.has(level)) {
// Drop the forward edge ref → id, then re-prune ref so the graph stays
// Prune connections after removing this noun to ensure consistency // navigable. (id's own reverse entry is dropped wholesale below, so we
await this.pruneConnections(neighbor, level) // intentionally do not maintain incoming[id] inside this loop.)
ref.connections.get(level)!.delete(id)
await this.pruneConnections(ref, level)
}
} }
} }
} }
// Also check all other nouns for references to this noun and remove them // id's OUTGOING edges disappear with it → drop id from each out-neighbor's
for (const [nounId, otherNoun] of this.nouns.entries()) { // reverse set so no stale referrer survives.
if (nounId === id) continue // Skip the noun being removed for (const [level, targets] of noun.connections) {
for (const target of targets) {
this.removeIncoming(target, level, id)
for (const [level, connections] of otherNoun.connections.entries()) {
if (connections.has(id)) {
connections.delete(id)
// Prune connections after removing this reference
await this.pruneConnections(otherNoun, level)
}
} }
} }
// Remove the noun // Remove the noun + its reverse-index entry.
this.nouns.delete(id) this.nouns.delete(id)
this.incoming?.delete(id)
// If we removed the entry point, find a new one // If we removed the entry point, find a new one
if (this.entryPointId === id) { if (this.entryPointId === id) {
@ -966,6 +1014,7 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
*/ */
public clear(): void { public clear(): void {
this.nouns.clear() this.nouns.clear()
this.incoming = null // reverse index no longer reflects the (now empty) graph
this.entryPointId = null this.entryPointId = null
this.maxLevel = 0 this.maxLevel = 0
} }
@ -1762,7 +1811,11 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
// Only proceed if we have valid neighbors // Only proceed if we have valid neighbors
if (distances.size === 0) { if (distances.size === 0) {
// If no valid neighbors, clear connections at this level // If no valid neighbors, clear connections at this level. Every current
// connection is dropped — unlink each from the reverse index.
if (this.incoming !== null) {
for (const dropped of connections) this.removeIncoming(dropped, level, noun.id)
}
noun.connections.set(level, new Set()) noun.connections.set(level, new Set())
return return
} }
@ -1774,8 +1827,16 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
this.config.M this.config.M
) )
// Update connections with only valid neighbors // Update connections with only valid neighbors. Pruning only ever drops
noun.connections.set(level, new Set(selectedNeighbors.keys())) // edges (the selected set is a subset of the current connections), so unlink
// each dropped neighbor from the reverse index.
const kept = new Set(selectedNeighbors.keys())
if (this.incoming !== null) {
for (const prev of connections) {
if (!kept.has(prev)) this.removeIncoming(prev, level, noun.id)
}
}
noun.connections.set(level, kept)
} }
/** /**

View file

@ -0,0 +1,168 @@
/**
* @module tests/unit/hnsw/remove-reverse-adjacency
* @description Correctness guard for the reverse-adjacency index that makes
* `removeItem` O(in-degree) instead of O(N) (and bulk delete O(N·degree) instead
* of O(N²)). The risk of a reverse index is staleness a missed maintenance site
* leaves a dangling reference to a deleted node or a stale referrer. These tests
* exercise mixed add/delete workloads and assert three invariants:
*
* 1. No surviving node's connections reference a deleted id (no dangling edges).
* 2. The incrementally-maintained reverse index matches a fresh rebuild from the
* live adjacency i.e. link/prune/remove maintenance is exactly consistent.
* 3. Search still returns the true nearest neighbours of the surviving set
* (verified exactly against a brute-force scan on a complete small graph).
*/
import { describe, it, expect, beforeEach } from 'vitest'
import { JsHnswVectorIndex } from '../../../src/hnsw/hnswIndex.js'
import { euclideanDistance } from '../../../src/utils/index.js'
import { MemoryStorage } from '../../../src/storage/adapters/memoryStorage.js'
const DIM = 8
function seededRand(seed: number): () => number {
let s = seed >>> 0
return () => {
s = (s + 0x6d2b79f5) | 0
let t = Math.imul(s ^ (s >>> 15), 1 | s)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
/** A deterministic vector pointing in a pseudo-random direction (well-connected graph). */
function vec(idx: number): number[] {
const rand = seededRand(idx + 1)
return Array.from({ length: DIM }, () => rand() * 2 - 1)
}
type Noun = { id: string; vector: number[]; connections: Map<number, Set<string>> }
function nounsOf(index: JsHnswVectorIndex): Map<string, Noun> {
return (index as unknown as { nouns: Map<string, Noun> }).nouns
}
/** Flatten a reverse index (or a freshly-derived one) to sorted `target|level|source` triples. */
function triplesFromIncoming(inc: Map<string, Map<number, Set<string>>>): string[] {
const out: string[] = []
for (const [target, byLevel] of inc) {
for (const [level, sources] of byLevel) {
for (const source of sources) out.push(`${target}|${level}|${source}`)
}
}
return out.sort()
}
/** Derive the ground-truth reverse index directly from the live forward adjacency. */
function triplesFromAdjacency(nouns: Map<string, Noun>): string[] {
const out: string[] = []
for (const [nodeId, node] of nouns) {
for (const [level, targets] of node.connections) {
for (const target of targets) out.push(`${target}|${level}|${nodeId}`)
}
}
return out.sort()
}
function assertNoDanglingRefs(nouns: Map<string, Noun>): void {
for (const [nodeId, node] of nouns) {
for (const [level, targets] of node.connections) {
for (const target of targets) {
expect(nouns.has(target), `node ${nodeId} (L${level}) links to deleted ${target}`).toBe(true)
}
}
}
}
function assertReverseIndexConsistent(index: JsHnswVectorIndex): void {
// ensureIncoming() returns the live (incrementally-maintained) index, building it
// only if it was invalidated. The triples it yields must equal the triples derived
// straight from the forward adjacency — empty leftover sets contribute nothing.
const live = (index as unknown as { ensureIncoming: () => Map<string, Map<number, Set<string>>> }).ensureIncoming()
expect(triplesFromIncoming(live)).toEqual(triplesFromAdjacency(nounsOf(index)))
}
function makeIndex(M: number): JsHnswVectorIndex {
return new JsHnswVectorIndex(
{ M, efConstruction: 200, efSearch: 64, ml: 16 },
euclideanDistance,
{ useParallelization: false, storage: new MemoryStorage() }
)
}
describe('HNSW removeItem — reverse-adjacency correctness', () => {
it('leaves no dangling references and a consistent reverse index after interleaved add/delete', async () => {
const index = makeIndex(16)
const live = new Set<string>()
// Add 120, then interleave: delete every 3rd as we keep adding — exercises the
// incremental maintenance (link + prune on add, unlink on remove) continuously.
for (let i = 0; i < 120; i++) {
const id = `n-${i}`
await index.addItem({ id, vector: vec(i) })
live.add(id)
if (i % 3 === 0 && i > 0) {
const victim = `n-${i - 1}`
await index.removeItem(victim)
live.delete(victim)
}
}
// Now delete ~half of the survivors in one burst (the O(N²)-prone path).
const survivors = [...live]
for (let i = 0; i < survivors.length; i += 2) {
await index.removeItem(survivors[i])
live.delete(survivors[i])
}
const nouns = nounsOf(index)
expect(nouns.size).toBe(live.size)
assertNoDanglingRefs(nouns)
assertReverseIndexConsistent(index)
})
it('removing the entry point repeatedly stays consistent', async () => {
const index = makeIndex(8)
for (let i = 0; i < 40; i++) await index.addItem({ id: `e-${i}`, vector: vec(i) })
// Delete whatever is currently the entry point, 20 times.
for (let k = 0; k < 20; k++) {
const ep = (index as unknown as { entryPointId: string | null }).entryPointId
if (!ep) break
await index.removeItem(ep)
assertNoDanglingRefs(nounsOf(index))
}
assertReverseIndexConsistent(index)
})
it('search returns the true nearest neighbours of the surviving set (exact, complete graph)', async () => {
// M ≥ N-1 ⇒ the small graph is complete and never prunes, so HNSW recall is 100%
// and we can assert the survivors\' exact top-k against a brute-force scan.
const index = makeIndex(64)
const all: Array<{ id: string; v: number[] }> = []
for (let i = 0; i < 24; i++) {
const v = vec(i + 1000)
all.push({ id: `s-${i}`, v })
await index.addItem({ id: `s-${i}`, vector: v })
}
const deleted = new Set(['s-2', 's-5', 's-9', 's-14', 's-20'])
for (const id of deleted) await index.removeItem(id)
const survivors = all.filter((x) => !deleted.has(x.id))
const query = vec(7).map((x) => x * 0.5) // an arbitrary deterministic query point
const k = 5
const bruteTopK = survivors
.map((x) => ({ id: x.id, d: euclideanDistance(query, x.v) }))
.sort((a, b) => a.d - b.d)
.slice(0, k)
.map((x) => x.id)
const got = (await index.search(query, k)).map(([id]) => id)
expect(got).toEqual(bruteTopK)
// And none of the deleted ids can ever surface.
expect(got.some((id) => deleted.has(id))).toBe(false)
assertNoDanglingRefs(nounsOf(index))
})
})