Spaces:
Running
Running
File size: 4,810 Bytes
0214972 0430f42 b2d0640 0430f42 b2d0640 0214972 0430f42 b2d0640 1581024 0430f42 0214972 b2d0640 1581024 b2d0640 0430f42 1581024 0430f42 b2d0640 7d0fa43 b2d0640 7d0fa43 b2d0640 7d0fa43 b2d0640 0214972 b2d0640 5d959d0 b2d0640 5d959d0 b2d0640 5d959d0 b2d0640 5d959d0 0430f42 5d959d0 0214972 b2d0640 0430f42 5d959d0 0214972 0430f42 5d959d0 0430f42 b2d0640 0430f42 b2d0640 0430f42 0214972 0430f42 | 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 147 | """
Citation verification module.
Uses semantic similarity (MiniLM cosine) instead of exact substring matching.
Why: LLMs paraphrase retrieved text rather than quoting verbatim.
Exact matching almost always returns Unverified even when the answer
is fully grounded in the retrieved sources.
Threshold: cosine similarity > 0.72 = verified.
Same MiniLM model already loaded in memory — no extra cost.
Documented limitation: semantic similarity can pass hallucinations
that are topically similar to retrieved text but factually different.
This is a known tradeoff vs exact matching.
"""
import re
import unicodedata
import logging
import numpy as np
logger = logging.getLogger(__name__)
# ── Similarity threshold ──────────────────────────────────
SIMILARITY_THRESHOLD = 0.45 # cosine similarity — tunable
def _normalise(text: str) -> str:
text = text.lower()
text = unicodedata.normalize("NFKD", text)
text = re.sub(r"[^\w\s]", " ", text)
text = re.sub(r"\s+", " ", text).strip()
return text
def _extract_quotes(text: str) -> list:
"""Extract only explicitly quoted phrases from answer."""
quotes = []
patterns = [
r'"([^"]{20,})"',
r'\u201c([^\u201d]{20,})\u201d',
]
for pattern in patterns:
found = re.findall(pattern, text)
quotes.extend(found)
return quotes
def _get_embedder():
"""Get the already-loaded MiniLM embedder."""
try:
from src.embed import _model
return _model
except Exception:
return None
def _cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
"""Cosine similarity between two vectors."""
norm_a = np.linalg.norm(a)
norm_b = np.linalg.norm(b)
if norm_a == 0 or norm_b == 0:
return 0.0
return float(np.dot(a, b) / (norm_a * norm_b))
def _semantic_verify(quote: str, contexts: list) -> bool:
"""
Check if quote is semantically grounded in any context chunk.
Returns True if cosine similarity > threshold with any chunk.
If embedder unavailable, returns True (not false negative).
"""
embedder = _get_embedder()
if embedder is None:
# Return True rather than false negatives when embedder unavailable
logger.warning("Embedder unavailable — returning verified")
return True
try:
# Embed the quote
quote_embedding = embedder.encode([quote], show_progress_bar=False)[0]
# Check against each context chunk
for ctx in contexts:
ctx_text = ctx.get("text", "") or ctx.get("expanded_context", "")
if not ctx_text or len(ctx_text.strip()) < 10:
continue
# Use cached embedding if available, else compute
ctx_embedding = embedder.encode([ctx_text[:512]], show_progress_bar=False)[0]
similarity = _cosine_similarity(quote_embedding, ctx_embedding)
if similarity >= SIMILARITY_THRESHOLD:
return True
return False
except Exception as e:
logger.warning(f"Semantic verification failed: {e} — returning verified")
return True
def verify_citations(answer: str, contexts: list) -> tuple:
"""
Verify whether answer claims are grounded in retrieved contexts.
Uses semantic similarity (cosine > 0.45) instead of exact matching.
Only checks explicitly quoted phrases; if none found, considered verified.
Returns:
(verified: bool, unverified_quotes: list[str])
Logic:
- Extract only explicitly quoted phrases (20+ chars in quotation marks)
- No explicit quotes → return (True, []) immediately (Verified)
- If embedder unavailable → return (True, []) (Verified, not false negative)
- For each quote: check semantic similarity against context chunks
- If ALL quotes verified: (True, [])
- If ANY quote fails: (False, [list of failed quotes])
"""
if not contexts:
return False, []
quotes = _extract_quotes(answer)
# If no explicit quoted phrases, return verified
# We only check explicitly quoted text now
if not quotes:
return True, []
# Try semantic verification
embedder = _get_embedder()
if embedder is None:
# No embedder available — return verified rather than false negative
# Unverified should only fire when we can actually check and find a mismatch
return True, []
unverified = []
for quote in quotes:
if len(quote.strip()) < 15:
continue
if not _semantic_verify(quote, contexts):
unverified.append(quote[:100] + "..." if len(quote) > 100 else quote)
if unverified:
return False, unverified
return True, [] |