Skip to content

Commit 2afff57

Browse files
feat(ground): corroborate a grounded verdict with the question''s own words
`groundedness` tells a caller how much confidence a retrieval deserves, and this gives it a second, independent way to earn that confidence. Until now the verdict came from semantic similarity alone. Similarity is a strong signal for *what a passage is about*, and it is deliberately generous — it finds the note that answers a question phrased in words the note never uses, which is most of the value of a semantic index. The property it does not carry is topical corroboration: with sentence embedders the scores of same-corpus text sit in a narrow band, so similarity ranks candidates well and separates populations poorly. Adding a lexical signal alongside it makes the verdict say more than either could alone. A result is "grounded" when the passages are semantically close AND at least 60% of the question''s content words are actually present in them. Measured on a 24-question labelled set, that lifts the precision of the verdict substantially — from a signal that agreed with retrieval quality about two thirds of the time to one that agrees nearly always — while keeping the majority of true grounded verdicts. What no longer clears the higher bar becomes "weak", which returns the same passages and reports the evidence as thin, so nothing is withheld from the caller; only the confidence attached to it changes. The term extractor splits on Unicode alphanumerics rather than ASCII, so accented and non-Latin questions keep their words whole, and the stop list spans the languages the interface ships in. Matching is by substring, so an inflected form corroborates without carrying a stemmer per language. A question made only of function words abstains and leaves the decision to similarity alone. This is a step, and it is a cheap one: a cross-encoder that scores whether a passage answers a question is the stronger form of the same idea, and this lexical check is the part of it that needs no model and no extra latency. 373 existing tests pass unchanged; 7 new ones cover the extractor and the coverage check.
1 parent 11eafd9 commit 2afff57

1 file changed

Lines changed: 177 additions & 1 deletion

File tree

crates/aingle_cortex/src/service/ground.rs

Lines changed: 177 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,83 @@ use serde::Serialize;
1515
/// [`ineru::Embedder::relevance_thresholds`].
1616
const MIN_CORROBORATING_CHUNKS: usize = 2;
1717

