use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use ulid::Ulid;
pub const FTS_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FtsDef {
pub collection: String,
pub field: String,
pub version: u32,
}
impl FtsDef {
pub fn supports_ranking(&self) -> bool {
self.version >= 1
}
}
#[derive(Deserialize)]
struct FtsDefV0 {
collection: String,
field: String,
}
pub fn decode_fts_def(bytes: &[u8]) -> Result<FtsDef, postcard::Error> {
match postcard::from_bytes::<FtsDef>(bytes) {
Ok(def) => Ok(def),
Err(_) => {
let v0: FtsDefV0 = postcard::from_bytes(bytes)?;
Ok(FtsDef {
collection: v0.collection,
field: v0.field,
version: 0,
})
}
}
}
pub fn fts_table_name(collection: &str, field: &str) -> String {
format!("fts::{collection}::{field}")
}
pub fn fts_len_table_name(collection: &str, field: &str) -> String {
format!("fts_len::{collection}::{field}")
}
pub fn fts_stats_table_name(collection: &str, field: &str) -> String {
format!("fts_stats::{collection}::{field}")
}
pub const FTS_STATS_KEY: &[u8] = b"stats";
#[derive(Debug, Clone)]
pub struct TextSearchResult {
pub document: crate::document::Document,
pub score: f32,
}
#[derive(Debug, Clone)]
pub struct HybridSearchResult {
pub document: crate::document::Document,
pub score: f32,
pub text_rank: Option<usize>,
pub vector_rank: Option<usize>,
}
pub struct HybridQuery<'a> {
pub text_field: &'a str,
pub text: &'a str,
pub vector_field: &'a str,
pub vector: &'a [f32],
pub top_k: usize,
pub filter: Option<crate::query::Filter>,
pub rrf: crate::bm25::RrfParams,
pub bm25: crate::bm25::Bm25Params,
pub candidates: Option<usize>,
}
impl<'a> HybridQuery<'a> {
pub fn new(
text_field: &'a str,
text: &'a str,
vector_field: &'a str,
vector: &'a [f32],
top_k: usize,
) -> Self {
Self {
text_field,
text,
vector_field,
vector,
top_k,
filter: None,
rrf: crate::bm25::RrfParams::default(),
bm25: crate::bm25::Bm25Params::default(),
candidates: None,
}
}
pub fn filter(mut self, filter: crate::query::Filter) -> Self {
self.filter = Some(filter);
self
}
}
pub fn tokenize(text: &str) -> Vec<String> {
text.split(|c: char| !c.is_alphanumeric())
.filter(|t| t.len() >= 2)
.map(str::to_lowercase)
.collect()
}
pub const STOPWORDS: &[&str] = &[
"an", "the", "and", "or", "but", "if", "of", "to", "in", "on", "at", "by", "for", "with",
"from", "into", "about", "as", "is", "are", "was", "were", "be", "been", "being", "am", "it",
"its", "this", "that", "these", "those", "he", "she", "him", "his", "her", "they", "them",
"their", "me", "my", "you", "your", "we", "our", "us", "do", "does", "did", "has", "have",
"had", "so", "too", "very", "can", "will", "just", "than", "then", "there", "here", "what",
"when", "who", "how", "which", "not",
];
pub fn without_stopwords(tokens: Vec<String>) -> Vec<String> {
if tokens.iter().all(|t| STOPWORDS.contains(&t.as_str())) {
return tokens;
}
tokens
.into_iter()
.filter(|t| !STOPWORDS.contains(&t.as_str()))
.collect()
}
pub fn token_frequencies(text: &str) -> (HashMap<String, u32>, u32) {
let tokens = tokenize(text);
let total = tokens.len().min(u32::MAX as usize) as u32;
let mut freqs: HashMap<String, u32> = HashMap::new();
for token in tokens {
*freqs.entry(token).or_insert(0) += 1;
}
(freqs, total)
}
pub fn encode_tf(tf: u32) -> [u8; 4] {
tf.to_le_bytes()
}
pub fn decode_tf(bytes: &[u8]) -> u32 {
if bytes.len() < 4 {
return 1;
}
u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]).max(1)
}
pub fn encode_doc_len(len: u32) -> [u8; 4] {
len.to_le_bytes()
}
pub fn decode_doc_len(bytes: &[u8]) -> u32 {
if bytes.len() < 4 {
return 0;
}
u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])
}
pub fn encode_fts_key(token: &str, ulid: &Ulid) -> Vec<u8> {
let mut key = Vec::with_capacity(token.len() + 1 + 16);
key.extend_from_slice(token.as_bytes());
key.push(0x00); key.extend_from_slice(&ulid.to_bytes());
key
}
pub fn fts_token_range(token: &str) -> (Vec<u8>, Vec<u8>) {
let mut start = Vec::with_capacity(token.len() + 17);
start.extend_from_slice(token.as_bytes());
start.push(0x00);
start.extend_from_slice(&[0x00u8; 16]);
let mut end = Vec::with_capacity(token.len() + 17);
end.extend_from_slice(token.as_bytes());
end.push(0x00);
end.extend_from_slice(&[0xFFu8; 16]);
(start, end)
}
pub fn ulid_from_fts_key(key: &[u8]) -> Option<Ulid> {
if key.len() < 17 {
return None;
}
let bytes: [u8; 16] = key[key.len() - 16..].try_into().ok()?;
Some(Ulid::from_bytes(bytes))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tokenize_basic() {
let tokens = tokenize("Hello, World! This is TalaDB.");
assert!(tokens.contains(&"hello".to_string()));
assert!(tokens.contains(&"world".to_string()));
assert!(tokens.contains(&"taladb".to_string()));
assert!(tokens.contains(&"is".to_string()));
}
#[test]
fn stopwords_are_dropped_unless_they_are_all_there_is() {
let words = |q: &str| without_stopwords(tokenize(q));
assert_eq!(words("kind to a classmate"), vec!["kind", "classmate"]);
assert_eq!(words("to be or not"), vec!["to", "be", "or", "not"]);
assert_eq!(words("The Fractions"), vec!["fractions"]);
assert!(words("").is_empty());
}
#[test]
fn tokenize_strips_short() {
let tokens = tokenize("a bb ccc");
assert!(!tokens.contains(&"a".to_string()));
assert!(tokens.contains(&"bb".to_string()));
assert!(tokens.contains(&"ccc".to_string()));
}
#[test]
fn fts_key_round_trip() {
let ulid = Ulid::new();
let key = encode_fts_key("hello", &ulid);
let decoded = ulid_from_fts_key(&key).unwrap();
assert_eq!(decoded, ulid);
}
#[test]
fn fts_token_range_bounds() {
let (start, end) = fts_token_range("rust");
assert!(start < end);
assert_eq!(&start[..5], b"rust\0");
}
#[test]
fn token_range_excludes_longer_tokens_sharing_a_prefix() {
let (start, end) = fts_token_range("rust");
let rusty = encode_fts_key("rusty", &Ulid::new());
assert!(rusty.as_slice() > end.as_slice());
let rust = encode_fts_key("rust", &Ulid::new());
assert!(rust.as_slice() >= start.as_slice() && rust.as_slice() <= end.as_slice());
}
#[test]
fn token_frequencies_counts_repeats_and_total() {
let (freqs, total) = token_frequencies("the cat sat on the mat the end");
assert_eq!(freqs.get("the"), Some(&3));
assert_eq!(freqs.get("cat"), Some(&1));
assert_eq!(total, 8);
}
#[test]
fn token_frequencies_of_empty_text_is_empty() {
let (freqs, total) = token_frequencies("");
assert!(freqs.is_empty());
assert_eq!(total, 0);
}
#[test]
fn tf_round_trips() {
assert_eq!(decode_tf(&encode_tf(7)), 7);
assert_eq!(decode_tf(&encode_tf(1)), 1);
}
#[test]
fn legacy_empty_postings_value_decodes_as_one() {
assert_eq!(decode_tf(&[]), 1);
}
#[test]
fn doc_len_round_trips_and_defaults_to_zero() {
assert_eq!(decode_doc_len(&encode_doc_len(42)), 42);
assert_eq!(decode_doc_len(&[]), 0);
}
#[test]
fn fts_def_decodes_current_layout() {
let def = FtsDef {
collection: "articles".into(),
field: "body".into(),
version: FTS_VERSION,
};
let bytes = postcard::to_allocvec(&def).unwrap();
let back = decode_fts_def(&bytes).unwrap();
assert_eq!(back.collection, "articles");
assert_eq!(back.field, "body");
assert_eq!(back.version, FTS_VERSION);
assert!(back.supports_ranking());
}
#[test]
fn fts_def_falls_back_to_v0_metadata() {
#[derive(Serialize)]
struct Legacy {
collection: String,
field: String,
}
let bytes = postcard::to_allocvec(&Legacy {
collection: "articles".into(),
field: "body".into(),
})
.unwrap();
let def = decode_fts_def(&bytes).unwrap();
assert_eq!(def.collection, "articles");
assert_eq!(def.field, "body");
assert_eq!(def.version, 0);
assert!(!def.supports_ranking());
}
}