Skip to content

Commit 8ed7e98

Browse files
claudehyperpolymath
authored andcommitted
feat(search): real HNSW default + real relevance scores end-to-end
Two correctness fixes that make vector search genuinely real at the product boundary (Week-2 truth sweep): - HNSW is now the default vector backend. verisim-vector shipped a complete ~670-LOC HNSW (Malkov-Yashunin) that was built but orphaned: the API's in-memory ConcreteOctadStore used BruteForceVectorStore, so the documented "HNSW similarity search" was aspirational. Swapped the type alias + construction to HnswVectorStore. (Persistent/redb vector backend stays brute-force for now — separate follow-on.) - Search scores are now real, not fabricated. text/vector/similar-query handlers reported score = 1.0 - 0.1*i (a synthetic rank sequence), discarding the BM25 (Tantivy) and cosine (vector index) scores the stores already compute. Added additive OctadStore::search_text_scored / search_similar_scored (-> Vec<(Octad, f32)>); the unscored methods become thin wrappers, so gRPC/VQL callers are unchanged. The three handlers surface the real scores. Test test_vector_search_returns_real_cosine_scores pins the distinction: the second hit carries its true ~0.994 cosine, which the old synthetic scheme would have reported as exactly 0.9. KNOWN-ISSUES #9 updated (HNSW now the default), new #27 records the score-fabrication fix. Workspace: 628 passed / 0 failed; clippy clean under -D warnings. Closes Week-2 items 6-7 of docs/reviews/2026-06-11-deep-review-and-july-1-plan.adoc https://claude.ai/code/session_01E8BpV19yxhf67UrCrvkfTr
1 parent 7db609b commit 8ed7e98

4 files changed

Lines changed: 147 additions & 28 deletions

File tree

KNOWN-ISSUES.adoc

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,13 +107,13 @@ Added `rescript.json` for build configuration.
107107

108108
**Impact:** None (resolved). Documented here for audit trail completeness.
109109

110-
=== 9. HNSW Implementation Is Real
110+
=== 9. HNSW Implementation Is Real — and now the default
111111

112-
**Location:** `rust-core/verisim-vector/src/hnsw.rs`
112+
**Location:** `rust-core/verisim-vector/src/hnsw.rs`, `rust-core/verisim-api/src/lib.rs`
113113

114-
**Current state:** The HNSW (Hierarchical Navigable Small World) implementation in `verisim-vector` is a genuine, functional implementation at approximately 670 lines of Rust. A previous audit incorrectly claimed this was brute-force search. This is **not** an issue -- it is a correction of a previous mischaracterization.
114+
**Current state:** The HNSW (Hierarchical Navigable Small World) implementation in `verisim-vector` is a genuine, functional implementation at approximately 670 lines of Rust (real Malkov–Yashunin layered graph, configurable `M`/`ef_construction`/`ef_search`, cosine/euclidean/dot metrics).
115115

116-
**Impact:** None (this is a positive clarification). The vector modality store uses a real approximate nearest-neighbor algorithm, not a naive linear scan.
116+
**Resolved (wiring):** 2026-06-13. Until now this module was *built but orphaned* — the API's `ConcreteOctadStore` used `BruteForceVectorStore` (exact O(n) linear scan), so the "HNSW similarity search" claim was aspirational at the product boundary. The in-memory `ConcreteOctadStore` now uses `HnswVectorStore` as its vector backend, so the default build performs real approximate nearest-neighbor search. (The persistent/redb vector backend remains brute-force over an mmap'd store — a separate follow-on.)
117117

118118
**Note:** This entry exists to prevent future audits from repeating the same incorrect claim.
119119

@@ -267,3 +267,11 @@ Added `rescript.json` for build configuration.
267267
* `temporal_consistency_drift` works at 1-second timestamp resolution, so two writes within the same second register as a (mild) conflict signal.
268268

