1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2026 aphrody contributors
//! High-level search pipeline combining FTS5 lexical retrieval, dense vector similarity,
//! RRF (Reciprocal Rank Fusion), and cross-encoder reranking.
use std::{collections::HashMap, sync::Arc, time::Instant};
use aphrody_rag_core::fusion::rrf;
use serde::{Deserialize, Serialize};
use crate::{
embedder::Embedder,
error::Result,
reranker::Reranker,
store::{RagStore, ScoredChunk},
};
/// Requested search strategy.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SearchMode {
/// Pure BM25 text match via SQLite FTS5.
Lexical,
/// Pure dense vector cosine similarity.
Vector,
/// Reciprocal Rank Fusion of Lexical and Vector candidates.
#[default]
Hybrid,
}
/// Search configuration parameters.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchOptions {
/// Maximum number of final results to return.
pub limit: usize,
/// Preferred search mode.
pub mode: SearchMode,
/// Whether to apply cross-encoder reranking if a reranker is configured.
pub rerank: bool,
/// Number of top candidates to pass to the reranker before truncating.
pub rerank_top_n: usize,
/// Restrict results to paths starting with this prefix.
pub path_prefix: Option<String>,
/// RRF smoothing constant (standard is 60.0).
pub rrf_k: f64,
}
impl Default for SearchOptions {
fn default() -> Self {
Self {
limit: 10,
mode: SearchMode::Hybrid,
rerank: true,
rerank_top_n: 25,
path_prefix: None,
rrf_k: 60.0,
}
}
}
/// Result returned by the RAG search pipeline.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResult {
/// Original query string.
pub query: String,
/// Ordered retrieved chunks with relevance scores and channels.
pub hits: Vec<ScoredChunk>,
/// Effective mode served (may differ from requested mode if fallback occurred).
pub mode_served: SearchMode,
/// Fallback explanation when honest degrade happened.
pub fallback_reason: Option<String>,
/// Pipeline latency in milliseconds.
pub elapsed_ms: u64,
/// Total raw candidate count before deduplication and reranking.
pub total_candidates: usize,
}
/// Unified search coordinator.
pub struct RagSearcher {
store: Arc<RagStore>,
embedder: Option<Arc<dyn Embedder>>,
reranker: Option<Arc<dyn Reranker>>,
}
impl RagSearcher {
/// Create a searcher wrapping a RAG store.
pub fn new(store: Arc<RagStore>) -> Self {
Self {
store,
embedder: None,
reranker: None,
}
}
/// Attach an embedding model.
pub fn with_embedder(mut self, embedder: Arc<dyn Embedder>) -> Self {
self.embedder = Some(embedder);
self
}
/// Attach an optional reranker.
pub fn with_reranker(mut self, reranker: Arc<dyn Reranker>) -> Self {
self.reranker = Some(reranker);
self
}
/// Execute a search according to `opts`.
pub async fn search(&self, query: &str, opts: &SearchOptions) -> Result<SearchResult> {
let start = Instant::now();
// 1. Determine effective mode (Honest Delivery degrade if embedder missing).
let (effective_mode, mut fallback_reason) = match opts.mode {
SearchMode::Lexical => (SearchMode::Lexical, None),
SearchMode::Vector => {
if self.embedder.is_some() {
(SearchMode::Vector, None)
} else {
(
SearchMode::Lexical,
Some("No embedder configured; fell back to lexical search".to_string()),
)
}
}
SearchMode::Hybrid => {
if self.embedder.is_some() {
(SearchMode::Hybrid, None)
} else {
(
SearchMode::Lexical,
Some(
"No embedder configured for vector branch; fell back to lexical search"
.to_string(),
),
)
}
}
};
// 2. Fetch candidates according to effective mode.
let (mut hits, total_candidates) = match effective_mode {
SearchMode::Lexical => {
let fetch_limit = (opts.limit * 3).max(opts.rerank_top_n);
let raw = self.store.search_lexical(query, fetch_limit).await?;
let filtered = self.filter_path_prefix(raw, opts.path_prefix.as_deref());
let total = filtered.len();
(filtered, total)
}
SearchMode::Vector => {
let emb = self
.embedder
.as_ref()
.expect("embedder verified in effective_mode");
let q_vec = emb.embed_query(query).await?;
let fetch_limit = (opts.limit * 3).max(opts.rerank_top_n);
let raw = self
.store
.search_vector(&q_vec, emb.model_id(), fetch_limit)
.await?;
let filtered = self.filter_path_prefix(raw, opts.path_prefix.as_deref());
let total = filtered.len();
(filtered, total)
}
SearchMode::Hybrid => {
let fetch_limit = (opts.limit * 3).max(opts.rerank_top_n);
// Fetch lexical branch
let lex_raw = self.store.search_lexical(query, fetch_limit).await?;
let lex_hits = self.filter_path_prefix(lex_raw, opts.path_prefix.as_deref());
// Fetch vector branch
let emb = self
.embedder
.as_ref()
.expect("embedder verified in effective_mode");
let q_vec = emb.embed_query(query).await?;
let vec_raw = self
.store
.search_vector(&q_vec, emb.model_id(), fetch_limit)
.await?;
let vec_hits = self.filter_path_prefix(vec_raw, opts.path_prefix.as_deref());
let total = lex_hits.len() + vec_hits.len();
let fused = self.fuse_rrf(lex_hits, vec_hits, opts.rrf_k);
(fused, total)
}
};
// 3. Optional cross-encoder reranking on top candidates
if opts.rerank
&& !hits.is_empty()
&& let Some(reranker) = self.reranker.as_ref()
{
let rerank_count = opts.rerank_top_n.min(hits.len());
let mut top_slice = hits[..rerank_count].to_vec();
match reranker.rerank(query, &mut top_slice).await {
Ok(()) => {
for item in &mut top_slice {
item.channel = "rerank".to_string();
}
// Replace reranked slice in hits
hits.splice(..rerank_count, top_slice);
}
Err(error) => {
let reason = format!(
"Reranker `{}` failed; kept retrieval order: {error}",
reranker.model_id()
);
fallback_reason = Some(match fallback_reason {
Some(existing) => format!("{existing}; {reason}"),
None => reason,
});
}
}
}
// 4. Truncate to final limit
if hits.len() > opts.limit {
hits.truncate(opts.limit);
}
let elapsed_ms = start.elapsed().as_millis() as u64;
Ok(SearchResult {
query: query.to_string(),
hits,
mode_served: effective_mode,
fallback_reason,
elapsed_ms,
total_candidates,
})
}
fn filter_path_prefix(
&self,
chunks: Vec<ScoredChunk>,
prefix: Option<&str>,
) -> Vec<ScoredChunk> {
let Some(p) = prefix else {
return chunks;
};
chunks
.into_iter()
.filter(|c| c.chunk.path.starts_with(p))
.collect()
}
fn fuse_rrf(
&self,
lex_hits: Vec<ScoredChunk>,
vec_hits: Vec<ScoredChunk>,
k: f64,
) -> Vec<ScoredChunk> {
if lex_hits.is_empty() {
return vec_hits;
}
if vec_hits.is_empty() {
return lex_hits;
}
let mut chunk_map: HashMap<i64, ScoredChunk> = HashMap::new();
let lex_ids: Vec<i64> = lex_hits
.into_iter()
.map(|c| {
let id = c.chunk.id;
chunk_map.entry(id).or_insert(c);
id
})
.collect();
let vec_ids: Vec<i64> = vec_hits
.into_iter()
.map(|c| {
let id = c.chunk.id;
chunk_map.entry(id).or_insert(c);
id
})
.collect();
let ranked = rrf(&[&lex_ids, &vec_ids], k);
let mut out = Vec::with_capacity(ranked.len());
for (chunk_id, rrf_score) in ranked {
if let Some(mut item) = chunk_map.remove(&chunk_id) {
item.score = rrf_score;
item.channel = "rrf".to_string();
out.push(item);
}
}
out
}
}