use crate::db::Db;
use crate::error::{storage_err, TopoError};
use crate::ids::{NodeId, Scope, ScopeSet};
use crate::index::IndexSpec;
use crate::props::PropValue;
use crate::state::NodeRecord;
use crate::storage::{node_key, scope_key, FTS_DOCS, FTS_STATS, POSTINGS};
use redb::{ReadableTable, Table};
use std::collections::{BTreeMap, BTreeSet, HashMap};
pub(crate) const K1: f32 = 1.2;
pub(crate) const B: f32 = 0.75;
pub(crate) fn tokenize(text: &str) -> Vec<String> {
text.to_lowercase()
.split(|c: char| !c.is_alphanumeric())
.filter(|t| !t.is_empty())
.map(str::to_string)
.collect()
}
pub(crate) fn doc_text(spec: &IndexSpec, rec: &NodeRecord) -> Option<String> {
let mut parts: Vec<&str> = Vec::new();
for pi in &spec.text {
if pi.label != rec.label {
continue;
}
if let Some(PropValue::Str(s)) = rec.props.get(&pi.prop) {
parts.push(s.as_str());
}
}
if parts.is_empty() {
None
} else {
Some(parts.join(" "))
}
}
fn term_freqs(tokens: &[String]) -> BTreeMap<&str, u32> {
let mut m: BTreeMap<&str, u32> = BTreeMap::new();
for t in tokens {
*m.entry(t.as_str()).or_insert(0) += 1;
}
m
}
fn read_posting(
postings: &impl ReadableTable<&'static [u8], &'static [u8]>,
term: &[u8],
) -> Result<Vec<(NodeId, u32)>, TopoError> {
match postings.get(term).map_err(storage_err)? {
Some(v) => postcard::from_bytes(v.value()).map_err(|e| TopoError::Encoding(e.to_string())),
None => Ok(Vec::new()),
}
}
fn set_posting(
postings: &mut Table<'_, &'static [u8], &'static [u8]>,
term: &[u8],
id: NodeId,
count: u32,
) -> Result<(), TopoError> {
let mut map: BTreeMap<NodeId, u32> = match postings.get(term).map_err(storage_err)? {
Some(v) => {
let vec: Vec<(NodeId, u32)> =
postcard::from_bytes(v.value()).map_err(|e| TopoError::Encoding(e.to_string()))?;
vec.into_iter().collect()
}
None => BTreeMap::new(),
};
if count == 0 {
map.remove(&id);
} else {
map.insert(id, count);
}
if map.is_empty() {
postings.remove(term).map_err(storage_err)?;
} else {
let vec: Vec<(NodeId, u32)> = map.into_iter().collect();
let bytes = postcard::to_allocvec(&vec).map_err(|e| TopoError::Encoding(e.to_string()))?;
postings
.insert(term, bytes.as_slice())
.map_err(storage_err)?;
}
Ok(())
}
fn posting_key(scope: Scope, term: &str) -> Vec<u8> {
let prefix = scope_key(scope);
let mut key = Vec::with_capacity(prefix.len() + term.len());
key.extend_from_slice(&prefix);
key.extend_from_slice(term.as_bytes());
key
}
fn read_stats(
stats: &impl ReadableTable<&'static [u8], &'static [u8]>,
scope: Scope,
) -> Result<(u64, u64), TopoError> {
let key = scope_key(scope);
match stats.get(key.as_slice()).map_err(storage_err)? {
Some(v) => postcard::from_bytes(v.value()).map_err(|e| TopoError::Encoding(e.to_string())),
None => Ok((0, 0)),
}
}
fn write_stats(
stats: &mut Table<'_, &'static [u8], &'static [u8]>,
scope: Scope,
doc_count: u64,
total_len: u64,
) -> Result<(), TopoError> {
let key = scope_key(scope);
if doc_count == 0 {
stats.remove(key.as_slice()).map_err(storage_err)?;
} else {
let bytes = postcard::to_allocvec(&(doc_count, total_len))
.map_err(|e| TopoError::Encoding(e.to_string()))?;
stats
.insert(key.as_slice(), bytes.as_slice())
.map_err(storage_err)?;
}
Ok(())
}
fn read_doc_len(
docs: &impl ReadableTable<&'static [u8], &'static [u8]>,
id: NodeId,
) -> Result<u32, TopoError> {
let key = node_key(id);
match docs.get(key.as_slice()).map_err(storage_err)? {
Some(v) => {
postcard::from_bytes::<u32>(v.value()).map_err(|e| TopoError::Encoding(e.to_string()))
}
None => Ok(0),
}
}
pub(crate) fn fts_update(
postings: &mut Table<'_, &'static [u8], &'static [u8]>,
docs: &mut Table<'_, &'static [u8], &'static [u8]>,
stats: &mut Table<'_, &'static [u8], &'static [u8]>,
scope: Scope,
id: NodeId,
old_text: Option<&str>,
new_text: Option<&str>,
) -> Result<(), TopoError> {
let old_tokens = old_text.map(tokenize).unwrap_or_default();
let new_tokens = new_text.map(tokenize).unwrap_or_default();
let old_text = if old_tokens.is_empty() {
None
} else {
old_text
};
let new_text = if new_tokens.is_empty() {
None
} else {
new_text
};
if old_text == new_text {
return Ok(());
}
let old_tf = term_freqs(&old_tokens);
let new_tf = term_freqs(&new_tokens);
let mut terms: BTreeSet<&str> = BTreeSet::new();
terms.extend(old_tf.keys().copied());
terms.extend(new_tf.keys().copied());
for term in terms {
let count = new_tf.get(term).copied().unwrap_or(0);
set_posting(postings, posting_key(scope, term).as_slice(), id, count)?;
}
let key = node_key(id);
let old_len = old_tokens.len() as u64;
let new_len = new_tokens.len() as u64;
let (mut doc_count, mut total_len) = read_stats(stats, scope)?;
match (old_text.is_some(), new_text.is_some()) {
(false, true) => {
doc_count += 1;
total_len += new_len;
}
(true, true) => {
total_len = total_len.saturating_sub(old_len) + new_len;
}
(true, false) => {
doc_count = doc_count.saturating_sub(1);
total_len = total_len.saturating_sub(old_len);
}
(false, false) => {}
}
if new_text.is_some() {
let bytes = postcard::to_allocvec(&(new_len as u32))
.map_err(|e| TopoError::Encoding(e.to_string()))?;
docs.insert(key.as_slice(), bytes.as_slice())
.map_err(storage_err)?;
} else {
docs.remove(key.as_slice()).map_err(storage_err)?;
}
write_stats(stats, scope, doc_count, total_len)?;
Ok(())
}
impl Db {
pub fn search_text(
&self,
scopes: &ScopeSet,
query: &str,
k: usize,
) -> Result<Vec<(NodeRecord, f32)>, TopoError> {
if k == 0 {
return Err(TopoError::Rejected("text search requires k > 0".into()));
}
let tokens = tokenize(query);
if tokens.is_empty() {
return Err(TopoError::Rejected("query has no searchable terms".into()));
}
let distinct: BTreeSet<String> = tokens.into_iter().collect();
let storage = self.storage();
let tx = storage.db.begin_read().map_err(storage_err)?;
let postings = tx.open_table(POSTINGS).map_err(storage_err)?;
let docs = tx.open_table(FTS_DOCS).map_err(storage_err)?;
let stats = tx.open_table(FTS_STATS).map_err(storage_err)?;
let mut scores: HashMap<NodeId, f32> = HashMap::new();
for scope in scopes.iter_scopes() {
let (n_docs, total_len) = read_stats(&stats, scope)?;
if n_docs == 0 {
continue;
}
let avgdl = total_len as f32 / n_docs as f32;
for term in &distinct {
let list = read_posting(&postings, posting_key(scope, term).as_slice())?;
let df = list.len() as f32;
if df == 0.0 {
continue;
}
let idf = ((n_docs as f32 - df + 0.5) / (df + 0.5) + 1.0).ln();
for (id, tf) in list {
let len = read_doc_len(&docs, id)? as f32;
let tf = tf as f32;
let denom = tf + K1 * (1.0 - B + B * len / avgdl);
*scores.entry(id).or_insert(0.0) += idf * tf * (K1 + 1.0) / denom;
}
}
}
let snap = self.snapshot();
let mut out: Vec<(NodeRecord, f32)> = scores
.into_iter()
.filter_map(|(id, score)| {
snap.nodes
.get(&id)
.filter(|n| scopes.contains(n.scope))
.map(|n| (n.clone(), score))
})
.collect();
out.sort_by(|a, b| {
b.1.partial_cmp(&a.1)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.0.id.cmp(&b.0.id))
});
out.truncate(k);
self.bump(out.iter().map(|(n, _)| n.id));
Ok(out)
}
}