269269
**Original issue:** The product's core claim — drift detection — ran on no real data anywhere in the system.
270+
271+
=== 27. Search Scores Were Fabricated From Rank — ✅ RESOLVED
272+
273+
**Location:** `rust-core/verisim-api/src/lib.rs`, `rust-core/verisim-octad/src/{lib,store}.rs`
274+
275+
**Resolved:** 2026-06-13. The text, vector, and similar-query search endpoints all reported `score: 1.0 - (i as f32 * 0.1)` — a synthetic sequence derived from result position, discarding the real relevance scores the modality stores already compute (Tantivy BM25 for documents, cosine similarity for vectors). The octad store now exposes `search_text_scored` / `search_similar_scored` (returning `Vec<(Octad, f32)>`); the three handlers surface those real scores. Test `test_vector_search_returns_real_cosine_scores` pins the distinction (the second hit reports its true ~0.994 cosine, not synthetic 0.9).
276+
277+
**Original issue:** API search responses carried fabricated scores, so any client ranking or thresholding on `score` was meaningless.

rust-core/verisim-api/src/lib.rs

Lines changed: 89 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ use verisim_tensor::InMemoryTensorStore;
7878
#[cfg(feature = "persistent")]
7979
use verisim_tensor::RedbTensorStore;
8080
#[cfg(not(feature = "persistent"))]
81-
use verisim_vector::BruteForceVectorStore;
81+
use verisim_vector::HnswVectorStore;
8282
use verisim_vector::DistanceMetric;
8383
#[cfg(feature = "persistent")]
8484
use verisim_vector::RedbVectorStore;
@@ -91,7 +91,7 @@ use verisim_vector::RedbVectorStore;
9191
#[cfg(not(feature = "persistent"))]
9292
pub type ConcreteOctadStore = InMemoryOctadStore<
9393
SimpleGraphStore,
94-
BruteForceVectorStore,
94+
HnswVectorStore,
9595
TantivyDocumentStore,
9696
InMemoryTensorStore,
9797
InMemorySemanticStore,
@@ -580,7 +580,7 @@ impl AppState {
580580
.map_err(|e| ApiError::Internal(e.to_string()))?,
581581
);
582582
#[cfg(not(feature = "persistent"))]
583-
let vector = Arc::new(BruteForceVectorStore::new(
583+
let vector = Arc::new(HnswVectorStore::with_defaults(
584584
config.vector_dimension,
585585
DistanceMetric::Cosine,
586586
));
@@ -1153,18 +1153,17 @@ async fn text_search_handler(
11531153
};
11541154
let limit = validate_limit(query.limit.unwrap_or(10));
11551155

1156-
let octads = state
1156+
let scored = state
11571157
.octad_store
1158-
.search_text(&q, limit)
1158+
.search_text_scored(&q, limit)
11591159
.await
11601160
.map_err(|e| ApiError::Internal(e.to_string()))?;
11611161

1162-
let results: Vec<SearchResultResponse> = octads
1162+
let results: Vec<SearchResultResponse> = scored
11631163
.iter()
1164-
.enumerate()
1165-
.map(|(i, h)| SearchResultResponse {
1164+
.map(|(h, score)| SearchResultResponse {
11661165
id: h.id.to_string(),
1167-
score: 1.0 - (i as f32 * 0.1), // Approximate score based on ranking
1166+
score: *score, // Tantivy BM25 relevance score
11681167
title: h.document.as_ref().map(|d| d.title.clone()),
11691168
})
11701169
.collect();
@@ -1189,18 +1188,17 @@ async fn vector_search_handler(
11891188
}
11901189
validate_vector(&request.vector)?;
11911190

1192-
let octads = state
1191+
let scored = state
11931192
.octad_store
1194-
.search_similar(&request.vector, k)
1193+
.search_similar_scored(&request.vector, k)
11951194
.await
11961195
.map_err(|e| ApiError::Internal(e.to_string()))?;
11971196

1198-
let results: Vec<SearchResultResponse> = octads
1197+
let results: Vec<SearchResultResponse> = scored
11991198
.iter()
1200-
.enumerate()
1201-
.map(|(i, h)| SearchResultResponse {
1199+
.map(|(h, score)| SearchResultResponse {
12021200
id: h.id.to_string(),
1203-
score: 1.0 - (i as f32 * 0.1), // Approximate score based on ranking
1201+
score: *score, // cosine similarity from the vector index
12041202
title: h.document.as_ref().map(|d| d.title.clone()),
12051203
})
12061204
.collect();
@@ -1594,25 +1592,24 @@ async fn similar_queries_handler(
15941592
validate_vector(&request.vector)?;
15951593

15961594
// Search for similar octads (which includes query-octads)
1597-
let octads = state
1595+
let scored = state
15981596
.octad_store
1599-
.search_similar(&request.vector, k)
1597+
.search_similar_scored(&request.vector, k)
16001598
.await
16011599
.map_err(|e| ApiError::Internal(e.to_string()))?;
16021600

16031601
// Filter to only query octads (those with "vql_query" type in document fields)
1604-
let results: Vec<SearchResultResponse> = octads
1602+
let results: Vec<SearchResultResponse> = scored
16051603
.iter()
1606-
.filter(|h| {
1604+
.filter(|(h, _score)| {
16071605
h.document
16081606
.as_ref()
16091607
.map(|d| d.title.starts_with("VQL Query:"))
16101608
.unwrap_or(false)
16111609
})
1612-
.enumerate()
1613-
.map(|(i, h)| SearchResultResponse {
1610+
.map(|(h, score)| SearchResultResponse {
16141611
id: h.id.to_string(),
1615-
score: 1.0 - (i as f32 * 0.1),
1612+
score: *score, // cosine similarity from the vector index
16161613
title: h.document.as_ref().map(|d| d.title.clone()),
16171614
})
16181615
.collect();
@@ -2811,6 +2808,76 @@ mod tests {
28112808
assert_eq!(response.status(), StatusCode::OK);
28122809
}
28132810

2811+
/// Vector search must surface the index's real cosine scores, not a
2812+
/// synthetic rank sequence (the old `1.0 - 0.1*i`). The discriminator:
2813+
/// `[0.9, 0.1, 0.0]` is ~0.994 cosine to `[1,0,0]`, whereas the old
2814+
/// scheme would have reported the second hit as exactly 0.9.
2815+
#[tokio::test]
2816+
async fn test_vector_search_returns_real_cosine_scores() {
2817+
let state = create_test_state().await;
2818+
let app = build_router(state);
2819+
2820+
let make = |embedding: Vec<f32>, title: &str| OctadRequest {
2821+
title: Some(title.to_string()),
2822+
body: Some("body".to_string()),
2823+
embedding: Some(embedding),
2824+
types: None,
2825+
relationships: None,
2826+
tensor: None,
2827+
metadata: None,
2828+
provenance: None,
2829+
spatial: None,
2830+
};
2831+
2832+
post_octad(&app, &make(vec![1.0, 0.0, 0.0], "aligned")).await;
2833+
post_octad(&app, &make(vec![0.9, 0.1, 0.0], "near")).await;
2834+
post_octad(&app, &make(vec![0.0, 1.0, 0.0], "orthogonal")).await;
2835+
2836+
let request = VectorSearchRequest {
2837+
vector: vec![1.0, 0.0, 0.0],
2838+
k: Some(3),
2839+
};
2840+
let response = app
2841+
.oneshot(
2842+
Request::builder()
2843+
.method("POST")
2844+
.uri("/search/vector")
2845+
.header("content-type", "application/json")
2846+
.body(Body::from(serde_json::to_string(&request).unwrap()))
2847+
.unwrap(),
2848+
)
2849+
.await
2850+
.unwrap();
2851+
assert_eq!(response.status(), StatusCode::OK);
2852+
2853+
let body = axum::body::to_bytes(response.into_body(), 1024 * 1024)
2854+
.await
2855+
.unwrap();
2856+
let results: Vec<SearchResultResponse> = serde_json::from_slice(&body).unwrap();
2857+
assert!(results.len() >= 2, "expected at least two hits");
2858+
2859+
// Top hit is the identical-direction vector: cosine ~1.0.
2860+
assert!(
2861+
results[0].score > 0.99,
2862+
"top cosine score should be ~1.0, got {}",
2863+
results[0].score
2864+
);
2865+
// Second hit's real cosine is ~0.994; the old synthetic scheme would
2866+
// have reported exactly 0.9. This is the load-bearing assertion.
2867+
assert!(
2868+
results[1].score > 0.95,
2869+
"second hit must carry its real cosine (~0.994), not synthetic 0.9; got {}",
2870+
results[1].score
2871+
);
2872+
// Scores are non-increasing in relevance order.
2873+
for pair in results.windows(2) {
2874+
assert!(
2875+
pair[0].score >= pair[1].score,
2876+
"scores must be in descending relevance order"
2877+
);
2878+
}
2879+
}
2880+
28142881
#[tokio::test]
28152882
async fn test_drift_status() {
28162883
let state = create_test_state().await;

rust-core/verisim-octad/src/lib.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,9 +324,25 @@ pub trait OctadStore: Send + Sync {
324324
/// Search by vector similarity
325325
async fn search_similar(&self, embedding: &[f32], k: usize) -> Result<Vec<Octad>, OctadError>;
326326

327+
/// Search by vector similarity, returning each match with its modality
328+
/// score (cosine similarity for the default metric; higher is closer).
329+
async fn search_similar_scored(
330+
&self,
331+
embedding: &[f32],
332+
k: usize,
333+
) -> Result<Vec<(Octad, f32)>, OctadError>;
334+
327335
/// Search by document text
328336
async fn search_text(&self, query: &str, limit: usize) -> Result<Vec<Octad>, OctadError>;
329337

338+
/// Search by document text, returning each match with its relevance
339+
/// score (Tantivy BM25; higher is more relevant).
340+
async fn search_text_scored(
341+
&self,
342+
query: &str,
343+
limit: usize,
344+
) -> Result<Vec<(Octad, f32)>, OctadError>;
345+
330346
/// Query by graph relationship
331347
async fn query_related(&self, id: &OctadId, predicate: &str) -> Result<Vec<Octad>, OctadError>;
332348

rust-core/verisim-octad/src/store.rs

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1385,6 +1385,19 @@ where
13851385
}
13861386

13871387
async fn search_similar(&self, embedding: &[f32], k: usize) -> Result<Vec<Octad>, OctadError> {
1388+
Ok(self
1389+
.search_similar_scored(embedding, k)
1390+
.await?
1391+
.into_iter()
1392+
.map(|(octad, _score)| octad)
1393+
.collect())
1394+
}
1395+
1396+
async fn search_similar_scored(
1397+
&self,
1398+
embedding: &[f32],
1399+
k: usize,
1400+
) -> Result<Vec<(Octad, f32)>, OctadError> {
13881401
let results =
13891402
self.vector
13901403
.search(embedding, k)
@@ -1394,17 +1407,32 @@ where
13941407
message: e.to_string(),
13951408
})?;
13961409

1410+
// Preserve the vector store's relevance order and carry each score
1411+
// through to the caller (the API surfaces it; no fabricated ranking).
13971412
let mut octads = Vec::new();
13981413
for result in results {
13991414
if let Some(octad) = self.load_octad(&OctadId::new(&result.id)).await? {
1400-
octads.push(octad);
1415+
octads.push((octad, result.score));
14011416
}
14021417
}
14031418

14041419
Ok(octads)
14051420
}
14061421

14071422
async fn search_text(&self, query: &str, limit: usize) -> Result<Vec<Octad>, OctadError> {
1423+
Ok(self
1424+
.search_text_scored(query, limit)
1425+
.await?
1426+
.into_iter()
1427+
.map(|(octad, _score)| octad)
1428+
.collect())
1429+
}
1430+
1431+
async fn search_text_scored(
1432+
&self,
1433+
query: &str,
1434+
limit: usize,
1435+
) -> Result<Vec<(Octad, f32)>, OctadError> {
14081436
let results =
14091437
self.document
14101438
.search(query, limit)
@@ -1417,7 +1445,7 @@ where
14171445
let mut octads = Vec::new();
14181446
for result in results {
14191447
if let Some(octad) = self.load_octad(&OctadId::new(&result.id)).await? {
1420-
octads.push(octad);
1448+
octads.push((octad, result.score));
14211449
}
14221450
}
14231451

0 commit comments

Comments
 (0)