Skip to main content

lc_evaluation/
faithfulness.rs

1//! Faithfulness evaluator: detects whether an answer is faithful to the reference context (hallucination detection).
2//!
3//! The idea comes from Ragas' faithfulness: the answer is split into atomic claims,
4//! each judged for whether it can be derived from the reference context; the pass rate is the faithfulness score.
5//! Here `reference` acts as the "context / retrieved content" and `prediction` is the answer under test.
6
7use async_trait::async_trait;
8use futures_util::stream::{self, StreamExt};
9use serde::Deserialize;
10
11use lc_core::judge::{structured_call, truncate, StructuredJudgeError};
12use lc_core::tools::ToolDefinition;
13use lc_core::BaseChatModel;
14use lc_schema::Message;
15
16use super::{EvalError, Evaluator, Score};
17
18/// P1-5: maximum concurrent claim-verification calls to the judge in a single eval (prevents N paths all dying to rate limits).
19const MAX_CONCURRENT_VERIFY: usize = 4;
20
21/// P2-5: character cap for the reference context in a single claim's judge prompt.
22/// The full long reference is truncated once and reused by N claims, avoiding re-sending the whole context per claim.
23const DEFAULT_MAX_CONTEXT_CHARS: usize = 2000;
24
25/// Faithfulness evaluator (hallucination detection): how faithful an answer is to the reference context.
26///
27/// Splits `prediction` into atomic claims and asks the judge per claim whether it can be derived from `reference`;
28/// pass rate = derivable claims / total claims.
29pub struct Faithfulness<M: BaseChatModel> {
30    judge: M,
31    /// Whether to split claims with the LLM (default false: rule-based split on punctuation)
32    llm_split: bool,
33    /// Score for an empty prediction (no verifiable claims), default 0.0 (no answer = not faithful).
34    empty_score: f64,
35    /// Per-claim reference-context transmission cap (chars, default [`DEFAULT_MAX_CONTEXT_CHARS`]).
36    max_context_chars: usize,
37}
38
39/// Splits an answer into atomic claims (split on period, question mark, exclamation mark, semicolon, newline).
40///
41/// B9: shared with the RAGAS context-recall evaluator, which splits the *reference* answer
42/// into claims the same way.
43pub(crate) fn split_claims(prediction: &str) -> Vec<String> {
44    prediction
45        .split(['。', '.', '!', '?', ';', ';', '\n'])
46        .map(|s| s.trim().to_string())
47        .filter(|s| !s.is_empty())
48        .collect()
49}
50
51impl<M: BaseChatModel> Faithfulness<M> {
52    /// Creates a faithfulness evaluator.
53    pub fn new(judge: M) -> Self {
54        Self {
55            judge,
56            llm_split: false,
57            empty_score: 0.0, // P0-2: empty prediction defaults to 0 (no answer = not faithful)
58            max_context_chars: DEFAULT_MAX_CONTEXT_CHARS,
59        }
60    }
61
62    /// Splits claims with the LLM (default: rule-based on punctuation; LLM split handles comma compound sentences)
63    pub fn with_llm_split(mut self, v: bool) -> Self {
64        self.llm_split = v;
65        self
66    }
67
68    /// Score for an empty prediction: default 0.0 (no answer = not faithful); can be set to 1.0 for "not fabricating is faithful"
69    pub fn with_empty_score(mut self, score: f64) -> Self {
70        self.empty_score = score;
71        self
72    }
73
74    /// Per-claim reference-context transmission cap (chars). P2-5: default 2000, preventing a long reference from being fully stuffed into the prompt once per claim.
75    pub fn with_max_context_chars(mut self, max: usize) -> Self {
76        self.max_context_chars = max;
77        self
78    }
79
80    /// Asks the judge whether a single claim can be derived from the context.
81    async fn verify_claim(&self, context: &str, claim: &str) -> Result<bool, EvalError> {
82        let system =
83            "你是事实核查员。判断给定的陈述能否从参考上下文中推导出来。调用 check_claim 工具提交判定。"
84                .to_string();
85        let user =
86            format!("参考上下文:\n{context}\n\n陈述:\n{claim}\n\n这条陈述能从上下文推导出来吗?");
87        let messages = vec![Message::system(system), Message::human(user)];
88
89        // P0-1: prefer structured output (boolean verdict); models without tool binding fall back to text parsing.
90        let args: VerdictArgs = structured_call(&self.judge, verdict_tool(), messages, |raw| {
91            let verdict = parse_yes_no(raw).ok_or_else(|| {
92                StructuredJudgeError::Parse(format!(
93                    "failed to parse yes/no from judge reply: {}",
94                    truncate(raw, 200)
95                ))
96            })?;
97            Ok(VerdictArgs {
98                verdict,
99                reason: String::new(),
100            })
101        })
102        .await?;
103        Ok(args.verdict)
104    }
105
106    /// Splits the answer into atomic claims with the LLM (one per line), handling compound sentences the rule-based split cannot.
107    async fn split_claims_llm(&self, prediction: &str) -> Result<Vec<String>, EvalError> {
108        let system =
109            "你是文本分析助手。把回答拆成原子陈述,每条一行,只输出陈述本身,不要编号不要解释。"
110                .to_string();
111        let user = format!("回答:\n{prediction}\n\n把它拆成原子陈述,每行一条:");
112        let result = self
113            .judge
114            .chat_with_system(system, vec![Message::human(user)])
115            .await
116            .map_err(|e| EvalError::PredictorError(e.to_string()))?;
117        Ok(result
118            .content
119            .lines()
120            .map(|s| s.trim().to_string())
121            .filter(|s| !s.is_empty())
122            .collect())
123    }
124}
125
126#[async_trait]
127impl<M: BaseChatModel> Evaluator for Faithfulness<M> {
128    async fn eval(
129        &self,
130        _input: &str,
131        prediction: &str,
132        reference: &str,
133    ) -> Result<Score, EvalError> {
134        let claims = if self.llm_split {
135            self.split_claims_llm(prediction).await?
136        } else {
137            split_claims(prediction)
138        };
139        if claims.is_empty() {
140            return Ok(Score::new(self.empty_score).with_label("no_claims"));
141        }
142        // P2-5: the reference context is truncated once and reused by all claims (avoiding a full long reference transmitted N times).
143        let context = truncate(reference, self.max_context_chars);
144        // verify claims concurrently (one LLM call each) but throttle with buffer_unordered:
145        // P1-5 — join_all would fire unlimited concurrency at one judge; hitting a rate limit kills all N.
146        // `ctx` is a Copy reference so the closure can capture it repeatedly; capturing `context`
147        // directly would be moved out claim by claim by async move, and map(FnMut) would not compile.
148        let ctx = &context;
149        let total = claims.len();
150        let results: Vec<Result<bool, EvalError>> = stream::iter(claims)
151            .map(|claim| async move { self.verify_claim(ctx, &claim).await })
152            .buffer_unordered(MAX_CONCURRENT_VERIFY)
153            .collect()
154            .await;
155        let mut supported = 0usize;
156        for r in results {
157            if r? {
158                supported += 1;
159            }
160        }
161        let value = supported as f64 / total as f64;
162        Ok(Score::new(value).with_label("faithfulness"))
163    }
164
165    fn name(&self) -> &str {
166        "faithfulness"
167    }
168}
169
170/// Structured verdict arguments (returned via tool_calls).
171#[derive(Debug, Deserialize)]
172struct VerdictArgs {
173    verdict: bool,
174    /// Asks the LLM to attach a brief reason (improves judgment quality); currently unused.
175    #[serde(default)]
176    #[allow(dead_code)]
177    reason: String,
178}
179
180/// Builds the verdict tool: lets the LLM submit a verdict as `{"verdict": bool, "reason": "..."}`.
181fn verdict_tool() -> ToolDefinition {
182    ToolDefinition::new(
183        "check_claim",
184        "判断陈述能否从参考上下文推导出来,提交布尔判定。",
185    )
186    .with_parameters(serde_json::json!({
187        "type": "object",
188        "properties": {
189            "verdict": { "type": "boolean", "description": "能否从上下文推导" },
190            "reason": { "type": "string", "description": "简短依据" }
191        },
192        "required": ["verdict", "reason"]
193    }))
194}
195
196/// Parses "yes/no". With no yes/no marker returns `None` (parse failure, reported by the caller),
197/// rather than silently defaulting to false — so an off-topic LLM reply is not read as "unfaithful".
198///
199/// B9: shared by the RAGAS boolean judges in `ragas.rs`.
200pub(crate) fn parse_yes_no(raw: &str) -> Option<bool> {
201    let lower = raw.to_lowercase();
202    // check negatives first (so negated phrasings are not caught by the positive keywords; negatives take precedence over positives)
203    if lower.contains("否")
204        || lower.contains("no")
205        || lower.contains("不能")
206        || lower.contains("不是")
207        || lower.contains("false")
208    {
209        return Some(false);
210    }
211    if lower.contains("是")
212        || lower.contains("yes")
213        || lower.contains("能")
214        || lower.contains("true")
215    {
216        return Some(true);
217    }
218    None
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use futures_util::Stream;
225    use lc_core::language_models::{LLMResult, StreamChunk};
226    use lc_core::{BaseLanguageModel, Runnable, RunnableConfig};
227    use lc_schema::MessageType;
228    use std::pin::Pin;
229    use std::sync::atomic::{AtomicUsize, Ordering};
230    use std::sync::{Arc, Mutex};
231
232    #[derive(Debug)]
233    struct JudgeError(String);
234    impl std::fmt::Display for JudgeError {
235        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
236            write!(f, "{}", self.0)
237        }
238    }
239    impl std::error::Error for JudgeError {}
240
241    struct SeqMockJudge {
242        replies: Vec<String>,
243        call: Arc<AtomicUsize>,
244        last_user: Arc<Mutex<Option<String>>>,
245    }
246    impl SeqMockJudge {
247        fn new(replies: Vec<String>) -> Self {
248            Self {
249                replies,
250                call: Arc::new(AtomicUsize::new(0)),
251                last_user: Arc::new(Mutex::new(None)),
252            }
253        }
254        fn last_user_content(&self) -> String {
255            self.last_user
256                .lock()
257                .unwrap_or_else(|e| e.into_inner())
258                .clone()
259                .unwrap_or_default()
260        }
261    }
262
263    #[async_trait]
264    impl Runnable<Vec<Message>, LLMResult> for SeqMockJudge {
265        type Error = JudgeError;
266        async fn invoke(
267            &self,
268            _input: Vec<Message>,
269            _config: Option<RunnableConfig>,
270        ) -> Result<LLMResult, Self::Error> {
271            Err(JudgeError("use chat".into()))
272        }
273    }
274    #[async_trait]
275    impl BaseLanguageModel<Vec<Message>, LLMResult> for SeqMockJudge {
276        fn model_name(&self) -> &str {
277            "seq-mock"
278        }
279        fn get_num_tokens(&self, t: &str) -> usize {
280            t.len()
281        }
282        fn with_temperature(self, _: f32) -> Self {
283            self
284        }
285        fn with_max_tokens(self, _: usize) -> Self {
286            self
287        }
288    }
289    #[async_trait]
290    impl BaseChatModel for SeqMockJudge {
291        async fn chat(
292            &self,
293            messages: Vec<Message>,
294            _config: Option<RunnableConfig>,
295        ) -> Result<LLMResult, Self::Error> {
296            let idx = self.call.fetch_add(1, Ordering::SeqCst);
297            let reply = self.replies.get(idx).cloned().unwrap_or_default();
298            if let Some(human) = messages
299                .iter()
300                .find(|m| m.message_type == MessageType::Human)
301            {
302                *self.last_user.lock().unwrap_or_else(|e| e.into_inner()) =
303                    Some(human.content.clone());
304            }
305            Ok(LLMResult {
306                content: reply,
307                model: "seq-mock".to_string(),
308                token_usage: None,
309                tool_calls: None,
310                thinking_content: None,
311            })
312        }
313        async fn stream_chat(
314            &self,
315            _messages: Vec<Message>,
316            _config: Option<RunnableConfig>,
317        ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>
318        {
319            Err(JudgeError("not supported".into()))
320        }
321    }
322
323    #[test]
324    fn test_split_claims() {
325        let claims = split_claims("巴黎是法国首都。伦敦是英国首都。");
326        assert_eq!(claims.len(), 2);
327        assert_eq!(claims[0], "巴黎是法国首都");
328        assert_eq!(claims[1], "伦敦是英国首都");
329    }
330
331    #[test]
332    fn test_split_claims_empty() {
333        assert!(split_claims("").is_empty());
334        assert!(split_claims("。。。").is_empty());
335    }
336
337    #[tokio::test]
338    async fn test_faithfulness_all_supported() {
339        let judge = Faithfulness::new(SeqMockJudge::new(vec!["是".into(), "是".into()]));
340        let s = judge
341            .eval("", "巴黎是法国首都。伦敦是英国首都。", "ctx")
342            .await
343            .unwrap();
344        assert!((s.value - 1.0).abs() < 1e-9);
345    }
346
347    #[tokio::test]
348    async fn test_faithfulness_half_supported() {
349        let judge = Faithfulness::new(SeqMockJudge::new(vec!["是".into(), "否".into()]));
350        let s = judge
351            .eval("", "巴黎是法国首都。伦敦是英国首都。", "ctx")
352            .await
353            .unwrap();
354        assert!((s.value - 0.5).abs() < 1e-9);
355    }
356
357    #[tokio::test]
358    async fn test_faithfulness_none_supported() {
359        let judge = Faithfulness::new(SeqMockJudge::new(vec!["否".into(), "否".into()]));
360        let s = judge
361            .eval("", "巴黎是法国首都。伦敦是英国首都。", "ctx")
362            .await
363            .unwrap();
364        assert!((s.value - 0.0).abs() < 1e-9);
365    }
366
367    #[tokio::test]
368    async fn test_faithfulness_empty_prediction() {
369        // P0-2: empty prediction defaults to 0 (no answer = not faithful)
370        let judge = Faithfulness::new(SeqMockJudge::new(vec![]));
371        let s = judge.eval("", "", "ctx").await.unwrap();
372        assert!((s.value - 0.0).abs() < 1e-9);
373        assert_eq!(s.label.as_deref(), Some("no_claims"));
374    }
375
376    #[tokio::test]
377    async fn test_faithfulness_empty_score_configurable() {
378        // can be explicitly configured to 1.0 (not fabricating is faithful)
379        let judge = Faithfulness::new(SeqMockJudge::new(vec![])).with_empty_score(1.0);
380        let s = judge.eval("", "", "ctx").await.unwrap();
381        assert!((s.value - 1.0).abs() < 1e-9);
382    }
383
384    #[tokio::test]
385    async fn test_faithfulness_llm_split() {
386        // rule split counts a comma compound as 1 claim; LLM split divides it into 2 and verifies each
387        let judge = Faithfulness::new(SeqMockJudge::new(vec![
388            "巴黎是法国首都\n伦敦是英国首都".into(),
389            "是".into(),
390            "是".into(),
391        ]))
392        .with_llm_split(true);
393        let s = judge
394            .eval("", "巴黎是法国首都,伦敦是英国首都。", "ctx")
395            .await
396            .unwrap();
397        assert!((s.value - 1.0).abs() < 1e-9);
398    }
399
400    #[test]
401    fn test_parse_yes_no() {
402        assert_eq!(parse_yes_no("是"), Some(true));
403        assert_eq!(parse_yes_no("yes"), Some(true));
404        assert_eq!(parse_yes_no("否"), Some(false));
405        assert_eq!(parse_yes_no("no"), Some(false));
406        assert_eq!(parse_yes_no("不是"), Some(false));
407        assert_eq!(parse_yes_no("不能"), Some(false));
408        // no yes/no marker = parse failure, must not silently default
409        assert_eq!(parse_yes_no("我不会告诉你"), None);
410    }
411
412    /// P0-1: models supporting bind_tools use structured output (boolean verdict), no longer relying on text parsing.
413    #[tokio::test]
414    async fn test_faithfulness_structured_verdict() {
415        use crate::test_support::ToolJudge;
416        // two claims: one supported, one not -> faithfulness 0.5
417        let judge = Faithfulness::new(ToolJudge::sequence(vec![
418            r#"{"verdict": true, "reason": "能从上下文推导"}"#.into(),
419            r#"{"verdict": false, "reason": "无法推导"}"#.into(),
420        ]));
421        let s = judge
422            .eval("", "巴黎是法国首都。伦敦是英国首都。", "巴黎是法国首都")
423            .await
424            .unwrap();
425        assert!((s.value - 0.5).abs() < 1e-9);
426    }
427
428    /// P0-1: all unsupported -> 0 score.
429    #[tokio::test]
430    async fn test_faithfulness_structured_all_false() {
431        use crate::test_support::ToolJudge;
432        let judge = Faithfulness::new(ToolJudge::new(
433            r#"{"verdict": false, "reason": "均无法推导"}"#,
434        ));
435        let s = judge
436            .eval("", "巴黎是法国首都。伦敦是英国首都。", "巴黎是法国首都")
437            .await
438            .unwrap();
439        assert!((s.value - 0.0).abs() < 1e-9);
440    }
441
442    /// P2-5: a long reference context is truncated once and reused by N claims, not re-sent in full.
443    #[tokio::test]
444    async fn test_faithfulness_reference_truncated_once() {
445        let judge = SeqMockJudge::new(vec!["是".into(), "是".into()]);
446        let f = Faithfulness::new(judge).with_max_context_chars(10);
447        let long_ref =
448            "这是一段非常长的参考上下文,远超默认的单条传输上限,里面藏了一个不该被完整发送的尾巴"
449                .to_string();
450        let s = f
451            .eval("", "巴黎是首都。伦敦是首都。", &long_ref)
452            .await
453            .unwrap();
454        assert!((s.value - 1.0).abs() < 1e-9);
455        let sent = f.judge.last_user_content();
456        // the reference context is truncated to budget: the head remains, the far-beyond-budget tail is not sent
457        assert!(sent.contains("这是一段非常长"), "actual sent: {sent}");
458        assert!(
459            !sent.contains("不该被完整发送"),
460            "full long reference was sent repeatedly"
461        );
462    }
463}