Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/fusion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,13 @@ pub fn rrf_fuse(lanes: &[(&str, &[RankedResult], f64)], k: usize) -> Vec<FusedRe
})
.collect();

// Sort by rrf_score descending
// Sort by rrf_score descending; tiebreak on file_path so equal scores
// rank deterministically (acc_map iteration order is random per process).
results.sort_by(|a, b| {
b.rrf_score
.partial_cmp(&a.rrf_score)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.file_path.cmp(&b.file_path))
});

// Normalize confidence as percentage of max score
Expand Down
6 changes: 5 additions & 1 deletion src/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,11 @@ pub fn graph_expand(
.into_iter()
.map(|(fid, (score, hop, seed))| (fid, score, hop, seed))
.collect();
results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
results.sort_by(|a, b| {
b.1.partial_cmp(&a.1)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.0.cmp(&b.0))
});
results.truncate(max_expansions);

// Convert to RankedResult
Expand Down
16 changes: 15 additions & 1 deletion src/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ pub fn search_with_intelligence(
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.file_path.cmp(&b.file_path))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve pass-one order for tied rerank scores

When the reranker assigns the same score to multiple candidates, including the existing unwrap_or(0.0) path when scoring fails, this tie-break turns the rerank lane into an alphabetical ranking. Because the rerank lane is fused with a nonzero weight, equal reranker scores can now promote alphabetically earlier files over candidates that were ranked higher by pass 1; the deterministic tie-break here should preserve the existing fused_pass1 candidate order instead of using file_path.

Useful? React with 👍 / 👎.

});
true
} else {
Expand Down Expand Up @@ -311,6 +312,7 @@ pub fn search_with_intelligence(
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.file_path.cmp(&b.file_path))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve base relevance for equal temporal scores

For temporal queries where several candidates fall inside the requested date range, temporal_score gives them identical scores, so this tie-break makes the temporal lane rank those equally timed notes alphabetically. Since the temporal lane has a high RRF weight, that can push alphabetically earlier paths above more relevant base results even though the temporal signal did not distinguish them; tie scores should keep the base_fused order to preserve the existing relevance signal deterministically.

Useful? React with 👍 / 👎.

});

// 5-lane RRF (rerank_results is empty when reranker absent, weight 0)
Expand Down Expand Up @@ -374,10 +376,14 @@ fn dedup_by_file(results: Vec<RankedResult>) -> Vec<RankedResult> {
}
}
let mut deduped: Vec<RankedResult> = by_file.into_values().collect();
// Tiebreak on file_path: the map's iteration order is random per process,
// and equal scores otherwise land in random rank order (nondeterministic
// RRF ranks downstream).
deduped.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.file_path.cmp(&b.file_path))
});
deduped
}
Expand All @@ -393,7 +399,15 @@ fn merge_seeds(semantic: &[RankedResult], fts: &[RankedResult]) -> Vec<RankedRes
by_file.insert(r.file_path.clone(), r.clone());
}
}
by_file.into_values().collect()
let mut seeds: Vec<RankedResult> = by_file.into_values().collect();
// Deterministic seed order (map iteration order is random per process).
seeds.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.file_path.cmp(&b.file_path))
});
seeds
}

/// Run a search query and print results.
Expand Down
7 changes: 6 additions & 1 deletion src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ CREATE TABLE IF NOT EXISTS chunks (
vector BLOB
);

CREATE INDEX IF NOT EXISTS idx_chunks_file_id ON chunks(file_id);

CREATE TABLE IF NOT EXISTS tombstones (
id INTEGER PRIMARY KEY,
vector_id INTEGER UNIQUE NOT NULL,
Expand Down Expand Up @@ -1001,7 +1003,7 @@ impl Store {
/// Get the best (highest token_count) chunk for a file.
pub fn get_best_chunk_for_file(&self, file_id: i64) -> Result<Option<(String, String)>> {
let mut stmt = self.conn.prepare(
"SELECT heading, snippet FROM chunks WHERE file_id = ?1 ORDER BY token_count DESC LIMIT 1",
"SELECT heading, snippet FROM chunks WHERE file_id = ?1 ORDER BY token_count DESC, id ASC LIMIT 1",
)?;
let mut rows = stmt.query_map(params![file_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
Expand Down Expand Up @@ -2619,6 +2621,9 @@ mod tests {
store
.insert_chunk(f1, "Big heading", "big snippet", 2, 100)
.unwrap();
store
.insert_chunk(f1, "Later tie", "later snippet", 3, 100)
.unwrap();

let best = store.get_best_chunk_for_file(f1).unwrap().unwrap();
assert_eq!(best.0, "Big heading");
Expand Down
68 changes: 68 additions & 0 deletions tests/golden_search.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
//! Deterministic, model-free search regression battery.
//!
//! The old integration suites exercised deleted HNSW/Embedder modules. This
//! fixture uses the current Store + MockLlm path and repeats the same queries
//! to catch ranking nondeterminism without downloading a GGUF model.

use engraph::docid::generate_docid;
use engraph::llm::MockLlm;
use engraph::search::search_internal;
use engraph::store::Store;

fn fixture_store() -> Store {
let store = Store::open_memory().unwrap();
let embedder = MockLlm::new(256);
let documents = [
(
"notes/alpha.md",
"retrieval ranking architecture",
"retrieval ranking",
),
(
"notes/beta.md",
"retrieval ranking operations",
"retrieval ranking",
),
("notes/gamma.md", "unrelated gardening notes", "unrelated"),
];

for (path, snippet, vector_seed) in documents {
let file_id = store
.insert_file(path, path, 0, &[], &generate_docid(path), None, None)
.unwrap();
let vector_id = store.next_vector_id().unwrap();
let vector = embedder.hash_to_vector(vector_seed);
store
.insert_chunk_with_vector(file_id, "# Fixture", snippet, vector_id, 3, &vector)
.unwrap();
store.insert_vec(vector_id, &vector).unwrap();
store.insert_fts_chunk(file_id, 0, snippet).unwrap();
}
store
}

#[test]
fn golden_search_battery_is_stable() {
let store = fixture_store();
let mut embedder = MockLlm::new(256);

for _ in 0..10 {
let retrieval = search_internal("retrieval ranking", 2, &store, &mut embedder).unwrap();
let retrieval_paths: Vec<&str> = retrieval
.results
.iter()
.map(|result| result.file_path.as_str())
.collect();
assert_eq!(
retrieval_paths,
vec!["notes/alpha.md", "notes/beta.md"],
"retrieval golden result changed"
);

let unrelated = search_internal("unrelated", 1, &store, &mut embedder).unwrap();
assert_eq!(
unrelated.results[0].file_path, "notes/gamma.md",
"unrelated golden result changed"
);
}
}