use khive_score::DeterministicScore;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum QueryNode {
Vector {
embedding: Vec<f32>,
top_k: usize,
min_score: Option<DeterministicScore>,
},
Keyword {
text: String,
top_k: usize,
min_score: Option<DeterministicScore>,
},
Fuse {
children: Vec<QueryNode>,
strategy: FuseStrategy,
top_k: usize,
},
Filter {
child: Box<QueryNode>,
predicate: FilterPredicate,
},
Rerank {
child: Box<QueryNode>,
method: RerankMethod,
top_k: usize,
},
Empty,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FuseStrategy {
Rrf {
k: usize,
},
Weighted {
weights: Vec<f64>,
},
Union,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FilterPredicate {
MinScore(DeterministicScore),
TopK(usize),
MetadataEquals {
field: String,
value: serde_json::Value,
},
And(Vec<FilterPredicate>),
Or(Vec<FilterPredicate>),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RerankMethod {
CrossEncoder {
model: String,
},
ScoreWeighted {
weights: Vec<f64>,
},
}
impl QueryNode {
pub fn vector(embedding: Vec<f32>, top_k: usize) -> Self {
QueryNode::Vector {
embedding,
top_k,
min_score: None,
}
}
pub fn keyword(text: impl Into<String>, top_k: usize) -> Self {
QueryNode::Keyword {
text: text.into(),
top_k,
min_score: None,
}
}
pub fn hybrid(embedding: Vec<f32>, text: impl Into<String>, top_k: usize) -> Self {
let candidate_k = top_k.saturating_mul(3);
QueryNode::Fuse {
children: vec![
QueryNode::vector(embedding, candidate_k),
QueryNode::keyword(text, candidate_k),
],
strategy: FuseStrategy::Rrf { k: 60 },
top_k,
}
}
#[must_use]
pub fn with_min_score(self, min_score: DeterministicScore) -> Self {
QueryNode::Filter {
child: Box::new(self),
predicate: FilterPredicate::MinScore(min_score),
}
}
#[must_use]
pub fn with_top_k(self, k: usize) -> Self {
QueryNode::Filter {
child: Box::new(self),
predicate: FilterPredicate::TopK(k),
}
}
pub fn is_empty(&self) -> bool {
match self {
QueryNode::Empty => true,
QueryNode::Vector { top_k: 0, .. } => true,
QueryNode::Keyword { top_k: 0, .. } => true,
QueryNode::Keyword { text, .. } if text.is_empty() => true,
QueryNode::Fuse { children, .. } if children.is_empty() => true,
QueryNode::Filter { child, .. } => child.is_empty(),
QueryNode::Rerank { child, .. } => child.is_empty(),
_ => false,
}
}
pub fn leaf_count(&self) -> usize {
match self {
QueryNode::Vector { .. } | QueryNode::Keyword { .. } => 1,
QueryNode::Fuse { children, .. } => children.iter().map(|c| c.leaf_count()).sum(),
QueryNode::Filter { child, .. } | QueryNode::Rerank { child, .. } => child.leaf_count(),
QueryNode::Empty => 0,
}
}
pub fn top_k(&self) -> usize {
match self {
QueryNode::Vector { top_k, .. } => *top_k,
QueryNode::Keyword { top_k, .. } => *top_k,
QueryNode::Fuse { top_k, .. } => *top_k,
QueryNode::Filter { child, predicate } => match predicate {
FilterPredicate::TopK(k) => *k,
_ => child.top_k(),
},
QueryNode::Rerank { top_k, .. } => *top_k,
QueryNode::Empty => 0,
}
}
pub fn depth(&self) -> usize {
match self {
QueryNode::Empty => 0,
QueryNode::Vector { .. } | QueryNode::Keyword { .. } => 1,
QueryNode::Fuse { children, .. } => {
1 + children.iter().map(|c| c.depth()).max().unwrap_or(0)
}
QueryNode::Filter { child, .. } | QueryNode::Rerank { child, .. } => 1 + child.depth(),
}
}
}
#[cfg(test)]
#[allow(clippy::uninlined_format_args)]
#[path = "query_ir/tests.rs"]
mod tests;