Skip to content

GraphSearcher Assertion can fail for dot product when all candidates score negative #713

Description

@MarkWolters

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.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions