tidaldb/docs/planning/milestone-5/phase-2/task-02-retrieval-mode-router.md
jordan 192c473f55 feat: complete Milestone 5 — full-text search, RRF fusion, and creator search
- M5p1: BM25 text indexing via Tantivy with background syncer (0.26ms @ 10K docs)
- M5p2: RRF fusion layer combining BM25 + ANN scores (46µs @ 1K candidates)
- M5p3: unified Search query API (8-stage pipeline, BM25 + vector + ranking)
- M5p4: creator text + vector indexing and creator search executor (< 20ms @ 200 creators)
- Refactor db/mod.rs into focused sub-modules (creators, items, sessions, signals, etc.)
- Decompose monolithic files into directory modules (query/executor, ranking/diversity, etc.)
- Split brute.rs → brute/mod.rs + brute/tests.rs; extract search executor helpers
- Add benches: fusion, search, session, text_index
- Add M5 UAT test suites (m5_uat, m5_search, m5p4_creator_search, text_index)
- Update blog posts, roadmap, content strategy, and M5 planning docs
- Add tmp/ and .claude/worktrees/ to .gitignore

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 23:53:16 -07:00

237 lines
8.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Task 02: Retrieval Mode Router
## Delivers
`RetrievalMode` enum and `route_results()` function. `RetrievalMode::determine()` selects text-only, vector-only, or hybrid based on what's present in the query. `route_results()` converts pre-retrieved result lists through the appropriate path — direct passthrough for single-mode, `HybridFusion::fuse()` for hybrid. Criterion benchmark confirming fusion adds < 1ms at 1000 candidates per list.
## Complexity: S
## Dependencies
- Task 01 COMPLETE: `HybridFusion` with `fuse()` method in `tidal/src/query/fusion.rs`
- m5p1 COMPLETE: `EntityId` type
- m2p1 COMPLETE: `VectorSearchResult { id: VectorId, distance: f32 }` in `tidal/src/storage/vector/`
## Technical Design
### RetrievalMode
```rust
// tidal/src/query/fusion.rs (additions)
/// Which retrieval system(s) to use for a search query.
///
/// Determined by what the query provides:
/// - `TextOnly` — only `query_text` is present
/// - `VectorOnly` — only `query_vector` is present
/// - `Hybrid` — both `query_text` and `query_vector` are present
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RetrievalMode {
/// Execute BM25 text search only.
TextOnly,
/// Execute ANN vector search only.
VectorOnly,
/// Execute both and fuse results via RRF.
Hybrid,
}
impl RetrievalMode {
/// Determine the retrieval mode from query contents.
///
/// Returns `None` if neither text nor vector is provided (invalid query).
#[must_use]
pub fn determine(has_text: bool, has_vector: bool) -> Option<Self> {
match (has_text, has_vector) {
(true, false) => Some(Self::TextOnly),
(false, true) => Some(Self::VectorOnly),
(true, true) => Some(Self::Hybrid),
(false, false) => None,
}
}
}
```
### route_results()
```rust
/// Route pre-retrieved result lists through the appropriate fusion path.
///
/// - `TextOnly`: converts BM25 scores to `f64` and returns them sorted descending.
/// - `VectorOnly`: converts ANN distance → rank-based score and returns sorted descending.
/// - `Hybrid`: calls `HybridFusion::fuse()` and returns the fused result.
///
/// # Inputs
///
/// - `bm25_results`: `(EntityId, f32)` where f32 is BM25 score, **pre-sorted descending**.
/// - `ann_results`: `(EntityId, f32)` where f32 is L2-squared distance, **pre-sorted ascending**.
/// - Both slices may be empty; callers pass `&[]` for unused modes.
///
/// # Returns
///
/// `Vec<(EntityId, f64)>` sorted descending by score. For `TextOnly` and `VectorOnly`,
/// scores are normalized to `[0, 1]` relative to the top candidate (score 1.0).
/// For `Hybrid`, scores are raw RRF values (typically 0.010.04 for k=60).
pub fn route_results(
mode: RetrievalMode,
bm25_results: &[(EntityId, f32)],
ann_results: &[(EntityId, f32)],
fusion: &HybridFusion,
) -> Vec<(EntityId, f64)> {
match mode {
RetrievalMode::TextOnly => {
// Convert f32 BM25 scores to f64; already sorted descending by caller.
bm25_results
.iter()
.map(|(id, score)| (*id, f64::from(*score)))
.collect()
}
RetrievalMode::VectorOnly => {
// Convert rank-position to a score using the same RRF formula for
// consistency: score = 1.0 / (k + rank). This gives ANN-only results
// the same score range as hybrid results.
let k = f64::from(fusion.k);
ann_results
.iter()
.enumerate()
.map(|(i, (id, _distance))| {
let rank = (i + 1) as f64;
(*id, 1.0 / (k + rank))
})
.collect()
}
RetrievalMode::Hybrid => fusion.fuse(bm25_results, ann_results),
}
}
```
### ann_to_ranked()
A helper to convert `Vec<VectorSearchResult>` (returned by `VectorIndex::search()`) to `Vec<(EntityId, f32)>` suitable as input to `fuse()` or `route_results()`:
```rust
use crate::storage::vector::VectorSearchResult;
/// Convert ANN search results to a ranked list for fusion input.
///
/// `VectorSearchResult` is already sorted ascending by distance (best first).
/// This function maps it to `(EntityId, f32)` where the f32 is the raw L2 distance.
/// The caller passes this to `fuse()` or `route_results()` which uses position-as-rank.
#[must_use]
pub fn ann_to_ranked(ann_results: &[VectorSearchResult]) -> Vec<(EntityId, f32)> {
ann_results
.iter()
.map(|r| (EntityId::new(r.id), r.distance))
.collect()
}
```
### Module Integration
Add to `tidal/src/query/mod.rs`:
```rust
pub use fusion::{HybridFusion, RetrievalMode, ann_to_ranked, route_results};
```
### Criterion Benchmark
```rust
// tidal/benches/fusion.rs
fn bench_rrf_1k_per_list(c: &mut Criterion) {
// 1000 BM25 results
let bm25: Vec<(EntityId, f32)> = (0u64..1000)
.map(|i| (EntityId::new(i), (1000 - i) as f32))
.collect();
// 1000 ANN results, 50% overlap with BM25
let ann: Vec<(EntityId, f32)> = (500u64..1500)
.enumerate()
.map(|(i, id)| (EntityId::new(id), i as f32 * 0.001))
.collect();
let fusion = HybridFusion::new();
c.bench_function("rrf_fuse_1k_per_list", |b| {
b.iter(|| {
let results = fusion.fuse(black_box(&bm25), black_box(&ann));
black_box(results)
});
});
}
```
## Acceptance Criteria
- [ ] `RetrievalMode` enum with `TextOnly`, `VectorOnly`, `Hybrid` variants in `fusion.rs`
- [ ] `RetrievalMode::determine(has_text, has_vector) -> Option<RetrievalMode>` returns correct variant
- [ ] `determine(false, false)` returns `None`
- [ ] `route_results(mode, bm25, ann, fusion) -> Vec<(EntityId, f64)>` implemented
- [ ] `TextOnly` path: BM25 scores converted to f64, list preserved
- [ ] `VectorOnly` path: ANN results converted to rank-based scores via `1.0 / (k + rank)`
- [ ] `Hybrid` path: calls `HybridFusion::fuse()` and returns result
- [ ] `ann_to_ranked(ann_results: &[VectorSearchResult]) -> Vec<(EntityId, f32)>` helper
- [ ] `RetrievalMode`, `route_results`, `ann_to_ranked` exported from `tidal/src/query/mod.rs`
- [ ] `tidal/benches/fusion.rs` created with Criterion benchmark `rrf_fuse_1k_per_list`
- [ ] Benchmark result confirms fusion < 1ms for 1000 candidates per list
- [ ] `[[bench]] name = "fusion" harness = false` added to `tidal/Cargo.toml`
- [ ] Unit tests: `determine_text_only`, `determine_vector_only`, `determine_hybrid`, `determine_none`, `route_text_only_passthrough`, `route_vector_only_rank_based`, `route_hybrid_calls_fuse`, `ann_to_ranked_converts_correctly`
- [ ] `cargo check`, `cargo fmt`, `cargo clippy -D warnings` all pass
## Test Strategy
```rust
#[test]
fn determine_text_only() {
assert_eq!(RetrievalMode::determine(true, false), Some(RetrievalMode::TextOnly));
}
#[test]
fn determine_hybrid() {
assert_eq!(RetrievalMode::determine(true, true), Some(RetrievalMode::Hybrid));
}
#[test]
fn determine_none() {
assert_eq!(RetrievalMode::determine(false, false), None);
}
#[test]
fn route_text_only_passthrough() {
let bm25 = vec![(EntityId::new(1), 1.0f32), (EntityId::new(2), 0.5f32)];
let fusion = HybridFusion::new();
let results = route_results(RetrievalMode::TextOnly, &bm25, &[], &fusion);
assert_eq!(results.len(), 2);
assert!((results[0].1 - 1.0f64).abs() < 1e-6); // f32 → f64 exact
assert!((results[1].1 - 0.5f64).abs() < 1e-6);
}
#[test]
fn route_vector_only_rank_based() {
// VectorSearchResult order: rank 1 (index 0) gets score 1/(60+1)
let ann = vec![
(EntityId::new(1), 0.1f32), // rank 1
(EntityId::new(2), 0.2f32), // rank 2
];
let fusion = HybridFusion::new();
let results = route_results(RetrievalMode::VectorOnly, &[], &ann, &fusion);
assert_eq!(results.len(), 2);
let expected_rank1 = 1.0 / (60.0 + 1.0);
let expected_rank2 = 1.0 / (60.0 + 2.0);
assert!((results[0].1 - expected_rank1).abs() < 1e-9);
assert!((results[1].1 - expected_rank2).abs() < 1e-9);
}
#[test]
fn ann_to_ranked_converts_correctly() {
use crate::storage::vector::VectorSearchResult;
let ann_results = vec![
VectorSearchResult { id: 42, distance: 0.1 },
VectorSearchResult { id: 99, distance: 0.3 },
];
let ranked = ann_to_ranked(&ann_results);
assert_eq!(ranked.len(), 2);
assert_eq!(ranked[0].0.as_u64(), 42);
assert!((ranked[0].1 - 0.1f32).abs() < 1e-6);
assert_eq!(ranked[1].0.as_u64(), 99);
}
```