From 73a712fc459ed196bd5de45a9b74faa1a1ad38a1 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Fri, 17 Apr 2026 05:34:33 -0600 Subject: [PATCH 1/2] Fix multiple HNSW bugs causing intermittent search quality issues - Only replace entry point when new node level is strictly higher, not equal, to avoid replacing well-connected entry points with unconnected new nodes - Use passed distance function for entry point in searchLayer instead of always using the instance default, which mixed distance metrics - Prevent orphaning nodes at level 0 when pruning excess connections - Update stale distances in reverse connections when a node's vector changes Co-Authored-By: Claude Opus 4.6 (1M context) --- .../HierarchicalNavigableSmallWorld.ts | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/resources/indexes/HierarchicalNavigableSmallWorld.ts b/resources/indexes/HierarchicalNavigableSmallWorld.ts index 380f8f061..8caf76067 100644 --- a/resources/indexes/HierarchicalNavigableSmallWorld.ts +++ b/resources/indexes/HierarchicalNavigableSmallWorld.ts @@ -130,8 +130,8 @@ export class HierarchicalNavigableSmallWorld { // Generate random level for this new element const level = oldNode.level ?? Math.min(Math.floor(-Math.log(Math.random()) * this.mL), MAX_LEVEL); let currentLevel = entryPoint.level; - if (level >= currentLevel) { - // if we are at this level or higher, make this the new entry point + if (level > currentLevel) { + // if we are at a higher, make this the new entry point if (typeof nodeId !== 'number') { throw new Error('Invalid nodeId: ' + nodeId); } @@ -232,6 +232,19 @@ export class HierarchicalNavigableSmallWorld { oldNode[l] = oldConnections; } oldConnections.splice(oldPosition, 1); + // update the distance in the reverse connection if the vector changed + if (oldConnection.distance !== distance) { + const neighborNode = updateNode(id, node); + if (neighborNode[l]) { + if (Object.isFrozen(neighborNode[l])) { + neighborNode[l] = neighborNode[l].slice(); + } + const reverseIdx = neighborNode[l].findIndex(({ id: nid }) => nid === nodeId); + if (reverseIdx >= 0) { + neighborNode[l][reverseIdx] = { id: nodeId, distance }; + } + } + } } else { // add new connection since this is truly a new connection now this.addConnection(id, updateNode(id, node), nodeId, l, distance, updateNode, options); @@ -360,7 +373,7 @@ export class HierarchicalNavigableSmallWorld { const candidates = [ { id: entryPointId, - distance: this.distance(queryVector, entryPoint.vector), + distance: distanceFunction(queryVector, entryPoint.vector), node: entryPoint, }, ]; @@ -531,10 +544,13 @@ export class HierarchicalNavigableSmallWorld { if (removedNode) { // Remove the reverse connection if it exists if (removedNode[level]) { - removedNode = updateNode(removed.id, removedNode); - removedNode[level] = removedNode[level].filter(({ id }) => id !== fromId); - if (level === 0 && removedNode[level].length === 0) { - logger.info?.('should not remove last connection', fromId, toId); + const filtered = removedNode[level].filter(({ id }) => id !== fromId); + if (level === 0 && filtered.length === 0) { + // don't remove the last connection at level 0 — it would orphan this node + logger.info?.('skipping removal of last connection', fromId, toId); + } else { + removedNode = updateNode(removed.id, removedNode); + removedNode[level] = filtered; } } } From 51d2db6d212507b11432f05faf23a7cda93e73a8 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Fri, 17 Apr 2026 09:22:31 -0600 Subject: [PATCH 2/2] Update resources/indexes/HierarchicalNavigableSmallWorld.ts Co-authored-by: Nathan Heskew --- resources/indexes/HierarchicalNavigableSmallWorld.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/indexes/HierarchicalNavigableSmallWorld.ts b/resources/indexes/HierarchicalNavigableSmallWorld.ts index 8caf76067..039dd2996 100644 --- a/resources/indexes/HierarchicalNavigableSmallWorld.ts +++ b/resources/indexes/HierarchicalNavigableSmallWorld.ts @@ -131,7 +131,7 @@ export class HierarchicalNavigableSmallWorld { const level = oldNode.level ?? Math.min(Math.floor(-Math.log(Math.random()) * this.mL), MAX_LEVEL); let currentLevel = entryPoint.level; if (level > currentLevel) { - // if we are at a higher, make this the new entry point + // if we are at a higher level, make this the new entry point if (typeof nodeId !== 'number') { throw new Error('Invalid nodeId: ' + nodeId); }