From 55f4e2d656349de518e5547be2de77f20b266530 Mon Sep 17 00:00:00 2001 From: Mattias Engman Date: Wed, 16 Sep 2026 12:02:31 +0200 Subject: [PATCH] Apply the neighbour heuristic incrementally on full back-links Every insert creates up to M0 back-links, and once the graph has warmed up the target list is almost always full. The old code handled that by re-running the complete selection heuristic over all M0 + 1 candidates: ~2,000 distance computations per back-link, ~130,000 per insert, each a 3 KiB memory-mapped read at 768 dimensions. Profiling the Cohere 100K build put 66 % of insert time there. AddNeighborConnection now does what Lucene's findWorstNonDiverse does: only the new node is unchecked, so an existing neighbour is compared against the new node alone and the new node against the neighbours nearer than itself. O(M0) distances instead of O(M0^2). Lucene's fallback of evicting the farthest candidate assumes the list only holds mutually diverse nodes. Ours does not, because SelectNeighborsHeuristic tops up with pruned candidates, and applying the fallback blindly left 7 of 200 nodes unreachable in an existing Euclidean test. When the incremental walk finds nothing redundant the code falls back to the full heuristic instead; instrumented on clustered data that happens on 0.1-0.7 % of full back-links. SelectNeighborsHeuristic drops LINQ for a stable index sort with identical ordering. The graph is not byte-identical to the old one, so recall is re-measured rather than assumed. Same machine, same seeds, old graph rebuilt the same day for the SIFT-1M comparison: siftsmall ~1,000-1,500 -> 2,485 inserts/s, recall unchanged Cohere 100K 1,696 s -> 589 s (59 -> 170 inserts/s), recall@100 -0.3 pp SIFT-1M 2,279 s -> 1,631 s (439 -> 613 inserts/s), recall@10 -1.5 pp at efSearch 10, -0.3 at 80, 0 at 320 Query throughput at a given efSearch is unchanged within run-to-run noise on both datasets. New tests: NeighborDiversityTests pins the heuristic's required outcome on hand-checkable 2-D/3-D constructions plus a well-formedness sweep, and passes against both implementations. RawGraph is shared out of GraphFanoutTests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Qvec.Core/QvecDatabase.cs | 106 ++++++++++++-- README.md | 16 +-- benchmarks/README.md | 23 ++++ docs/design-insert-prune.md | 122 ++++++++++++++++ tests/Qvec.Core.Tests/GraphFanoutTests.cs | 49 +------ .../Qvec.Core.Tests/NeighborDiversityTests.cs | 130 ++++++++++++++++++ tests/Qvec.Core.Tests/RawGraph.cs | 54 ++++++++ 7 files changed, 435 insertions(+), 65 deletions(-) create mode 100644 docs/design-insert-prune.md create mode 100644 tests/Qvec.Core.Tests/NeighborDiversityTests.cs create mode 100644 tests/Qvec.Core.Tests/RawGraph.cs diff --git a/Qvec.Core/QvecDatabase.cs b/Qvec.Core/QvecDatabase.cs index 5a7f9a5..aab3139 100644 --- a/Qvec.Core/QvecDatabase.cs +++ b/Qvec.Core/QvecDatabase.cs @@ -1650,15 +1650,43 @@ private bool ConnectNewNode(int newIndex, float[] newVector, int newLevel) { if (candidates.Length <= m) return candidates; - var ordered = candidates.Where(c => c.Id >= 0) - .OrderByDescending(c => c.Score) - .ToArray(); + int live = 0; + for (int i = 0; i < candidates.Length; i++) + if (candidates[i].Id >= 0) live++; + + var ordered = new (int Id, float Score)[live]; + for (int i = 0, j = 0; i < candidates.Length; i++) + if (candidates[i].Id >= 0) ordered[j++] = candidates[i]; + + // Stable, like the OrderByDescending it replaces, so equal scores keep candidate + // order and the resulting graph is unchanged. + StableSortDescending(ordered); if (ordered.Length <= m) return ordered; return PruneNeighbors(ordered, m); } + /// + /// Stable descending sort by score. Equal scores keep their input order, exactly as the + /// LINQ OrderByDescending this replaces, so the resulting graph is unchanged. + /// + private static void StableSortDescending((int Id, float Score)[] items) + { + var order = new int[items.Length]; + for (int i = 0; i < order.Length; i++) order[i] = i; + + Array.Sort(order, (a, b) => + { + int byScore = items[b].Score.CompareTo(items[a].Score); + return byScore != 0 ? byScore : a.CompareTo(b); + }); + + var sorted = new (int Id, float Score)[items.Length]; + for (int i = 0; i < order.Length; i++) sorted[i] = items[order[i]]; + sorted.CopyTo(items, 0); + } + /// /// Neighbour selection heuristic from Malkov & Yashunin, algorithm 4. /// Keeps a candidate only when it is closer to the owning node than to any candidate @@ -1983,6 +2011,15 @@ private unsafe void WriteNeighborsAtLevel(int nodeIndex, int level, int[] neighb /// leaving whole groups of entries with outgoing edges only — unreachable from the /// entry point, and therefore invisible to search. /// + /// + /// The heuristic is applied incrementally, the way Lucene's HNSW graph builder does it + /// (findWorstNonDiverse), instead of re-running the full O(M0²) selection over + /// all M0 + 1 candidates. Only the new node is unchecked: an existing neighbour can only + /// have become redundant because of the new node, and the new node has to be checked + /// against the neighbours closer than itself. That is O(M0) distance computations per + /// full back-link rather than O(M0²), and on 768-dimensional vectors, where every + /// distance is a 3 KiB memory read, the old path was 66 % of insert time. + /// private void AddNeighborConnection(int existingNode, int level, int newNode) { int slots = NeighborsAtLevel(level); @@ -2003,9 +2040,8 @@ private void AddNeighborConnection(int existingNode, int level, int newNode) if (neighbors[i] == newNode) return; } - // The list is full: re-run the selection heuristic over the existing neighbours - // plus the new node, all read in place from the mapped file. The new node's - // vector is already on disk at this point, so it needs no special casing. + // The list is full. Scores are recomputed because the graph section stores ids + // only; the new node's vector is already on disk, so it needs no special casing. int candidateCount = slots + 1; var candidates = new (int Id, float Score)[candidateCount]; @@ -2014,13 +2050,29 @@ private void AddNeighborConnection(int existingNode, int level, int newNode) candidates[slots] = (newNode, StoredSimilarity(existingNode, newNode)); - // Sorted by descending similarity for the pruning pass. Array.Sort runs the same - // introsort over the same comparison outcomes as the index-array sort it - // replaces, so tie ordering, and therefore the graph, is unchanged. Array.Sort(candidates, DescendingScore.Instance); - var selected = PruneNeighbors(candidates, slots); - WriteNeighborsAtLevel(existingNode, level, selected); + int newPosition = 0; + while (candidates[newPosition].Id != newNode) newPosition++; + int evict = FindWorstNonDiverse(candidates, newPosition); + + if (evict < 0) + { + // Nothing is redundant because of the new node. The list may still hold + // neighbours that were topped up past the diversity check when it was + // built (keepPrunedConnections), and those are the ones to give up before + // a diverse long-range link. Only the full heuristic can tell them apart. + WriteNeighborsAtLevel(existingNode, level, PruneNeighbors(candidates, slots)); + return; + } + + // Rejecting the new node leaves the stored list exactly as it was. + if (evict == newPosition) return; + + for (int i = 0, j = 0; i < candidateCount; i++) + if (i != evict) neighbors[j++] = candidates[i].Id; + + WriteNeighborsAtLevel(existingNode, level, neighbors); } finally { @@ -2028,6 +2080,38 @@ private void AddNeighborConnection(int existingNode, int level, int newNode) } } + /// + /// Walks the candidates from farthest to nearest and returns the index of the first one + /// that is closer to the new node than it is to the owner — the candidate the selection + /// heuristic would discard because of the insertion. For the new node itself every + /// nearer candidate is checked. Returns -1 when the new node makes nothing redundant. + /// + /// Sorted by descending similarity to the owner. + /// Index of the newly inserted node; the only candidate the existing neighbours were never checked against. + private int FindWorstNonDiverse((int Id, float Score)[] candidates, int newPosition) + { + for (int i = candidates.Length - 1; i > 0; i--) + { + var candidate = candidates[i]; + + if (i == newPosition) + { + for (int j = 0; j < i; j++) + { + if (StoredSimilarity(candidate.Id, candidates[j].Id) > candidate.Score) + return i; + } + } + else if (newPosition < i) + { + if (StoredSimilarity(candidate.Id, candidates[newPosition].Id) > candidate.Score) + return i; + } + } + + return -1; + } + private sealed class DescendingScore : IComparer<(int Id, float Score)> { public static readonly DescendingScore Instance = new(); diff --git a/README.md b/README.md index 6e2f6dc..01f4c48 100644 --- a/README.md +++ b/README.md @@ -34,14 +34,14 @@ Measured on **SIFT-1M** ([TexMex corpus](http://corpus-texmex.irisa.fr/)) agains | efSearch | recall@1 | recall@10 | QPS | mean latency | | ---: | ---: | ---: | ---: | ---: | -| 10 | 89.7 % | 85.9 % | 8,110 | 0.123 ms | -| 20 | 95.3 % | 93.7 % | 5,128 | 0.195 ms | -| 40 | 98.2 % | 97.8 % | 3,231 | 0.310 ms | -| 80 | 99.0 % | 99.4 % | 1,873 | 0.534 ms | -| 160 | 99.2 % | 99.8 % | 1,023 | 0.978 ms | -| 320 | 99.2 % | 99.9 % | 573 | 1.744 ms | +| 10 | 88.0 % | 84.4 % | 6,734 | 0.148 ms | +| 20 | 94.5 % | 92.5 % | 4,341 | 0.230 ms | +| 40 | 97.6 % | 97.0 % | 2,624 | 0.381 ms | +| 80 | 98.8 % | 99.0 % | 1,576 | 0.634 ms | +| 160 | 99.3 % | 99.7 % | 863 | 1.158 ms | +| 320 | 99.3 % | 99.9 % | 498 | 2.010 ms | -Index build: 2,762 s (362 inserts/s), producing a 1,324 MiB file, with `indexSeed` pinned so the run can be reproduced. Build throughput is still the weakest number here: the insert path is now dominated by the O(M0²) distance arithmetic of the neighbour-selection heuristic and by memory latency once the file outgrows the CPU caches. Query performance is not affected by it. +Index build: 1,631 s (613 inserts/s), producing a 1,324 MiB file, with `indexSeed` pinned so the run can be reproduced. The previous graph, built with the full O(M0²) neighbour heuristic on every back-link, took 2,279 s on the same day and machine and scored 85.9 / 93.7 / 97.8 / 99.4 / 99.8 / 99.9 % recall@10 on the same rows; the incremental heuristic ([design doc](docs/design-insert-prune.md)) trades up to 1.5 points of recall at the narrowest beam, and nothing from efSearch 160 up, for the faster build. Query throughput of the two graphs is identical within run-to-run noise (±5 %). Build is still single-threaded and memory-bound once the file outgrows the CPU caches. A single recall figure would be misleading, because any ANN index reaches 99% by widening the beam until it has effectively scanned everything. The honest unit is the whole curve, so pick the row that matches your latency budget. @@ -319,7 +319,7 @@ Planned cloud work is tracked in design documents and the roadmap below. - **Full Native AOT support for the typed client** — Remove or replace reflection, expression compilation, and reflection-based JSON paths. - **ProjectReference analyzer flow for source generation** — Ensure the `[QvecIndexed]` generator is available when consuming `Qvec.Core.Client` through project references. - **Published benchmark methodology** — ✅ Done. `benchmarks/Qvec.Benchmarks` measures recall vs. QPS against the TexMex SIFT/GIST corpora and their published ground truth. -- **Faster index construction** — Partly done: removing marshalling, pool and allocation overhead from the insert path took SIFT-1M from 242 to 362 inserts/s and doubled query throughput. What remains is algorithmic (the O(M0²) neighbour heuristic) and memory-bound; parallel construction and multi-accumulator SIMD kernels are the next candidates. +- **Faster index construction** — Partly done. Removing marshalling, pool and allocation overhead took SIFT-1M from 242 to 362 inserts/s; replacing the O(M0²) re-run of the neighbour heuristic on every full back-link with an incremental O(M0) update ([design doc](docs/design-insert-prune.md)) took Cohere 100K (768 dims) from 59 to 170 inserts/s. Insert is still single-threaded; parallel construction is the next lever. - **Multi-vector support** — Store and search multiple embeddings, such as image + text, for one logical entry. ## License diff --git a/benchmarks/README.md b/benchmarks/README.md index 9165615..c63863f 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -74,6 +74,29 @@ VectorDBBench's index build is parallel, while Qvec's insert path is single-thre build time is not comparable at all — it is the honest measurement of where Qvec stands, not a like-for-like number. +### Cohere 100K, measured + +`--dataset cohere100k --k 100`, `maxNeighbors = 32`, 12 logical cores, Windows 11. Build is +single-threaded; queries at `--concurrency 12`. + +| mode | build | inserts/s | file | efSearch | recall@100 | QPS 1 thread | QPS 12 threads | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| float | 589 s | 170 | 382 MiB | 100 | 97.6 % | 381 | 1,652 | +| float | | | | 180 | 99.0 % | 263 | 1,097 | +| int8 | 672 s | 149 | 164 MiB | 100 | 95.7 % | 549 | — | +| int8 | | | | 180 | 96.6 % | 395 | — | +| int8 | | | | 300 | 96.8 % | 308 | — | + +The float row is the incremental-prune build ([design doc](../docs/design-insert-prune.md)). +Before it the same build took 1,696 s (59 inserts/s) and reached 97.9 % / 99.3 % at efSearch +100 / 180 — the graph is not byte-identical, so recall is re-measured rather than assumed. +Absolute QPS on this laptop varies by up to 40 % between sessions (the same index file gave +585 and 347 QPS single-threaded on two different days), so compare QPS only within one table +measured back to back; the design doc does that for old versus new graph. The int8 row was +built with the old prune and shows the other open item plainly: without rescoring on the +floats, int8 recall@100 plateaus at 96.8 % on Cohere, which is why Zvec's published figures +use int8 *with* a refiner. + ## Metric SIFT and GIST ground truth is **Euclidean**, Cohere is **Cosine**. The benchmark defaults to the diff --git a/docs/design-insert-prune.md b/docs/design-insert-prune.md new file mode 100644 index 0000000..a205541 --- /dev/null +++ b/docs/design-insert-prune.md @@ -0,0 +1,122 @@ +# Design: incremental neighbour pruning on insert + +## Problem + +Every insert into the HNSW graph creates up to M0 = 2·M back-links on layer 0: each of the +new node's neighbours gets the new node appended to its own list. Once the graph has warmed up +that list is almost always full, and the original code handled a full list by re-running the +complete selection heuristic (Malkov & Yashunin, algorithm 4) over all M0 + 1 candidates: + +1. recompute the owner's similarity to every existing neighbour (M0 distances), +2. sort, +3. for each candidate in order, compare it against every already-selected candidate + (up to M0² / 2 distances). + +With M = 32 that is ~2,000 distance computations per full back-link and ~130,000 per insert. +Each `StoredSimilarity` reads two vectors from the memory-mapped file, so at 768 dimensions +(3 KiB per vector) an insert moved roughly 400 MB through the memory hierarchy. Profiling the +Cohere 100K build with `dotnet-trace` put 66 % of insert time in `PruneNeighbors` and gave +**59 inserts/s**, 1,696 s for 100K vectors. + +## Change + +`AddNeighborConnection` now applies the heuristic incrementally, the way Lucene's HNSW graph +builder does in `findWorstNonDiverse`: + +1. Recompute the owner's similarity to the existing neighbours and the new node (M0 + 1 + distances — unavoidable because the graph section stores ids only), and sort descending. +2. Walk the candidates from farthest to nearest. A candidate is *non-diverse* if it is closer + to some nearer candidate than to the owner. Only the new node is *unchecked*, so: + - an existing neighbour needs to be compared against the new node only (1 distance), and + only if the new node is nearer than it; + - the new node itself is compared against every candidate nearer than it (≤ M0 distances). +3. Evict the first non-diverse candidate found. If that is the new node, the stored list is + left untouched. + +Typical cost is O(M0) distances instead of O(M0²). + +### The fallback + +Lucene can stop there and evict the farthest candidate when nothing is non-diverse, because +its lists only ever contain mutually diverse nodes. Qvec's lists do not: `SelectNeighborsHeuristic` +tops an under-filled result up with the best discarded candidates (`keepPrunedConnections`), +so a full list can hold neighbours that already fail the diversity test against each other. + +Evicting the farthest candidate in that situation drops a diverse long-range link in favour of +a redundant short one. On a 200-vector Euclidean test set with widely varying magnitudes that +was enough to leave seven nodes with in-degree zero — unreachable from the entry point — and +fail `Search_WithEuclidean_ReturnsAnExactlyStoredVectorFirst`. + +So when the incremental walk finds nothing, the code falls back to the full heuristic +(`PruneNeighbors`) over the M0 + 1 candidates, which is exactly the old behaviour and does +tell the topped-up neighbours apart. Instrumented on clustered vectors the fallback fires in +0.1 % (M = 32, 128 dims) to 0.7 % (M = 16, 64 dims) of full back-links, so it costs almost +nothing and keeps the graph connected in the cases the incremental rule cannot judge. + +### Also + +`SelectNeighborsHeuristic` no longer uses LINQ (`Where` / `OrderByDescending` / `ToArray`); a +stable index sort replaces it with identical ordering, so the forward selection produces the +same graph as before. + +## What is not identical + +The graph is **not** byte-identical to the one the old code built. The old full re-run evicted +the worst candidate that was non-diverse against the *selected set*; the incremental rule +evicts the worst candidate that is non-diverse against the *new node*. Both are legitimate +readings of the heuristic, but they can pick different victims, so recall had to be re-measured +rather than proved unchanged. + +## Measurements + +Same machine (12 logical cores, Windows 11), same seeds, `maxNeighbors = 32`. + +| dataset | build before | build after | inserts/s before → after | +| --- | ---: | ---: | ---: | +| siftsmall (10K × 128) | ~7–10 s | 4.0 s | ~1,000–1,500 → 2,485 | +| Cohere 100K (768, cosine) | 1,696 s | 589 s | 59 → 170 (2.9×) | +| SIFT-1M (128, euclidean), same day | 2,279 s | 1,631 s | 439 → 613 (1.4×) | + +The gain is largest where a distance computation is most expensive: at 768 dimensions the +O(M0²) pass was two thirds of the insert; at 128 dimensions, cache-resident, it was a smaller +share and the search phase dominates. + +Recall, Cohere 100K, k = 100, both index files measured back to back with `--reuse-index`: + +| efSearch | recall@100 before | recall@100 after | QPS before | QPS after | +| ---: | ---: | ---: | ---: | ---: | +| 100 | 97.89 % | 97.60 % | 347 | 381 | +| 180 | 99.29 % | 99.03 % | 247 | 263 | + +Recall, SIFT-1M, k = 10, both graphs rebuilt the same day and measured back to back twice: + +| efSearch | recall@10 before | recall@10 after | Δ | QPS before / after | +| ---: | ---: | ---: | ---: | ---: | +| 10 | 85.94 % | 84.44 % | −1.50 | 6,679–7,016 / 6,509–6,734 | +| 20 | 93.72 % | 92.48 % | −1.24 | 3,951–4,101 / 4,252–4,341 | +| 40 | 97.81 % | 97.03 % | −0.78 | 2,556–2,644 / 2,561–2,624 | +| 80 | 99.35 % | 99.05 % | −0.30 | 1,488–1,573 / 1,456–1,576 | +| 160 | 99.82 % | 99.73 % | −0.09 | 831–853 / 824–863 | +| 320 | 99.91 % | 99.90 % | −0.01 | 470–475 / 484–498 | + +So the new graph is slightly less well connected at the narrowest beams — up to 1.5 points on +SIFT-1M, 0.3 on Cohere — and indistinguishable from efSearch 160 up. Query throughput at a +given efSearch is the same within run-to-run noise on both datasets. The recall/QPS frontier +moves by roughly one efSearch step at the low end; a caller who needs the old recall at +efSearch 10 gets it at efSearch 20 for ~35 % fewer QPS, and a caller at efSearch ≥ 80 sees no +difference. siftsmall recall is unchanged (100 % recall@10 from efSearch 100). + +## Tests + +`NeighborDiversityTests` pins the required *outcome* of a full-list back-link on hand-checkable +2-D and 3-D Euclidean constructions — new node rejected when redundant, the neighbour it makes +redundant evicted, farthest evicted when everyone is diverse — plus a well-formedness sweep +(no duplicates, no self-links, valid ids, no gaps) over clustered data under all three +distance functions. The tests pass against both the old and the new implementation, which is +the point: they describe the heuristic, not the shortcut. + +## Next + +Insert is still single-threaded. With the per-insert work now dominated by the search phase +and M0 + 1 owner-score recomputations, parallel insert (per-node locks on neighbour lists, as +in hnswlib) is the next lever, followed by measuring Cohere 1M. diff --git a/tests/Qvec.Core.Tests/GraphFanoutTests.cs b/tests/Qvec.Core.Tests/GraphFanoutTests.cs index 616e5e0..03a12e6 100644 --- a/tests/Qvec.Core.Tests/GraphFanoutTests.cs +++ b/tests/Qvec.Core.Tests/GraphFanoutTests.cs @@ -1,4 +1,3 @@ -using System.Buffers.Binary; using Qvec.Core; using Qvec.Core.Format; using Xunit.Abstractions; @@ -53,7 +52,7 @@ public void LayerZero_AcceptsMoreNeighborsThanMaxNeighbors() db.AddEntry(Vec.Random(8, rng), $"{{\"i\":{i}}}"); } - var graph = ReadGraph(temp.Path); + var graph = RawGraph.Read(temp.Path); int maxDegreeAtZero = 0; for (int node = 0; node < 100; node++) maxDegreeAtZero = Math.Max(maxDegreeAtZero, graph.Degree(node, level: 0)); @@ -81,7 +80,7 @@ public void LayersAboveZero_NeverExceedMaxNeighbors() db.AddEntry(Vec.Random(8, rng), "{}"); } - var graph = ReadGraph(temp.Path); + var graph = RawGraph.Read(temp.Path); for (int node = 0; node < 200; node++) { for (int level = 1; level < maxLayers; level++) @@ -119,7 +118,7 @@ public void LayerZeroWrites_DoNotBleedIntoOtherLevelsOrNodes() } } - var graph = ReadGraph(temp.Path); + var graph = RawGraph.Read(temp.Path); // The top layer is reached with probability ~0 for 40 nodes, so it must be untouched. for (int node = 0; node < 40; node++) @@ -170,46 +169,4 @@ public void Recall_WithNarrowSearchWidth_BenefitsFromDoubledBaseLayer() // runs of the identical test, which made any floor this close to the measurement flake. Assert.True(worst >= 0.48, $"Worst recall@1 across seeds was {worst:P1}, below the 48.0% floor."); } - - private static RawGraph ReadGraph(string path) - { - byte[] bytes = File.ReadAllBytes(path); - var header = V4Header.Read(bytes.AsSpan(0, V4Header.HeaderSizeValue), bytes.LongLength); - return new RawGraph(bytes, header); - } - - /// - /// Reads neighbour slots straight out of the file, independently of the library, so the test - /// fails if the production offset arithmetic and the documented layout ever disagree. - /// - private sealed class RawGraph(byte[] bytes, V4Header header) - { - private readonly SectionExtent _graph = header.GetRequiredSection(V4SectionIds.Graph); - - private int NodeStride => (header.MaxLayers + 1) * header.MaxNeighbors; - - private int SlotsAt(int level) => level == 0 ? header.MaxNeighbors * 2 : header.MaxNeighbors; - - private int LevelStart(int level) => level == 0 ? 0 : (level + 1) * header.MaxNeighbors; - - public int[] Slots(int node, int level) - { - var result = new int[SlotsAt(level)]; - long start = _graph.Offset + ((long)node * NodeStride + LevelStart(level)) * sizeof(int); - for (int i = 0; i < result.Length; i++) - result[i] = BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(checked((int)(start + i * sizeof(int))), sizeof(int))); - return result; - } - - public int Degree(int node, int level) - { - int degree = 0; - foreach (int slot in Slots(node, level)) - { - if (slot == -1) break; - degree++; - } - return degree; - } - } } diff --git a/tests/Qvec.Core.Tests/NeighborDiversityTests.cs b/tests/Qvec.Core.Tests/NeighborDiversityTests.cs new file mode 100644 index 0000000..90c4641 --- /dev/null +++ b/tests/Qvec.Core.Tests/NeighborDiversityTests.cs @@ -0,0 +1,130 @@ +using Qvec.Core; + +namespace Qvec.Core.Tests; + +/// +/// Pins what the neighbour-selection heuristic (Malkov & Yashunin, algorithm 4) must decide +/// when a back-link lands on a node whose neighbour list is already full. The vectors are +/// two-dimensional and Euclidean, with a single layer, so every expected outcome can be checked +/// by hand. The tests describe the required result, not how it is computed: the +/// incremental update must agree with a full re-run of the heuristic on these cases. +/// +public sealed class NeighborDiversityTests +{ + private const int M = 2; // M0 = 4 slots on layer 0 + private const int Owner = 0; // (0, 0), inserted first, so row 0 and entry point + + // Four neighbours at distance 10 from the owner in the four axis directions. Each is at + // least 14.1 from every other, so all four are diverse with respect to the owner and the + // owner's layer-0 list is exactly full after inserting them. + private static readonly float[][] Cross = + [ + [0f, 0f], + [10f, 0f], + [-10f, 0f], + [0f, 10f], + [0f, -10f], + ]; + + [Fact] + public void FullList_RejectsNewNodeThatIsCloserToAnExistingNeighbourThanToOwner() + { + using var temp = new TempDb(); + int[] before; + using (var db = temp.Open(dim: 2, max: 16, maxNeighbors: M, maxLayers: 1, distance: DistanceFunction.Euclidean)) + { + foreach (var v in Cross) db.AddEntry(v, "{}"); + // Row 5: 0.5 from A = (10, 0) but 10.5 from the owner. Linking it to the owner adds + // nothing that A does not already provide, so the owner must keep its four neighbours. + db.AddEntry([10.5f, 0f], "{}"); + } + + var graph = RawGraph.Read(temp.Path); + before = [1, 2, 3, 4]; + + Assert.Contains(Owner, graph.Neighbors(5, level: 0)); // the back-link was attempted + Assert.Equal(before, graph.Neighbors(Owner, level: 0).Order()); + } + + [Fact] + public void FullList_EvictsTheExistingNeighbourMadeRedundantByACloserNewNode() + { + using var temp = new TempDb(); + using (var db = temp.Open(dim: 2, max: 16, maxNeighbors: M, maxLayers: 1, distance: DistanceFunction.Euclidean)) + { + foreach (var v in Cross) db.AddEntry(v, "{}"); + // Row 5: 4.0 from the owner, on the way to A = (10, 0). A is now closer to the new + // node (6.0) than to the owner (10), so A is the one that should go; B, C and D stay. + db.AddEntry([4f, 0.1f], "{}"); + } + + var graph = RawGraph.Read(temp.Path); + + Assert.Equal(new[] { 2, 3, 4, 5 }, graph.Neighbors(Owner, level: 0).Order()); + } + + [Fact] + public void FullList_EvictsTheFarthestNeighbourWhenEveryoneIsDiverse() + { + using var temp = new TempDb(); + using (var db = temp.Open(dim: 3, max: 16, maxNeighbors: M, maxLayers: 1, distance: DistanceFunction.Euclidean)) + { + db.AddEntry([0f, 0f, 0f], "{}"); + // Distances 10, 11, 12, 13 from the owner along ±x and ±y; all mutually diverse. + db.AddEntry([10f, 0f, 0f], "{}"); + db.AddEntry([0f, 11f, 0f], "{}"); + db.AddEntry([-12f, 0f, 0f], "{}"); + db.AddEntry([0f, -13f, 0f], "{}"); + // Row 5 at distance 3 along z, perpendicular to every existing neighbour, so it is + // diverse and makes nobody redundant. The plain fallback applies and the farthest + // existing neighbour (row 4) is dropped. + db.AddEntry([0f, 0f, 3f], "{}"); + } + + var graph = RawGraph.Read(temp.Path); + + Assert.Equal(new[] { 1, 2, 3, 5 }, graph.Neighbors(Owner, level: 0).Order()); + } + + /// + /// Structural invariants that must hold no matter how the replacement is decided: every + /// slot is either empty or a valid row, nothing appears twice, no node links to itself and + /// the list never exceeds its capacity. + /// + [Theory] + [InlineData(DistanceFunction.Euclidean, 31)] + [InlineData(DistanceFunction.Cosine, 32)] + [InlineData(DistanceFunction.DotProduct, 33)] + public void NeighbourLists_StayWellFormedUnderHeavyBackLinkPressure(DistanceFunction distance, int seed) + { + const int n = 600; + const int maxLayers = 3; + + using var temp = new TempDb(); + using (var db = temp.Open(dim: 8, max: n, maxNeighbors: 3, maxLayers: maxLayers, distance: distance, indexSeed: seed)) + { + var clusters = new Vec.ClusteredVectors(dim: 8, clusterCount: 6, seed: seed); + var rng = new Random(seed); + for (int i = 0; i < n; i++) db.AddEntry(clusters.Next(8, rng), "{}"); + } + + var graph = RawGraph.Read(temp.Path); + for (int node = 0; node < n; node++) + { + for (int level = 0; level < maxLayers; level++) + { + var slots = graph.Slots(node, level); + bool seenEmpty = false; + var seen = new HashSet(); + foreach (int slot in slots) + { + if (slot == -1) { seenEmpty = true; continue; } + Assert.False(seenEmpty, $"Node {node} level {level} has a neighbour after an empty slot."); + Assert.InRange(slot, 0, n - 1); + Assert.NotEqual(node, slot); + Assert.True(seen.Add(slot), $"Node {node} level {level} lists neighbour {slot} twice."); + } + } + } + } +} diff --git a/tests/Qvec.Core.Tests/RawGraph.cs b/tests/Qvec.Core.Tests/RawGraph.cs new file mode 100644 index 0000000..1b41c59 --- /dev/null +++ b/tests/Qvec.Core.Tests/RawGraph.cs @@ -0,0 +1,54 @@ +using System.Buffers.Binary; +using Qvec.Core.Format; + +namespace Qvec.Core.Tests; + +/// +/// Reads neighbour slots straight out of a closed .qvec file, independently of the library, so +/// tests fail if the production offset arithmetic and the documented layout ever disagree. +/// +internal sealed class RawGraph +{ + private readonly byte[] _bytes; + private readonly V4Header _header; + private readonly SectionExtent _graph; + + private RawGraph(byte[] bytes, V4Header header) + { + _bytes = bytes; + _header = header; + _graph = header.GetRequiredSection(V4SectionIds.Graph); + } + + public static RawGraph Read(string path) + { + byte[] bytes = File.ReadAllBytes(path); + var header = V4Header.Read(bytes.AsSpan(0, V4Header.HeaderSizeValue), bytes.LongLength); + return new RawGraph(bytes, header); + } + + private int NodeStride => (_header.MaxLayers + 1) * _header.MaxNeighbors; + + private int SlotsAt(int level) => level == 0 ? _header.MaxNeighbors * 2 : _header.MaxNeighbors; + + private int LevelStart(int level) => level == 0 ? 0 : (level + 1) * _header.MaxNeighbors; + + public int[] Slots(int node, int level) + { + var result = new int[SlotsAt(level)]; + long start = _graph.Offset + ((long)node * NodeStride + LevelStart(level)) * sizeof(int); + for (int i = 0; i < result.Length; i++) + result[i] = BinaryPrimitives.ReadInt32LittleEndian(_bytes.AsSpan(checked((int)(start + i * sizeof(int))), sizeof(int))); + return result; + } + + /// Neighbour ids in slot order, stopping at the first empty slot. + public int[] Neighbors(int node, int level) + { + var slots = Slots(node, level); + int degree = Array.IndexOf(slots, -1); + return degree < 0 ? slots : slots[..degree]; + } + + public int Degree(int node, int level) => Neighbors(node, level).Length; +}