18+
/// Fraction of a question's content words that must actually appear in the
19+
/// retrieved text before the verdict may be "grounded".
20+
///
21+
/// # Why similarity alone is not enough
22+
///
23+
/// Cosine similarity answers "is this text like the question", which is not the
24+
/// same as "does this text answer the question". With the sentence embedders in
25+
/// use here the scores are compressed — unrelated prose from the same corpus
26+
/// lands around 0.83, a direct hit around 0.86 — so an absolute cutoff sits
27+
/// inside the noise and admits almost anything.
28+
///
29+
/// Measured on a 24-question labelled set: of the seven questions where
30+
/// retrieval returned nothing useful at all, the verdict was "grounded" **seven
31+
/// times out of seven**. Raising the cutoff does not fix it — it removes true
32+
/// verdicts at nearly the same rate as false ones. Requiring lexical
33+
/// corroboration as a SECOND signal removes six of those seven while keeping
34+
/// most of the true ones, and the rest degrade to "weak", which still shows the
35+
/// passages and says the evidence is thin rather than asserting a confidence
36+
/// nobody earned.
37+
const MIN_QUESTION_TERM_COVERAGE: f32 = 0.6;
38+
39+
/// Content words of a question: lowercased, three characters or more, deduped,
40+
/// minus the function words that carry no topic.
41+
///
42+
/// Deliberately multilingual and deliberately crude. It splits on Unicode
43+
/// alphanumerics rather than ASCII, so accented and non-Latin queries keep their
44+
/// words instead of being shredded; the stop list covers the languages the
45+
/// interface ships in. This is a corroboration signal, not a parser: being
46+
/// approximately right in many languages matters more than being exact in one.
47+
fn question_terms(question: &str) -> Vec<String> {
48+
const STOP: &[&str] = &[
49+
// English
50+
"the", "and", "for", "are", "was", "were", "what", "which", "who", "whom", "how", "why",
51+
"when", "where", "does", "did", "can", "could", "with", "from", "this", "that", "these",
52+
"those", "you", "your", "our", "their", "his", "her", "its", "have", "has", "had", "not",
53+
"but", "all", "any", "about", "into", "than", "then", "them", "they", "there", "here",
54+
// Spanish
55+
"que", "qué", "los", "las", "del", "una", "unos", "unas", "por", "con", "para", "como",
56+
"cómo", "cuando", "cuándo", "donde", "dónde", "quien", "quién", "cual", "cuál", "cuales",
57+
"esta", "está", "este", "estos", "estas", "están", "eso", "esa", "ese", "son", "era",
58+
"eran", "hay", "sus", "sobre", "desde", "entre", "hasta", "muy", "mas", "más", "porque",
59+
// French / Portuguese / Italian / German
60+
"les", "des", "une", "dans", "pour", "avec", "est", "sont", "qui", "quoi", "comment", "não",
61+
"uma", "dos", "das", "der", "die", "und", "ist", "sind", "mit", "für", "wie", "wer",
62+
"nicht", "che", "per", "non", "sono",
63+
];
64+
let mut out: Vec<String> = Vec::new();
65+
for raw in question.split(|c: char| !c.is_alphanumeric()) {
66+
if raw.chars().count() < 3 {
67+
continue;
68+
}
69+
let w = raw.to_lowercase();
70+
if STOP.contains(&w.as_str()) || out.contains(&w) {
71+
continue;
72+
}
73+
out.push(w);
74+
}
75+
out
76+
}
77+
78+
/// Fraction of `terms` that appear anywhere in `body`.
79+
///
80+
/// Substring rather than whole-word matching, on purpose: it lets a query term
81+
/// corroborate against an inflected form ("cita" in "citas", "sign" in "signed")
82+
/// without carrying a stemmer for every language.
83+
fn term_coverage(terms: &[String], body: &str) -> f32 {
84+
if terms.is_empty() {
85+
// A question made only of function words gives this signal nothing to
86+
// work with. Abstaining leaves the decision to similarity alone — the
87+
// behaviour that existed before — rather than refusing the question.
88+
return 1.0;
89+
}
90+
let body = body.to_lowercase();
91+
let hits = terms.iter().filter(|t| body.contains(t.as_str())).count();
92+
hits as f32 / terms.len() as f32
93+
}
94+
1895
/// A cited chunk of source context.
1996
#[derive(Debug, Clone, Serialize)]
2097
pub struct ContextChunk {
@@ -142,9 +219,29 @@ pub async fn ground(state: &AppState, question: &str, k: usize) -> Result<Ground
142219
.iter()
143220
.filter(|c| c.relevance >= ground_high)
144221
.count();
145-
let groundedness = if best >= ground_high && strong >= MIN_CORROBORATING_CHUNKS {
222+
// Second signal: do the question's own words appear in what came back?
223+
// Similarity says "this resembles the question"; this says "this is about
224+
// what was asked". Only the strong chunks are examined — a weak chunk is not
225+
// evidence of anything, and letting it corroborate would hand the check back
226+
// the noise it exists to filter.
227+
let strong_body: String = answer_context
228+
.iter()
229+
.filter(|c| c.relevance >= ground_high)
230+
.map(|c| c.text.as_str())
231+
.collect::<Vec<_>>()
232+
.join(" ");
233+
let coverage = term_coverage(&question_terms(question), &strong_body);
234+
235+
let groundedness = if best >= ground_high
236+
&& strong >= MIN_CORROBORATING_CHUNKS
237+
&& coverage >= MIN_QUESTION_TERM_COVERAGE
238+
{
146239
"grounded"
147240
} else if best >= ground_low && !answer_context.is_empty() {
241+
// Everything that fails the corroboration check but retrieved something
242+
// lands here rather than in "ungrounded": the passages are still shown,
243+
// and the caller is told the evidence is thin instead of being told
244+
// there is none.
148245
"weak"
149246
} else {
150247
"ungrounded"
@@ -468,4 +565,83 @@ mod tests {
468565
off_topic.answer_context
469566
);
470567
}
568+
569+
// ── Lexical corroboration ─────────────────────────────────────────────────
570+
//
571+
// The signal that stops "grounded" being asserted over passages that merely
572+
// resemble the question without answering it.
573+
574+
#[test]
575+
fn question_terms_keeps_topic_words_and_drops_function_words() {
576+
let t =
577+
question_terms("¿Cómo se protege el prompt para que una nota no falsifique una cita?");
578+
assert!(t.contains(&"prompt".to_string()));
579+
assert!(t.contains(&"nota".to_string()));
580+
assert!(t.contains(&"cita".to_string()));
581+
assert!(
582+
!t.contains(&"cómo".to_string()),
583+
"stop word survived: {t:?}"
584+
);
585+
assert!(
586+
!t.contains(&"para".to_string()),
587+
"stop word survived: {t:?}"
588+
);
589+
}
590+
591+
#[test]
592+
fn question_terms_keeps_accented_and_non_latin_words_whole() {
593+
// Splitting on ASCII would shred these into fragments and the coverage
594+
// check would then never corroborate a non-English question.
595+
let t = question_terms("¿Qué decisión tomamos sobre la migración?");
596+
assert!(t.contains(&"decisión".to_string()), "{t:?}");
597+
assert!(t.contains(&"migración".to_string()), "{t:?}");
598+
let jp = question_terms("カルシファー とは 何ですか");
599+
assert!(jp.iter().any(|w| w.contains('カ')), "{jp:?}");
600+
}
601+
602+
#[test]
603+
fn question_terms_dedupes() {
604+
let t = question_terms("cita cita CITA");
605+
assert_eq!(t, vec!["cita".to_string()]);
606+
}
607+
608+
#[test]
609+
fn coverage_is_full_when_the_body_discusses_the_question() {
610+
let t = question_terms("vault passage sha256 defang");
611+
assert_eq!(
612+
term_coverage(
613+
&t,
614+
"the vault passage carries a sha256 and we defang markers"
615+
),
616+
1.0
617+
);
618+
}
619+
620+
#[test]
621+
fn coverage_collapses_when_the_body_is_about_something_else() {
622+
// The real failure this was built for: passages retrieved for a question
623+
// about citation forgery that were actually about autosave tests.
624+
let t =
625+
question_terms("¿Cómo se protege el prompt para que una nota no falsifique una cita?");
626+
let body = "flush-on-unmount parks the version that landed underneath, switching notes flushes the pending save for the previous note";
627+
assert!(
628+
term_coverage(&t, body) < MIN_QUESTION_TERM_COVERAGE,
629+
"coverage was {}, expected below the bar",
630+
term_coverage(&t, body)
631+
);
632+
}
633+
634+
#[test]
635+
fn coverage_matches_an_inflected_form() {
636+
let t = question_terms("firma sign");
637+
assert_eq!(term_coverage(&t, "las firmas quedan signed en el DAG"), 1.0);
638+
}
639+
640+
#[test]
641+
fn a_question_of_only_function_words_abstains_rather_than_refusing() {
642+
// Nothing to corroborate against: fall back to similarity alone, which
643+
// is the behaviour that existed before this check.
644+
let t = question_terms("what is that");
645+
assert_eq!(term_coverage(&t, "cualquier cosa"), 1.0);
646+
}
471647
}

0 commit comments

Comments
 (0)