use std::collections::HashSet;
use crate::indexer::schema::{DocId, DocRecord, StructuralMeta, Trigram};
use crate::search::filters::field_match_score;
use crate::search::query::QueryNode;
#[derive(Debug, Clone)]
pub struct ScoringWeights {
pub trigram: f64,
pub proximity: f64,
pub recency: f64,
pub structural: f64,
pub title: f64,
}
impl Default for ScoringWeights {
fn default() -> Self {
ScoringWeights {
trigram: 0.45,
proximity: 0.20,
recency: 0.10,
structural: 0.20,
title: 0.05,
}
}
}
pub struct ScoringInput<'a> {
pub doc: &'a DocRecord,
pub meta: &'a StructuralMeta,
pub query_text: &'a str,
pub query_trigrams: &'a HashSet<Trigram>,
pub query_node: &'a QueryNode,
pub doc_trigrams: HashSet<Trigram>, pub min_position_span: Option<u32>, pub total_match_positions: usize,
}
pub fn score(input: &ScoringInput, weights: &ScoringWeights) -> f64 {
let trgm = trigram_score(input.query_trigrams, &input.doc_trigrams, input.total_match_positions);
let prox = proximity_score(input.min_position_span);
let rec = recency_score(input.doc);
let st = field_match_score(input.query_node, input.doc, input.meta);
let title = title_boost(input.query_text, input.doc);
weights.trigram * trgm
+ weights.proximity * prox
+ weights.recency * rec
+ weights.structural * st
+ weights.title * title
}
fn trigram_score(query: &HashSet<Trigram>, doc: &HashSet<Trigram>, total_positions: usize) -> f64 {
if query.is_empty() {
return 0.0;
}
let matched = query.intersection(doc).count();
let coverage = matched as f64 / query.len() as f64;
let tf_boost = if total_positions > 0 {
(1.0 + (total_positions as f64).ln()) / (1.0 + (total_positions as f64).ln() + 1.0)
} else {
0.0
};
coverage * 0.7 + tf_boost * 0.3
}
fn proximity_score(min_span: Option<u32>) -> f64 {
match min_span {
None => 0.0,
Some(0) => 1.0,
Some(span) => 1.0 / (1.0 + (span as f64).ln().max(0.0)),
}
}
fn recency_score(doc: &DocRecord) -> f64 {
const HALF_LIFE_SECS: f64 = 180.0 * 86_400.0;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0);
let mtime = doc
.mtime
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0);
let age = (now - mtime).max(0.0);
(-age / HALF_LIFE_SECS).exp()
}
fn title_boost(query_text: &str, doc: &DocRecord) -> f64 {
let Some(title) = &doc.title else {
return 0.0;
};
let title_lower = title.to_lowercase();
for word in query_text.split_whitespace() {
if title_lower.contains(&word.to_lowercase()) {
return 1.0;
}
}
0.0
}
#[derive(Debug, Clone)]
pub struct ScoredDoc {
pub doc_id: DocId,
pub score: f64,
pub path: std::path::PathBuf,
pub snippet: String,
pub doc_type: Option<String>,
}
impl ScoredDoc {
pub fn new(doc: &DocRecord, meta: &StructuralMeta, score: f64) -> Self {
ScoredDoc {
doc_id: doc.id,
score,
path: doc.path.clone(),
snippet: doc.snippet.clone(),
doc_type: meta.doc_type.clone(),
}
}
}
pub fn rank(mut results: Vec<ScoredDoc>, limit: usize) -> Vec<ScoredDoc> {
results.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
results.truncate(limit);
results
}