use std::collections::{HashMap, HashSet};
use crate::error::{NopalError, Result};
use crate::index::{IndexQuery, IndexType};
use crate::types::{NodeId, PropertyValue};
use super::Graph;
#[cfg(feature = "hybrid")]
const DEFAULT_EF_SEARCH: usize = 30;
#[derive(Debug, Clone, Default)]
pub struct HybridFilter {
pub label: Option<String>,
pub props: Vec<(String, PropertyValue)>,
}
impl HybridFilter {
pub fn is_empty(&self) -> bool {
self.label.is_none() && self.props.is_empty()
}
}
#[derive(Debug, Clone)]
pub struct HybridQuery {
pub text: Option<String>,
pub text_index: Option<String>,
pub vector: Option<(Vec<f32>, String)>,
pub k: usize,
pub ef_search: Option<usize>,
pub rrf_k: f32,
pub overfetch: usize,
pub filter: Option<HybridFilter>,
}
impl HybridQuery {
pub fn new() -> Self {
Self {
text: None,
text_index: None,
vector: None,
k: 10,
ef_search: None,
rrf_k: 60.0,
overfetch: 4,
filter: None,
}
}
}
impl Default for HybridQuery {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct HybridHit {
pub node_id: NodeId,
pub score: f32,
pub text_rank: Option<usize>,
pub vector_rank: Option<usize>,
}
impl Graph {
#[cfg(feature = "hybrid")]
pub async fn search_hybrid(&self, q: HybridQuery) -> Result<Vec<HybridHit>> {
if q.text.is_none() && q.vector.is_none() {
return Err(NopalError::Custom(
"search_hybrid: provide at least one of `text` or `vector`".into(),
));
}
let candidates = q.k.saturating_mul(q.overfetch).max(q.k);
let allowed = self.hybrid_allowed_set(q.filter.as_ref()).await?;
let mut text_ranks: HashMap<NodeId, usize> = HashMap::new();
if let Some(text) = &q.text {
let index_name = self.resolve_fulltext_index(q.text_index.as_deref(), q.filter.as_ref()).await?;
let ids = self
.index_manager
.query(&index_name, &IndexQuery::FullText(text.clone()))
.await?;
for id in ids
.into_iter()
.filter(|id| allowed.as_ref().is_none_or(|s| s.contains(id)))
.take(candidates)
{
let n = text_ranks.len();
text_ranks.entry(id).or_insert(n);
}
}
let mut vector_ranks: HashMap<NodeId, usize> = HashMap::new();
if let Some((vector, model)) = &q.vector {
let index = self.get_or_build_embedding_index(model).await?;
let ef = q.ef_search.unwrap_or(DEFAULT_EF_SEARCH);
let hits = match &allowed {
Some(set) => index.search_knn_filtered(vector, candidates, ef, |id| set.contains(id))?,
None => index.search_knn_with_ef(vector, candidates, ef)?,
};
for (rank, (id, _dist)) in hits.into_iter().enumerate() {
vector_ranks.entry(id).or_insert(rank);
}
}
let mut ids: HashSet<NodeId> = HashSet::new();
ids.extend(text_ranks.keys().copied());
ids.extend(vector_ranks.keys().copied());
let mut hits: Vec<HybridHit> = ids
.into_iter()
.map(|id| {
let tr = text_ranks.get(&id).copied();
let vr = vector_ranks.get(&id).copied();
let mut score = 0.0f32;
if let Some(r) = tr {
score += 1.0 / (q.rrf_k + r as f32);
}
if let Some(r) = vr {
score += 1.0 / (q.rrf_k + r as f32);
}
HybridHit { node_id: id, score, text_rank: tr, vector_rank: vr }
})
.collect();
hits.sort_by(|a, b| {
b.score
.total_cmp(&a.score)
.then_with(|| a.node_id.cmp(&b.node_id))
});
hits.truncate(q.k);
Ok(hits)
}
#[cfg(feature = "hybrid")]
async fn hybrid_allowed_set(
&self,
filter: Option<&HybridFilter>,
) -> Result<Option<HashSet<NodeId>>> {
let Some(filter) = filter else { return Ok(None) };
if filter.is_empty() {
return Ok(None);
}
let mut set: Option<HashSet<NodeId>> = None;
if let Some(label) = &filter.label {
let by_label: HashSet<NodeId> = self
.get_nodes_by_label(label)
.await?
.into_iter()
.map(|n| n.id)
.collect();
set = Some(intersect(set, by_label));
}
for (prop, val) in &filter.props {
let by_prop: HashSet<NodeId> = self
.get_all_nodes_by_property(prop, val)
.await?
.into_iter()
.collect();
set = Some(intersect(set, by_prop));
}
Ok(set)
}
#[cfg(feature = "hybrid")]
async fn resolve_fulltext_index(
&self,
given: Option<&str>,
filter: Option<&HybridFilter>,
) -> Result<String> {
if let Some(name) = given {
return Ok(name.to_string());
}
let metas = self.index_manager.list_indexes().await;
let fulltext: Vec<_> = metas
.into_iter()
.filter(|m| m.index_type == IndexType::FullText)
.collect();
if fulltext.is_empty() {
return Err(NopalError::index_error(
"search_hybrid: no full-text index exists — create one with \
`create index on <Label>(<property>) type fulltext`"
.to_string(),
));
}
let wanted_label = filter.and_then(|f| f.label.as_deref());
let chosen = wanted_label
.and_then(|label| fulltext.iter().find(|m| m.label == label))
.or_else(|| fulltext.first())
.unwrap();
Ok(chosen.name.clone())
}
}
#[cfg(feature = "hybrid")]
fn intersect(acc: Option<HashSet<NodeId>>, next: HashSet<NodeId>) -> HashSet<NodeId> {
match acc {
None => next,
Some(cur) => cur.intersection(&next).copied().collect(),
}
}