35 lines
1.5 KiB
Rust
35 lines
1.5 KiB
Rust
|
|
use crate::affinity::TopicAffinities;
|
||
|
|
|
||
|
|
/// Inputs to the final blended relevance score for one article.
|
||
|
|
pub struct RelevanceInputs<'a> {
|
||
|
|
/// Cosine similarity (0.0-1.0, already renormalized from [-1,1] if
|
||
|
|
/// needed) between article and preference-profile embeddings.
|
||
|
|
pub embedding_score: f32,
|
||
|
|
/// LLM judgment (0.0-1.0), `None` if the article didn't clear the
|
||
|
|
/// embedding shortlist threshold and so was never sent to the LLM.
|
||
|
|
pub llm_score: Option<f32>,
|
||
|
|
pub topics: &'a [String],
|
||
|
|
pub affinities: &'a TopicAffinities,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Weights are deliberately conservative: the LLM judgment dominates when
|
||
|
|
/// present (it has read the actual content), the embedding score is a
|
||
|
|
/// fallback when the LLM stage was skipped, and topic affinity acts as a
|
||
|
|
/// bounded nudge rather than a veto — a strong LLM match should still
|
||
|
|
/// surface even for a topic the user historically skims.
|
||
|
|
const W_LLM: f32 = 0.6;
|
||
|
|
const W_EMBEDDING: f32 = 0.25;
|
||
|
|
const W_AFFINITY: f32 = 0.15;
|
||
|
|
|
||
|
|
pub fn score_article(inputs: RelevanceInputs) -> f32 {
|
||
|
|
let affinity = inputs.affinities.score_topics(inputs.topics) as f32; // [-1, 1]
|
||
|
|
let affinity_component = (affinity + 1.0) / 2.0; // renormalize to [0, 1]
|
||
|
|
|
||
|
|
match inputs.llm_score {
|
||
|
|
Some(llm) => W_LLM * llm + W_EMBEDDING * inputs.embedding_score + W_AFFINITY * affinity_component,
|
||
|
|
// No LLM score yet: redistribute its weight onto the embedding score.
|
||
|
|
None => (W_LLM + W_EMBEDDING) * inputs.embedding_score + W_AFFINITY * affinity_component,
|
||
|
|
}
|
||
|
|
.clamp(0.0, 1.0)
|
||
|
|
}
|