Skip to content

Commit a6b004d

Browse files
Merge latest commits from hnsw-3 (#423)
See #402
1 parent 86ffba6 commit a6b004d

File tree

12 files changed

+137
-88
lines changed

12 files changed

+137
-88
lines changed

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,18 @@ There are two broad categories of ANN index:
1010

1111
Graph-based indexes tend to be simpler to implement and faster, but more importantly they can be constructed and updated incrementally. This makes them a much better fit for a general-purpose index than partitioning approaches that only work on static datasets that are completely specified up front. That is why all the major commercial vector indexes use graph approaches.
1212

13-
JVector is a graph index that takes a hybrid merging the the DiskANN and HNSW family trees.
13+
JVector is a graph index that merges the DiskANN and HNSW family trees.
1414
JVector borrows the hierarchical structure from HNSW, and uses Vamana (the algorithm behind DiskANN) within each layer.
1515

1616

1717
## JVector Architecture
1818

19-
JVector is a graph-based index that builds on the HNSW anD DiskANN designs with composable extensions.
19+
JVector is a graph-based index that builds on the HNSW and DiskANN designs with composable extensions.
2020

2121
JVector implements a multi-layer graph with nonblocking concurrency control, allowing construction to scale linearly with the number of cores:
2222
![JVector scales linearly as thread count increases](https://github.com/jbellis/jvector/assets/42158/f0127bfc-6c45-48b9-96ea-95b2120da0d9)
2323

24-
The upper layers of the hierarchy are represnted by an in-memory adjacency list per node. This allows for quick navigation with no IOs.
24+
The upper layers of the hierarchy are represented by an in-memory adjacency list per node. This allows for quick navigation with no IOs.
2525
The bottom layer of the graph is represented by an on-disk adjacency list per node. JVector uses additional data stored inline to support two-pass searches, with the first pass powered by lossily compressed representations of the vectors kept in memory, and the second by a more accurate representation read from disk. The first pass can be performed with
2626
* Product quantization (PQ), optionally with [anisotropic weighting](https://arxiv.org/abs/1908.10396)
2727
* [Binary quantization](https://huggingface.co/blog/embedding-quantization) (BQ)

UPGRADING.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
in each vector with high accuracy by first applying a nonlinear transformation that is individually fit to each
66
vector. These nonlinearities are designed to be lightweight and have a negligible impact on distance computation
77
performance.
8-
- Support for hierarchical graph indices. These new type of indices blends HNSW and DiskANN in a novel way. An
8+
- Support for hierarchical graph indices. This new type of index blends HNSW and DiskANN in a novel way. An
99
HNSW-like hierarchy resides in memory for quickly seeding the search. This also reduces the need for caching the
1010
DiskANN graph near the entrypoint. The base layer of the hierarchy is a DiskANN-like index and inherits its
1111
properties. This hierarchical structure can be disabled, ending up with just the base DiskANN layer.
@@ -19,6 +19,8 @@
1919
- GraphSearcher can be configured to run pruned searches using GraphSearcher.usePruning. When this is set to true,
2020
we do early termination of the search. In certain cases, this can accelerate the search at the potential cost of some
2121
accuracy. It is set to false by default.
22+
- The constructors of GraphIndexBuilder allow to specify different maximum out-degrees for the graphs in each layer.
23+
However, this feature does not work with FusedADC in this version.
2224

2325
### API changes in 3.0.6
2426

jvector-base/src/main/java/io/github/jbellis/jvector/graph/GraphIndex.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ default boolean containsNode(int nodeId) {
9696
void close() throws IOException;
9797

9898
/**
99-
* @return The maximum (coarser) level with that contains a vector in the graph.
99+
* @return The maximum (coarser) level that contains a vector in the graph.
100100
*/
101101
int getMaxLevel();
102102

jvector-base/src/main/java/io/github/jbellis/jvector/graph/GraphIndexBuilder.java

Lines changed: 74 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -279,33 +279,35 @@ public static GraphIndexBuilder rescore(GraphIndexBuilder other, BuildScoreProvi
279279
other.parallelExecutor);
280280

281281
// Copy each node and its neighbors from the old graph to the new one
282-
IntStream.range(0, other.graph.getIdUpperBound()).parallel().forEach(i -> {
283-
// Find the highest layer this node exists in
284-
int maxLayer = -1;
285-
for (int lvl = 0; lvl < other.graph.layers.size(); lvl++) {
286-
if (other.graph.getNeighbors(lvl, i) == null) {
287-
break;
282+
other.parallelExecutor.submit(() -> {
283+
IntStream.range(0, other.graph.getIdUpperBound()).parallel().forEach(i -> {
284+
// Find the highest layer this node exists in
285+
int maxLayer = -1;
286+
for (int lvl = 0; lvl < other.graph.layers.size(); lvl++) {
287+
if (other.graph.getNeighbors(lvl, i) == null) {
288+
break;
289+
}
290+
maxLayer = lvl;
291+
}
292+
if (maxLayer < 0) {
293+
return;
288294
}
289-
maxLayer = lvl;
290-
}
291-
if (maxLayer < 0) {
292-
return;
293-
}
294295

295-
// Loop over 0..maxLayer, re-score neighbors for each layer
296-
var sf = newProvider.searchProviderFor(i).scoreFunction();
297-
for (int lvl = 0; lvl <= maxLayer; lvl++) {
298-
var oldNeighbors = other.graph.getNeighbors(lvl, i);
299-
// Copy edges, compute new scores
300-
var newNeighbors = new NodeArray(oldNeighbors.size());
301-
for (var it = oldNeighbors.iterator(); it.hasNext();) {
302-
int neighbor = it.nextInt();
303-
// since we're using a different score provider, use insertSorted instead of addInOrder
304-
newNeighbors.insertSorted(neighbor, sf.similarityTo(neighbor));
296+
// Loop over 0..maxLayer, re-score neighbors for each layer
297+
var sf = newProvider.searchProviderFor(i).scoreFunction();
298+
for (int lvl = 0; lvl <= maxLayer; lvl++) {
299+
var oldNeighbors = other.graph.getNeighbors(lvl, i);
300+
// Copy edges, compute new scores
301+
var newNeighbors = new NodeArray(oldNeighbors.size());
302+
for (var it = oldNeighbors.iterator(); it.hasNext();) {
303+
int neighbor = it.nextInt();
304+
// since we're using a different score provider, use insertSorted instead of addInOrder
305+
newNeighbors.insertSorted(neighbor, sf.similarityTo(neighbor));
306+
}
307+
newBuilder.graph.addNode(lvl, i, newNeighbors);
305308
}
306-
newBuilder.graph.addNode(lvl, i, newNeighbors);
307-
}
308-
});
309+
});
310+
}).join();
309311

310312
// Set the entry node
311313
newBuilder.graph.updateEntryNode(other.graph.entry());
@@ -375,15 +377,24 @@ private void improveConnections(int node) {
375377
var bits = new ExcludingBits(node);
376378
try (var gs = searchers.get()) {
377379
gs.initializeInternal(ssp, graph.entry(), bits);
380+
var acceptedBits = Bits.intersectionOf(bits, gs.getView().liveNodes());
378381

379-
// Move downward from entry.level to 1
382+
// Move downward from entry.level to 0
380383
for (int lvl = graph.entry().level; lvl >= 0; lvl--) {
381-
gs.searchOneLayer(ssp, 1, 0.0f, lvl, Bits.intersectionOf(bits, gs.getView().liveNodes()));
384+
// This additional call seems redundant given that we have already initialized an ssp above.
385+
// However, there is a subtle interplay between the ssp of the search and the ssp used in insertDiverse.
386+
// Do not remove this line.
387+
ssp = scoreProvider.searchProviderFor(node);
388+
382389
if (graph.layers.get(lvl).get(node) != null) {
390+
gs.searchOneLayer(ssp, beamWidth, 0.0f, lvl, acceptedBits);
391+
383392
var candidates = new NodeArray(gs.approximateResults.size());
384393
gs.approximateResults.foreach(candidates::insertSorted);
385394
var newNeighbors = graph.layers.get(lvl).insertDiverse(node, candidates);
386395
graph.layers.get(lvl).backlink(newNeighbors, node, neighborOverflow);
396+
} else {
397+
gs.searchOneLayer(ssp, 1, 0.0f, lvl, acceptedBits);
387398
}
388399
gs.setEntryPointsFromPreviousLayer();
389400
}
@@ -530,7 +541,7 @@ public synchronized long removeDeletedNodes() {
530541
if (nRemoved == 0) {
531542
return 0;
532543
}
533-
// make a list of remaining live nodes
544+
// make a list of remaining live nodes
534545
var liveNodes = new IntArrayList();
535546
for (int i = 0; i < graph.getIdUpperBound(); i++) {
536547
if (graph.containsNode(i) && !toDelete.get(i)) {
@@ -627,14 +638,17 @@ public synchronized long removeDeletedNodes() {
627638
graph.updateEntryNode(newEntry >= 0 ? new NodeAtLevel(newLevel, newEntry) : null);
628639
}
629640

641+
long memorySize = 0;
642+
630643
// Remove the deleted nodes from the graph
631644
assert toDelete.cardinality() == nRemoved : "cardinality changed";
632-
int nodeLayers = 0;
633645
for (int i = toDelete.nextSetBit(0); i != NO_MORE_DOCS; i = toDelete.nextSetBit(i + 1)) {
634-
nodeLayers += graph.removeNode(i);
646+
int nDeletions = graph.removeNode(i);
647+
for (var iLayer = 0; iLayer < nDeletions; iLayer++) {
648+
memorySize += graph.ramBytesUsedOneNode(iLayer);
649+
}
635650
}
636-
// TODO this is not correct since different layers can use more or less ram due to different degrees
637-
return nodeLayers * graph.ramBytesUsedOneNode(0);
651+
return memorySize;
638652
}
639653

640654
private void updateNeighbors(int layer, int nodeId, NodeArray natural, NodeArray concurrent) {
@@ -703,10 +717,30 @@ public void load(RandomAccessReader in) throws IOException {
703717
throw new IllegalStateException("Cannot load into a non-empty graph");
704718
}
705719

720+
int maybeMagic = in.readInt();
721+
int version; // This is not used in V4 but may be useful in the future, putting it as a placeholder.
722+
if (maybeMagic != OnHeapGraphIndex.MAGIC) {
723+
// JVector 3 format, no magic or version, starts straight off with the number of nodes
724+
version = 3;
725+
int size = maybeMagic;
726+
loadV3(in, size);
727+
} else {
728+
version = in.readInt();
729+
loadV4(in);
730+
}
731+
}
732+
733+
private void loadV4(RandomAccessReader in) throws IOException {
734+
if (graph.size(0) != 0) {
735+
throw new IllegalStateException("Cannot load into a non-empty graph");
736+
}
737+
706738
int layerCount = in.readInt();
707739
int entryNode = in.readInt();
708740
var layerDegrees = new ArrayList<Integer>(layerCount);
709741

742+
Map<Integer, Integer> nodeLevelMap = new HashMap<>();
743+
710744
// Read layer info
711745
for (int level = 0; level < layerCount; level++) {
712746
int layerSize = in.readInt();
@@ -721,19 +755,25 @@ public void load(RandomAccessReader in) throws IOException {
721755
ca.addInOrder(neighbor, sf.similarityTo(neighbor));
722756
}
723757
graph.addNode(level, nodeId, ca);
758+
nodeLevelMap.put(nodeId, level);
724759
}
725760
}
726761

762+
for (var k : nodeLevelMap.keySet()) {
763+
NodeAtLevel nal = new NodeAtLevel(nodeLevelMap.get(k), k);
764+
graph.markComplete(nal);
765+
}
766+
727767
graph.setDegrees(layerDegrees);
728768
graph.updateEntryNode(new NodeAtLevel(graph.getMaxLevel(), entryNode));
729769
}
730770

731-
public void loadV3(RandomAccessReader in) throws IOException {
771+
772+
private void loadV3(RandomAccessReader in, int size) throws IOException {
732773
if (graph.size() != 0) {
733774
throw new IllegalStateException("Cannot load into a non-empty graph");
734775
}
735776

736-
int size = in.readInt();
737777
int entryNode = in.readInt();
738778
int maxDegree = in.readInt();
739779

@@ -747,6 +787,7 @@ public void loadV3(RandomAccessReader in) throws IOException {
747787
ca.addInOrder(neighbor, sf.similarityTo(neighbor));
748788
}
749789
graph.addNode(0, nodeId, ca);
790+
graph.markComplete(new NodeAtLevel(0, nodeId));
750791
}
751792

752793
graph.updateEntryNode(new NodeAtLevel(0, entryNode));

jvector-base/src/main/java/io/github/jbellis/jvector/graph/GraphSearcher.java

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ public GraphIndex.View getView() {
9999
}
100100

101101
/**
102-
* Whe using pruning, we are using a heuristic to terminate the search earlier.
102+
* When using pruning, we are using a heuristic to terminate the search earlier.
103103
* In certain cases, it can lead to speedups. This is set to false by default.
104104
* @param usage a boolean that determines whether we do early termination or not.
105105
*/
@@ -402,10 +402,8 @@ SearchResult resume(int topK, int rerankK, float threshold, float rerankFloor) {
402402
rerankedResults.setMaxSize(topK);
403403

404404
// add evicted results from the last call back to the candidates
405-
var previouslyEvicted = evictedResults.size() > 0 ? new SparseBits() : Bits.NONE;
406405
evictedResults.foreach((node, score) -> {
407406
candidates.push(node, score);
408-
((SparseBits) previouslyEvicted).set(node);
409407
});
410408
evictedResults.clear();
411409

jvector-base/src/main/java/io/github/jbellis/jvector/graph/OnHeapGraphIndex.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@
5656
* For searching, use a view obtained from {@link #getView()} which supports level–aware operations.
5757
*/
5858
public class OnHeapGraphIndex implements GraphIndex {
59+
// Used for saving and loading OnHeapGraphIndex
60+
public static final int MAGIC = 0x75EC4012; // JVECTOR, with some imagination
61+
5962
// The current entry node for searches
6063
private final AtomicReference<NodeAtLevel> entryPoint;
6164

@@ -448,6 +451,9 @@ public void save(DataOutput out) {
448451
}
449452

450453
try (var view = getView()) {
454+
out.writeInt(OnHeapGraphIndex.MAGIC); // the magic number
455+
out.writeInt(4); // The version
456+
451457
// Write graph-level properties.
452458
out.writeInt(layers.size());
453459
assert view.entryNode().level == getMaxLevel();

jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndex.java

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ public class OnDiskGraphIndex implements GraphIndex, AutoCloseable, Accountable
7878
// offset of L0 adjacency data
7979
private final long neighborsOffset;
8080
/** For layers > 0, store adjacency fully in memory. */
81-
private volatile AtomicReference<List<Int2ObjectHashMap<int[]>>> inMemoryNeighbors;
81+
private final AtomicReference<List<Int2ObjectHashMap<int[]>>> inMemoryNeighbors;
8282

8383
OnDiskGraphIndex(ReaderSupplier readerSupplier, Header header, long neighborsOffset)
8484
{
@@ -202,17 +202,17 @@ public NodesIterator getNodes(int level) {
202202
}
203203

204204
try (var reader = readerSupplier.get()) {
205-
int[] valid_nodes = new int[size(level)];
205+
int[] validNodes = new int[size(level)];
206206
int upperBound = level == 0 ? getIdUpperBound() : size(level);
207207
int pos = 0;
208208
for (int node = 0; node < upperBound; node++) {
209-
long node_offset = layerOffset + (node * thisLayerNodeSide);
210-
reader.seek(node_offset);
209+
long nodeOffset = layerOffset + (node * thisLayerNodeSide);
210+
reader.seek(nodeOffset);
211211
if (reader.readInt() != -1) {
212-
valid_nodes[pos++] = node;
212+
validNodes[pos++] = node;
213213
}
214214
}
215-
return new NodesIterator.ArrayNodesIterator(valid_nodes, size);
215+
return new NodesIterator.ArrayNodesIterator(validNodes, size);
216216
} catch (IOException e) {
217217
throw new UncheckedIOException(e);
218218
}

jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndexWriter.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ public synchronized void write(Map<FeatureId, IntFunction<Feature.State>> featur
205205
out.seek(out.position() + feature.featureSize());
206206
}
207207
out.writeInt(0);
208-
for (int n = 0; n < graph.maxDegree(); n++) {
208+
for (int n = 0; n < graph.getDegree(0); n++) {
209209
out.writeInt(-1);
210210
}
211211
continue;

jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedADC.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ public void writeInline(DataOutput out, Feature.State state_) throws IOException
103103
var state = (FusedADC.State) state_;
104104
var pqv = state.pqVectors;
105105

106-
var neighbors = state.view.getNeighborsIterator(0, state.nodeId); // TODO
106+
var neighbors = state.view.getNeighborsIterator(0, state.nodeId);
107107
int n = 0;
108108
compressedNeighbors.zero();
109109
while (neighbors.hasNext()) {

0 commit comments

Comments
 (0)