Skip to content
Merged
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
43 changes: 43 additions & 0 deletions crates/scry-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ pub struct Config {
#[serde(default)]
pub tavily: Option<TavilyConfig>,
#[serde(default)]
pub rerank: Option<RerankConfig>,
#[serde(default)]
pub client: ClientConfig,
#[serde(default)]
pub index: IndexConfig,
Expand Down Expand Up @@ -101,6 +103,31 @@ pub struct ChatConfig {
pub thinking: bool,
}

/// Cross-encoder over the fused top `top_n`, reached through an
/// OpenAI/Jina-style `POST {base_url}/rerank`, fused back into the
/// ranking as a weighted reciprocal-rank leg. Absent means no rerank.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RerankConfig {
pub base_url: String,
/// Empty sends no Authorization header.
#[serde(default)]
pub api_key: String,
pub model: String,
#[serde(default = "default_rerank_top_n")]
pub top_n: usize,
#[serde(default = "default_rerank_weight")]
pub weight: f64,
}

fn default_rerank_top_n() -> usize {
20
}

fn default_rerank_weight() -> f64 {
1.0
}

#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TavilyConfig {
Expand Down Expand Up @@ -307,6 +334,10 @@ impl Config {
tavily.api_key =
resolve_secret(Some(std::mem::take(&mut tavily.api_key))).unwrap_or_default();
}
if let Some(rerank) = self.rerank.as_mut() {
rerank.api_key =
resolve_secret(Some(std::mem::take(&mut rerank.api_key))).unwrap_or_default();
}
}
}

Expand Down Expand Up @@ -364,6 +395,18 @@ mod tests {
assert_eq!(config.index.max_file_count, 500);
}

#[test]
fn parses_rerank_section() {
let config: Config = toml::from_str(
"[rerank]\nbase_url = \"http://r:8080/v1\"\nmodel = \"bge\"\nweight = 2.0\n",
)
.unwrap();
let rerank = config.rerank.unwrap();
assert_eq!(rerank.top_n, 20);
assert_eq!(rerank.weight, 2.0);
assert!(Config::default().rerank.is_none());
}

