File size: 5,859 Bytes
2b63102 8f6bb93 0b87551 8f6bb93 2b63102 98714ab 2b63102 8f6bb93 98714ab 8f6bb93 2b63102 98714ab 8f6bb93 98714ab 8f6bb93 0b87551 8f6bb93 df4d82f 2b63102 df4d82f 2b63102 df4d82f 8f6bb93 2b63102 df4d82f 2b63102 df4d82f 8f6bb93 98714ab 2b63102 98714ab 8f6bb93 df4d82f 2b63102 df4d82f 98714ab 2b63102 8f6bb93 98714ab 8f6bb93 0b87551 8f6bb93 98714ab 2b63102 98714ab df4d82f 98714ab df4d82f 98714ab 2b63102 98714ab 8f6bb93 2b63102 8f6bb93 2b63102 8f6bb93 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 | """Local answer-quality metrics: faithfulness (NLI), relevance (cosine), accuracy (ROUGE).
No LLM/API calls, so it stays cheap and CPU-friendly. Model libraries are imported lazily
inside the functions that need them, keeping module import (and CI) fast."""
import re
import logging
import numpy as np
from rouge_score import rouge_scorer
logger = logging.getLogger(__name__)
# FEVER/ANLI-trained NLI model: reliable for fact verification, unlike smaller NLI models
# that mislabel subset/superset and compound claims.
_NLI_MODEL_NAME = "MoritzLaurer/DeBERTa-v3-base-mnli-fever-anli"
_TOP_EVIDENCE = 4
# Leading proper-noun subject of 2-4 capitalized words, e.g. "Devi Sri Bandaru ".
_SUBJECT_RE = re.compile(r"^[A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,3}\s+")
_nli_model = None
_entail_idx = None
_rouge = rouge_scorer.RougeScorer(["rougeL"], use_stemmer=True)
def _nli():
"""Load the NLI cross-encoder once and resolve its entailment label index
(label order differs between models, so read it from the config)."""
global _nli_model, _entail_idx
if _nli_model is None:
from sentence_transformers import CrossEncoder
_nli_model = CrossEncoder(_NLI_MODEL_NAME)
id2label = _nli_model.model.config.id2label
_entail_idx = next(i for i, lbl in id2label.items() if "entail" in lbl.lower())
return _nli_model
def _softmax(logits: np.ndarray) -> np.ndarray:
logits = np.atleast_2d(logits)
exp = np.exp(logits - logits.max(axis=1, keepdims=True))
return exp / exp.sum(axis=1, keepdims=True)
def _cosine(text_a: str, text_b: str) -> float:
if not text_a.strip() or not text_b.strip():
return 0.0
from sentence_transformers import util
from src.embeddings import get_sentence_transformer
m = get_sentence_transformer()
return round(float(util.cos_sim(
m.encode(text_a, convert_to_tensor=True),
m.encode(text_b, convert_to_tensor=True),
)), 3)
def _is_claim(text: str) -> bool:
"""Keep verifiable statements; drop list lead-ins and "(Source: file.pdf)" lines."""
if len(text) <= 15 or text.endswith(":"):
return False
return not re.match(r"^\(?\s*sources?\s*:", text, re.IGNORECASE)
def _split_sentences(text: str) -> list[str]:
"""Break an answer into individual claims, handling bullet lists and markdown."""
parts = re.split(r"(?<=[.!?])\s+|\n+", text.strip())
cleaned = []
for p in parts:
p = p.lstrip("*-•· \t")
p = re.sub(r"[*_`]+", "", p).strip()
cleaned.append(p)
return [p for p in cleaned if _is_claim(p)]
def _split_evidence(context: str) -> list[str]:
"""Source text split into single sentences (NLI is far more reliable per-sentence
than against a whole multi-sentence chunk). Source markers are stripped first."""
cleaned = re.sub(r"\[(?:File|Source)[^\]]*\]", " ", context)
parts = re.split(r"(?<=[.!?])\s+|\n+", cleaned.strip())
return [p.strip() for p in parts if len(p.strip()) > 15]
def _strip_subject(claim: str) -> str:
"""Drop a leading proper-noun subject so a subjectless source sentence can still
entail the fact. Only ever used as an extra variant (we keep the max), so a wrong
strip never lowers the score."""
return _SUBJECT_RE.sub("", claim)
def faithfulness_score(answer: str, source_context: str) -> float:
"""Mean entailment of each answer claim against the source it was drawn from (0-1).
Each claim is tested against its most similar source sentences and their
concatenation, keeping the best match. A claim is faithful if some evidence entails
it, so the score is robust to irrelevant passages while contradicted or unsupported
claims fall toward 0."""
if not answer.strip() or not source_context.strip():
return 0.0
claims = _split_sentences(answer) or [answer.strip()]
evidence = _split_evidence(source_context)
if not evidence:
return 0.0
try:
from sentence_transformers import util
from src.embeddings import get_sentence_transformer
model = get_sentence_transformer()
ev_emb = model.encode(evidence, convert_to_tensor=True)
cl_emb = model.encode(claims, convert_to_tensor=True)
sims = util.cos_sim(cl_emb, ev_emb)
topk = min(_TOP_EVIDENCE, len(evidence))
pairs, owners = [], []
for i in range(len(claims)):
idxs = sims[i].topk(topk).indices.tolist()
best = evidence[idxs[0]]
concat = " ".join(evidence[j] for j in idxs)
premises = [best] if best == concat else [best, concat]
variants = [claims[i]]
stripped = _strip_subject(claims[i])
if stripped != claims[i] and len(stripped) > 10:
variants.append(stripped)
for premise in premises:
for variant in variants:
pairs.append((premise, variant))
owners.append(i)
entail = _softmax(np.asarray(_nli().predict(pairs)))[:, _entail_idx]
per_claim = [0.0] * len(claims)
for owner, e in zip(owners, entail):
per_claim[owner] = max(per_claim[owner], float(e))
return round(float(np.mean(per_claim)), 3)
except Exception as e:
logger.warning(f"NLI faithfulness unavailable ({e}); falling back to cosine.")
return _cosine(answer, source_context)
def answer_relevance_score(question: str, answer: str) -> float:
"""Cosine similarity between the question and the answer (0-1)."""
return _cosine(question, answer)
def accuracy_score(answer: str, reference: str) -> float:
"""ROUGE-L F1 between the answer and a user-supplied reference (0-1)."""
if not answer.strip() or not reference.strip():
return 0.0
return round(_rouge.score(reference, answer)["rougeL"].fmeasure, 3)
|