See CNDB ticket https://github.com/riptano/cndb/issues/18237
Astra test testTrueDotproduct fails when we enable by default hierarchy for vector indexes. Logs show
java.lang.AssertionError: 0
at io.github.jbellis.jvector.graph.GraphSearcher.internalSearch(GraphSearcher.java:263)
at io.github.jbellis.jvector.graph.GraphSearcher.search(GraphSearcher.java:228)
at org.apache.cassandra.index.sai.disk.vector.CassandraDiskAnn.search(CassandraDiskAnn.java:264)
at org.apache.cassandra.index.sai.disk.v2.V2VectorIndexSearcher.searchInternal(V2VectorIndexSearcher.java:203)
at org.apache.cassandra.index.sai.disk.v2.V2VectorIndexSearcher.orderBy(V2VectorIndexSearcher.java:173)
at org.apache.cassandra.index.sai.disk.v1.Segment.orderBy(Segment.java:160)
at org.apache.cassandra.index.sai.disk.v1.V1SearchableIndex.orderBy(V1SearchableIndex.java:224)
at org.apache.cassandra.index.sai.SSTableIndex.orderBy(SSTableIndex.java:292)
at org.apache.cassandra.index.sai.plan.QueryController.lambda$getTopKRows$9(QueryController.java:620)
at org.apache.cassandra.index.sai.plan.QueryController.searchSSTables(QueryController.java:760)
at org.apache.cassandra.index.sai.plan.QueryController.searchTopKRows(QueryController.java:728)
at org.apache.cassandra.index.sai.plan.QueryController.getTopKRows(QueryController.java:621)
at org.apache.cassandra.index.sai.plan.QueryController.getTopKRows(QueryController.java:101)
at org.apache.cassandra.index.sai.plan.Plan$ScoredIndexScan.execute(Plan.java:1578)
at org.apache.cassandra.index.sai.plan.QueryController.buildIterator(QueryController.java:452)
at org.apache.cassandra.index.sai.plan.StorageAttachedIndexSearcher.search(StorageAttachedIndexSearcher.java:153)
at org.apache.cassandra.db.ReadCommand.searchStorage(ReadCommand.java:574)
at org.apache.cassandra.db.ReadCommand.executeLocally(ReadCommand.java:460)
at org.apache.cassandra.db.ReadCommandVerbHandler.doVerb(ReadCommandVerbHandler.java:74)
at org.apache.cassandra.net.InboundSink.lambda$new$0(InboundSink.java:80)
at org.apache.cassandra.net.InboundSink.accept(InboundSink.java:100)
at org.apache.cassandra.net.InboundSink.accept(InboundSink.java:47)
at org.apache.cassandra.net.InboundMessageHandler$ProcessMessage.run(InboundMessageHandler.java:440)
at org.apache.cassandra.concurrent.Stage.lambda$withTimeMeasurement$13(Stage.java:446)
at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:515)
at org.apache.cassandra.concurrent.AbstractLocalAwareExecutorService$FutureTask.run(AbstractLocalAwareExecutorService.java:165)
at org.apache.cassandra.concurrent.AbstractLocalAwareExecutorService$LocalSessionFutureTask.run(AbstractLocalAwareExecutorService.java:137)
at org.apache.cassandra.concurrent.SEPWorker.run(SEPWorker.java:119)
at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
at java.base/java.lang.Thread.run(Thread.java:829)
ERROR [ReadStage-1] 2026-06-18 14:50:52,060 AbstractLocalAwareExecutorService.java:169 - Uncaught exception on thread Thread[ReadStage-1,5,main]
java.lang.RuntimeException: java.lang.AssertionError: 0
at org.apache.cassandra.utils.Throwables.unchecked(Throwables.java:309)
at org.apache.cassandra.utils.Throwables.cleaned(Throwables.java:328)
at org.apache.cassandra.index.sai.plan.QueryController.searchSSTables(QueryController.java:772)
at org.apache.cassandra.index.sai.plan.QueryController.searchTopKRows(QueryController.java:728)
at org.apache.cassandra.index.sai.plan.QueryController.getTopKRows(QueryController.java:621)
at org.apache.cassandra.index.sai.plan.QueryController.getTopKRows(QueryController.java:101)
at org.apache.cassandra.index.sai.plan.Plan$ScoredIndexScan.execute(Plan.java:1578)
at org.apache.cassandra.index.sai.plan.QueryController.buildIterator(QueryController.java:452)
at org.apache.cassandra.index.sai.plan.StorageAttachedIndexSearcher.search(StorageAttachedIndexSearcher.java:153)
at org.apache.cassandra.db.ReadCommand.searchStorage(ReadCommand.java:574)
at org.apache.cassandra.db.ReadCommand.executeLocally(ReadCommand.java:460)
at org.apache.cassandra.db.ReadCommandVerbHandler.doVerb(ReadCommandVerbHandler.java:74)
at org.apache.cassandra.net.InboundSink.lambda$new$0(InboundSink.java:80)
at org.apache.cassandra.net.InboundSink.accept(InboundSink.java:100)
at org.apache.cassandra.net.InboundSink.accept(InboundSink.java:47)
at org.apache.cassandra.net.InboundMessageHandler$ProcessMessage.run(InboundMessageHandler.java:440)
at org.apache.cassandra.concurrent.Stage.lambda$withTimeMeasurement$13(Stage.java:446)
at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:515)
at org.apache.cassandra.concurrent.AbstractLocalAwareExecutorService$FutureTask.run(AbstractLocalAwareExecutorService.java:165)
at org.apache.cassandra.concurrent.AbstractLocalAwareExecutorService$LocalSessionFutureTask.run(AbstractLocalAwareExecutorService.java:137)
at org.apache.cassandra.concurrent.SEPWorker.run(SEPWorker.java:119)
at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
at java.base/java.lang.Thread.run(Thread.java:829)
Caused by: java.lang.AssertionError: 0
at io.github.jbellis.jvector.graph.GraphSearcher.internalSearch(GraphSearcher.java:263)
at io.github.jbellis.jvector.graph.GraphSearcher.search(GraphSearcher.java:228)
at org.apache.cassandra.index.sai.disk.vector.CassandraDiskAnn.search(CassandraDiskAnn.java:264)
at org.apache.cassandra.index.sai.disk.v2.V2VectorIndexSearcher.searchInternal(V2VectorIndexSearcher.java:203)
at org.apache.cassandra.index.sai.disk.v2.V2VectorIndexSearcher.orderBy(V2VectorIndexSearcher.java:173)
at org.apache.cassandra.index.sai.disk.v1.Segment.orderBy(Segment.java:160)
at org.apache.cassandra.index.sai.disk.v1.V1SearchableIndex.orderBy(V1SearchableIndex.java:224)
at org.apache.cassandra.index.sai.SSTableIndex.orderBy(SSTableIndex.java:292)
at org.apache.cassandra.index.sai.plan.QueryController.lambda$getTopKRows$9(QueryController.java:620)
at org.apache.cassandra.index.sai.plan.QueryController.searchSSTables(QueryController.java:760)
... 20 common frames omitted
The bug: internalSearch's hierarchy-descent loop hardcodes a 0.0f threshold that implicitly assumes similarity scores are always ≥ 0:
// GraphSearcher.java:260-265
for (int lvl = entry.level; lvl > 0; lvl--) {
searchOneLayer(scoreProvider, 1, 0.0f, lvl, Bits.ALL);
assert approximateResults.size() == 1 : approximateResults.size(); // <-- line 263
setEntryPointsFromPreviousLayer();
}
Inside searchOneLayer, that threshold is used as a hard filter (GraphSearcher.java:409):
if (acceptOrdsThisLayer.get(topCandidateNode) && topCandidateScore >= threshold) {
addTopCandidate(topCandidateNode, topCandidateScore, rerankK);
}
Any candidate whose score is < 0 is silently dropped, never added to approximateResults. If every candidate reachable from the entry point within that hierarchy layer happens to score negative for a given query, approximateResults stays empty and the size() == 1 assert fires — exactly the AssertionError: 0 in the stack trace.
Why 0.0f is unsafe: it's only a valid "accept everything" sentinel for similarity functions that are mathematically bounded to (0, 1] — COSINE and EUCLIDEAN are. DOT_PRODUCT (VectorSimilarityFunction.java:51-56, (1 + dotProduct(v1,v2)) / 2) is not bounded unless callers pre-normalize vectors to unit length — the class's own javadoc says as much: "Using dot product with vectors that are not normalized can result in errors."
VectorDotProductWithLengthTest.testTrueDotproduct (datastax/cassandra) deliberately uses non-unit 2D vectors with components in [-100, 100]:
// these need to NOT be unit vectors to test the difference between DP and cosine
return new float[] { R.nextFloatBetween(-100, 100), R.nextFloatBetween(-100, 100) };
With that range, raw dot products routinely fall well outside [-1, 1], so (1+dot)/2 is often strongly negative — exactly the condition that trips the filter above.
Why it's hierarchy-specific (matches the reporter's observation that disabling hierarchy makes it pass): layer-0 search uses a wide candidate pool and a large rerankK, so the genuinely-best (positively-scored, well-aligned) neighbors are found regardless — it silently drops negative-scored candidates too, but rarely all of them. Upper hierarchy layers search with rerankK=1 from a single fixed entry point — a much narrower search where it's entirely plausible that every candidate examined at that layer scores negative relative to a given query, especially in a small (2000-vector, 2-D) dataset with widely-ranged random vectors.
See CNDB ticket https://github.com/riptano/cndb/issues/18237
Astra test testTrueDotproduct fails when we enable by default hierarchy for vector indexes. Logs show
The bug: internalSearch's hierarchy-descent loop hardcodes a 0.0f threshold that implicitly assumes similarity scores are always ≥ 0:
Inside searchOneLayer, that threshold is used as a hard filter (GraphSearcher.java:409):
Any candidate whose score is < 0 is silently dropped, never added to approximateResults. If every candidate reachable from the entry point within that hierarchy layer happens to score negative for a given query, approximateResults stays empty and the size() == 1 assert fires — exactly the AssertionError: 0 in the stack trace.
Why 0.0f is unsafe: it's only a valid "accept everything" sentinel for similarity functions that are mathematically bounded to (0, 1] — COSINE and EUCLIDEAN are. DOT_PRODUCT (VectorSimilarityFunction.java:51-56, (1 + dotProduct(v1,v2)) / 2) is not bounded unless callers pre-normalize vectors to unit length — the class's own javadoc says as much: "Using dot product with vectors that are not normalized can result in errors."
VectorDotProductWithLengthTest.testTrueDotproduct (datastax/cassandra) deliberately uses non-unit 2D vectors with components in [-100, 100]:
With that range, raw dot products routinely fall well outside [-1, 1], so (1+dot)/2 is often strongly negative — exactly the condition that trips the filter above.
Why it's hierarchy-specific (matches the reporter's observation that disabling hierarchy makes it pass): layer-0 search uses a wide candidate pool and a large rerankK, so the genuinely-best (positively-scored, well-aligned) neighbors are found regardless — it silently drops negative-scored candidates too, but rarely all of them. Upper hierarchy layers search with rerankK=1 from a single fixed entry point — a much narrower search where it's entirely plausible that every candidate examined at that layer scores negative relative to a given query, especially in a small (2000-vector, 2-D) dataset with widely-ranged random vectors.