#[test]
fn rejects_unknown_keys() {
assert!(toml::from_str::<Config>("[server]\nlissten = \"x\"\n").is_err());
Expand Down
2 changes: 2 additions & 0 deletions crates/scry-core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ pub enum Error {
Embedding(String),
#[error("chat: {0}")]
Chat(String),
#[error("rerank: {0}")]
Rerank(String),
}

pub type Result<T> = std::result::Result<T, Error>;
1 change: 1 addition & 0 deletions crates/scry-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pub mod hashing;
pub mod index;
pub mod memory;
pub mod repo;
pub mod rerank;
pub mod search;
pub mod store;
pub mod walk;
Expand Down
150 changes: 150 additions & 0 deletions crates/scry-core/src/rerank.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
//! Cross-encoder rerank of the fused candidates. The endpoint is the
//! Jina/Cohere shape llama-server exposes at `/v1/rerank`.

use serde::Deserialize;

use crate::config::RerankConfig;
use crate::search::{RRF_K, SearchHit};
use crate::{Error, Result};

pub struct RerankClient {
client: reqwest::Client,
config: RerankConfig,
}

#[derive(Deserialize)]
struct RerankResponse {
results: Vec<RerankResult>,
}

#[derive(Debug, Clone, Copy, Deserialize)]
pub struct RerankResult {
pub index: usize,
pub relevance_score: f64,
}

impl RerankClient {
pub fn new(config: RerankConfig) -> Self {
Self {
client: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("client build"),
config,
}
}

pub fn top_n(&self) -> usize {
self.config.top_n
}

pub fn weight(&self) -> f64 {
self.config.weight
}

/// Scores every document against the query; results come back best
/// first with the index into `documents`.
pub async fn rerank(&self, query: &str, documents: &[String]) -> Result<Vec<RerankResult>> {
let url = format!("{}/rerank", self.config.base_url.trim_end_matches('/'));
let body = serde_json::json!({
"model": self.config.model,
"query": query,
"documents": documents,
"top_n": documents.len(),
});
let mut request = self.client.post(&url).json(&body);
if !self.config.api_key.is_empty() {
request = request.bearer_auth(&self.config.api_key);
}
let response = request.send().await?.error_for_status()?;
let parsed: RerankResponse = response.json().await?;
let mut results = parsed.results;
let mut seen = std::collections::HashSet::with_capacity(results.len());
if results
.iter()
.any(|r| r.index >= documents.len() || !seen.insert(r.index))
{
return Err(Error::Rerank(
"bad or repeated index in response".to_string(),
));
}
results.sort_by(|a, b| b.relevance_score.total_cmp(&a.relevance_score));
Ok(results)
}
}

/// Fuses the reranker's ranking into the pool as a reciprocal-rank leg
/// weighted by `weight` against the pool's own order, then keeps
/// `limit`. Scores are left as they were; the reranker changes order only.
pub fn fuse(
hits: Vec<SearchHit>,
ranked: &[RerankResult],
weight: f64,
limit: usize,
) -> Vec<SearchHit> {
let mut score: Vec<f64> = (0..hits.len())
.map(|rank| 1.0 / (RRF_K + rank as f64 + 1.0))
.collect();
for (rank, r) in ranked.iter().enumerate() {
score[r.index] += weight / (RRF_K + rank as f64 + 1.0);
}
let mut order: Vec<usize> = (0..hits.len()).collect();
order.sort_by(|a, b| score[*b].total_cmp(&score[*a]));
let mut slots: Vec<Option<SearchHit>> = hits.into_iter().map(Some).collect();
order
.into_iter()
.filter_map(|i| slots[i].take())
.take(limit)
.collect()
}

#[cfg(test)]
mod tests {
use super::*;

fn hit(relpath: &str) -> SearchHit {
SearchHit {
repo_key: "r".to_string(),
relpath: relpath.to_string(),
start_line: 1,
end_line: 1,
symbol: None,
score: 0.5,
content: String::new(),
}
}

#[test]
fn fuse_promotes_by_reranker_rank_and_keeps_scores() {
let hits = vec![hit("a"), hit("b"), hit("c")];
let ranked = [
RerankResult {
index: 2,
relevance_score: 3.5,
},
RerankResult {
index: 0,
relevance_score: 0.4,
},
RerankResult {
index: 1,
relevance_score: -1.0,
},
];
let out = fuse(hits, &ranked, 2.0, 2);
let paths: Vec<&str> = out.iter().map(|h| h.relpath.as_str()).collect();
assert_eq!(paths, vec!["c", "a"]);
assert_eq!(out[0].score, 0.5);
}

#[test]
fn fuse_with_zero_weight_keeps_the_pool_order() {
let hits = vec![hit("a"), hit("b")];
let ranked = [RerankResult {
index: 1,
relevance_score: 9.0,
}];
let out = fuse(hits, &ranked, 0.0, 2);
assert_eq!(out[0].relpath, "a");
}
}
2 changes: 1 addition & 1 deletion crates/scry-core/src/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use crate::embed::Embedder;
use crate::store::Store;
use crate::{Error, Result};

const RRF_K: f64 = 60.0;
pub const RRF_K: f64 = 60.0;
const CANDIDATES: usize = 50;
const JACCARD_DEDUP: f64 = 0.8;
const RECENCY_BOOST: f64 = 0.1;
Expand Down
7 changes: 7 additions & 0 deletions crates/scry-server/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,19 @@ pub struct SearchRequest {
pub limit: usize,
#[serde(default)]
pub path_prefix: Option<String>,
/// Applies the server's `[rerank]` stage when one is configured.
#[serde(default = "default_true")]
pub rerank: bool,
}

fn default_limit() -> usize {
10
}

fn default_true() -> bool {
true
}

#[derive(Debug, Serialize, Deserialize)]
pub struct SearchResponse {
pub hits: Vec<Hit>,
Expand Down
3 changes: 3 additions & 0 deletions crates/scry-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ use scry_core::Result;
use scry_core::chat::ChatClient;
use scry_core::config::{Config, HydeMode, IndexConfig, MemoryConfig};
use scry_core::embed::{Embedder, HttpEmbedder};
use scry_core::rerank::RerankClient;
use scry_core::store::Store;

const MAX_SYNC_BODY_BYTES: usize = 64 * 1024 * 1024;
Expand All @@ -32,6 +33,7 @@ pub struct AppState {
pub index_config: IndexConfig,
pub memory_config: MemoryConfig,
pub tavily: Option<tavily::TavilyClient>,
pub rerank: Option<RerankClient>,
}

impl AppState {
Expand All @@ -54,6 +56,7 @@ impl AppState {
index_config: config.index.clone(),
memory_config: config.memory.clone(),
tavily: config.tavily.clone().map(tavily::TavilyClient::new),
rerank: config.rerank.clone().map(RerankClient::new),
}
}
}
Expand Down
36 changes: 35 additions & 1 deletion crates/scry-server/src/routes/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,23 @@ use std::sync::Arc;

use axum::Json;
use axum::extract::State;
use scry_core::index::embed_input;
use scry_core::rerank::fuse;
use scry_core::search::{SearchOptions, query_vector, search_with_vector};

use crate::AppState;
use crate::api::{Hit, SearchRequest, SearchResponse};
use crate::error::ApiError;

/// A reranker is an improvement layer: past this budget or on any error
/// the fused order is returned, so search is never worse than without it.
const RERANK_BUDGET: std::time::Duration = std::time::Duration::from_secs(6);

fn truncated<T>(mut hits: Vec<T>, limit: usize) -> Vec<T> {
hits.truncate(limit);
hits
}

pub async fn search(
State(state): State<Arc<AppState>>,
Json(request): Json<SearchRequest>,
Expand All @@ -19,6 +30,9 @@ pub async fn search(
&request.query,
)
.await?;
let rerank = state.rerank.as_ref().filter(|_| request.rerank);
let pool = rerank.map_or(request.limit, |client| request.limit.max(client.top_n()));
let (query, limit) = (request.query.clone(), request.limit);
let hits = state
.store
.call(move |store| {
Expand All @@ -30,13 +44,33 @@ pub async fn search(
None => None,
};
let options = SearchOptions {
limit: request.limit,
limit: pool,
path_prefix: request.path_prefix.clone(),
};
search_with_vector(store, repo_id, &request.query, &vector, &options).map(Some)
})
.await?
.ok_or_else(|| ApiError::NotFound("repo not indexed".to_string()))?;
let hits = match rerank {
Some(client) => {
let documents: Vec<String> = hits
.iter()
.map(|h| embed_input(&h.repo_key, &h.relpath, h.symbol.as_deref(), &h.content))
.collect();
match tokio::time::timeout(RERANK_BUDGET, client.rerank(&query, &documents)).await {
Ok(Ok(ranked)) => fuse(hits, &ranked, client.weight(), limit),
Ok(Err(error)) => {
tracing::warn!("rerank failed, returning fused order: {error}");
truncated(hits, limit)
}
Err(_) => {
tracing::warn!("rerank exceeded {RERANK_BUDGET:?}, returning fused order");
truncated(hits, limit)
}
}
}
None => hits,
};
Ok(Json(SearchResponse {
hits: hits
.into_iter()
Expand Down
Loading