use crate::adj::{read_varint, write_varint};
use crate::codec::{frame_value, unframe_value};
use crate::db::Db;
use crate::error::{storage_err, TopoError};
use crate::ids::ScopeSet;
use crate::index::IndexSpec;
use crate::props::PropValue;
use crate::state::NodeRecord;
use crate::storage::{
read_node_by_slot, slot_key, EMBEDDINGS, FTS_DOCS, FTS_STATS, NODES, 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 encode_postings(entries: &[(u64, u32)]) -> Vec<u8> {
let mut out = Vec::new();
write_varint(&mut out, entries.len() as u64);
let mut previous = 0u64;
for &(slot, tf) in entries {
write_varint(&mut out, slot - previous);
previous = slot;
write_varint(&mut out, tf as u64);
}
out
}
fn decode_postings(payload: &[u8]) -> Result<Vec<(u64, u32)>, TopoError> {
let mut input = payload;
let count = usize::try_from(read_varint(&mut input)?)
.map_err(|_| TopoError::Encoding("postings count too large".into()))?;
let mut entries = Vec::with_capacity(count);
let mut slot = 0u64;
for _ in 0..count {
slot = slot
.checked_add(read_varint(&mut input)?)
.ok_or_else(|| TopoError::Encoding("postings slot overflow".into()))?;
let tf = u32::try_from(read_varint(&mut input)?)
.map_err(|_| TopoError::Encoding("postings tf too large".into()))?;
entries.push((slot, tf));
}
if !input.is_empty() {
return Err(TopoError::Encoding(
"trailing bytes in postings value".into(),
));
}
Ok(entries)
}
fn read_posting(
postings: &impl ReadableTable<&'static [u8], &'static [u8]>,
term_key: &[u8],
) -> Result<Vec<(u64, u32)>, TopoError> {
match postings.get(term_key).map_err(storage_err)? {
Some(v) => {
let raw = unframe_value(v.value())?;
decode_postings(raw.as_ref())
}
None => Ok(Vec::new()),
}
}
fn set_posting(
postings: &mut Table<'_, &'static [u8], &'static [u8]>,
term_key: &[u8],
slot: u64,
count: u32,
) -> Result<(), TopoError> {
let mut map: BTreeMap<u64, u32> = match postings.get(term_key).map_err(storage_err)? {
Some(v) => {
let raw = unframe_value(v.value())?;
decode_postings(raw.as_ref())?.into_iter().collect()
}
None => BTreeMap::new(),
};
if count == 0 {
map.remove(&slot);
} else {
map.insert(slot, count);
}
if map.is_empty() {
postings.remove(term_key).map_err(storage_err)?;
} else {
let entries: Vec<(u64, u32)> = map.into_iter().collect();
let framed = frame_value(encode_postings(&entries));
postings
.insert(term_key, framed.as_slice())
.map_err(storage_err)?;
}
Ok(())
}
fn posting_key(scope_id: u32, term: &str) -> Vec<u8> {
let mut key = Vec::with_capacity(4 + term.len());
key.extend_from_slice(&scope_id.to_be_bytes());
key.extend_from_slice(term.as_bytes());
key
}
fn read_stats(
stats: &impl ReadableTable<&'static [u8], &'static [u8]>,
scope_id: u32,
) -> Result<(u64, u64), TopoError> {
let key = scope_id.to_be_bytes();
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_id: u32,
doc_count: u64,
total_len: u64,
) -> Result<(), TopoError> {
let key = scope_id.to_be_bytes();
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]>,
slot: u64,
) -> Result<u32, TopoError> {
let key = slot_key(slot);
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_id: u32,
slot: u64,
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_id, term).as_slice(),
slot,
count,
)?;
}
let key = slot_key(slot);
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_id)?;
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_id, 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 nodes = tx.open_table(NODES).map_err(storage_err)?;
let embeddings = tx.open_table(EMBEDDINGS).map_err(storage_err)?;
let dicts = storage.dicts.read().expect("dict lock poisoned");
let scope_registry = storage
.scope_registry
.read()
.expect("scope registry lock poisoned");
let mut scores: HashMap<u64, f32> = HashMap::new();
for scope in scopes.iter_scopes() {
let Some(scope_id) = scope_registry.id_of(scope) else {
continue;
};
let (n_docs, total_len) = read_stats(&stats, scope_id)?;
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_id, 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 (slot, tf) in list {
let len = read_doc_len(&docs, slot)? as f32;
let tf = tf as f32;
let denom = tf + K1 * (1.0 - B + B * len / avgdl);
*scores.entry(slot).or_insert(0.0) += idf * tf * (K1 + 1.0) / denom;
}
}
}
let mut out: Vec<(NodeRecord, f32)> = Vec::with_capacity(scores.len());
for (slot, score) in scores {
if let Some(rec) =
read_node_by_slot(&nodes, &embeddings, &dicts, &scope_registry, slot)?
{
if scopes.contains(rec.scope) {
out.push((rec, score));
}
}
}
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)
}
}
#[cfg(test)]
mod tests {
use super::*;
use redb::Database;
#[test]
fn postings_roundtrip_deltas_and_isolate_be_sharing_scope_prefixes() {
let dir = tempfile::tempdir().unwrap();
let db = Database::create(dir.path().join("t.redb")).unwrap();
let tx = db.begin_write().unwrap();
{
let mut postings = tx.open_table(POSTINGS).unwrap();
let mut docs = tx.open_table(FTS_DOCS).unwrap();
let mut stats = tx.open_table(FTS_STATS).unwrap();
fts_update(
&mut postings,
&mut docs,
&mut stats,
1,
2,
None,
Some("rust rust database"),
)
.unwrap();
fts_update(
&mut postings,
&mut docs,
&mut stats,
1,
5,
None,
Some("rust engine"),
)
.unwrap();
fts_update(
&mut postings,
&mut docs,
&mut stats,
1,
9,
None,
Some("rust topology graph"),
)
.unwrap();
fts_update(
&mut postings,
&mut docs,
&mut stats,
256,
2,
None,
Some("rust filler"),
)
.unwrap();
let list_1 = read_posting(&postings, posting_key(1, "rust").as_slice()).unwrap();
assert_eq!(
list_1,
vec![(2, 2), (5, 1), (9, 1)],
"scope 1's postings must round-trip sorted by slot with the correct tf"
);
let list_256 = read_posting(&postings, posting_key(256, "rust").as_slice()).unwrap();
assert_eq!(
list_256,
vec![(2, 1)],
"scope 256's postings must stay isolated from scope 1's, despite sharing slot 2 and a BE-key prefix"
);
}
tx.commit().unwrap();
}
}