use std::time::Duration;
use khive_score::DeterministicScore;
use serde::{Deserialize, Serialize};
use khive_fusion::FusionStrategy;
pub const DEFAULT_POOL_MULTIPLIER: usize = 5;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Query {
pub text: String,
pub embedding: Option<Vec<f32>>,
pub filters: Option<serde_json::Value>,
}
impl Query {
pub fn text(text: impl Into<String>) -> Self {
Self {
text: text.into(),
embedding: None,
filters: None,
}
}
pub fn hybrid(text: impl Into<String>, embedding: Vec<f32>) -> Self {
Self {
text: text.into(),
embedding: Some(embedding),
filters: None,
}
}
#[must_use]
pub fn with_filters(mut self, filters: serde_json::Value) -> Self {
self.filters = Some(filters);
self
}
pub fn has_embedding(&self) -> bool {
self.embedding.is_some()
}
}
#[derive(Deserialize)]
struct RawHybridConfig {
fusion_strategy: FusionStrategy,
top_k: usize,
candidate_pool_size: usize,
min_score: Option<DeterministicScore>,
vector_weight: f64,
keyword_weight: f64,
#[serde(default, with = "crate::timeout::serde_opt_duration")]
timeout: Option<Duration>,
}
impl TryFrom<RawHybridConfig> for HybridConfig {
type Error = String;
fn try_from(raw: RawHybridConfig) -> Result<Self, Self::Error> {
if !raw.vector_weight.is_finite() {
return Err(format!(
"vector_weight must be finite, got {}",
raw.vector_weight
));
}
if !raw.keyword_weight.is_finite() {
return Err(format!(
"keyword_weight must be finite, got {}",
raw.keyword_weight
));
}
Ok(HybridConfig {
fusion_strategy: raw.fusion_strategy,
top_k: raw.top_k,
candidate_pool_size: raw.candidate_pool_size,
min_score: raw.min_score,
vector_weight: raw.vector_weight,
keyword_weight: raw.keyword_weight,
timeout: raw.timeout,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(try_from = "RawHybridConfig")]
pub struct HybridConfig {
pub fusion_strategy: FusionStrategy,
pub top_k: usize,
pub candidate_pool_size: usize,
pub min_score: Option<DeterministicScore>,
pub vector_weight: f64,
pub keyword_weight: f64,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "crate::timeout::serde_opt_duration"
)]
pub timeout: Option<Duration>,
}
impl Default for HybridConfig {
fn default() -> Self {
Self {
fusion_strategy: FusionStrategy::rrf(),
top_k: 10,
candidate_pool_size: 50, min_score: None,
vector_weight: 0.7,
keyword_weight: 0.3,
timeout: None,
}
}
}
impl HybridConfig {
pub fn new(top_k: usize) -> Self {
Self {
top_k,
candidate_pool_size: top_k.saturating_mul(DEFAULT_POOL_MULTIPLIER),
..Default::default()
}
}
#[must_use]
pub fn with_fusion_strategy(mut self, strategy: FusionStrategy) -> Self {
self.fusion_strategy = strategy;
self
}
#[must_use]
pub fn with_pool_size(mut self, size: usize) -> Self {
self.candidate_pool_size = size;
self
}
#[must_use]
pub fn with_min_score(mut self, score: DeterministicScore) -> Self {
self.min_score = Some(score);
self
}
#[must_use]
pub fn with_weights(mut self, vector: f64, keyword: f64) -> Self {
debug_assert!(
vector.is_finite(),
"vector weight must be finite, got {vector}"
);
debug_assert!(
keyword.is_finite(),
"keyword weight must be finite, got {keyword}"
);
self.vector_weight = vector.clamp(0.0, 1.0);
self.keyword_weight = keyword.clamp(0.0, 1.0);
self
}
#[must_use]
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
pub fn normalized_weights(&self) -> (f64, f64) {
let sum = self.vector_weight + self.keyword_weight;
if sum <= 0.0 || !sum.is_finite() {
(0.5, 0.5)
} else {
(self.vector_weight / sum, self.keyword_weight / sum)
}
}
}
#[cfg(test)]
#[path = "config/tests.rs"]
mod tests;