|
|
| """
|
| The extractive reader: find the answer SPAN inside retrieved passages.
|
|
|
| WHY THIS COMPONENT CARRIES THE LATENCY PLAN
|
| -------------------------------------------
|
| src/extractability.py measured that 58.6% of answerable queries have their answer
|
| present verbatim (exact or token-subsequence) in the gold passage. Those queries
|
| never need a language model -- copy the span and you are done in single-digit
|
| milliseconds, on a MIG slice where generating 64 tokens costs 640-1000 ms.
|
|
|
| The reader also produces the CONFIDENCE that src/router.py routes on, and that
|
| src/router.calibrate_threshold turns into a guaranteed error bound. One score,
|
| three jobs: answer, route, abstain.
|
|
|
| TWO IMPLEMENTATIONS
|
| -------------------
|
| LexicalSpanReader no model, no GPU, ~0.3 ms. IDF-weighted overlap + answer-type
|
| priors. Works today, and is the honest baseline the neural
|
| reader must beat.
|
| NeuralSpanReader bge-m3 encoder + start/end heads, trained by weak supervision
|
| (src/span_labels.py locates `Answer` inside
|
| `Translated_passages`). Loads a checkpoint if one exists and
|
| falls back to lexical otherwise, so the pipeline never breaks
|
| because training has not finished.
|
|
|
| CALIBRATION MATTERS MORE THAN ACCURACY HERE
|
| -------------------------------------------
|
| Coverage at a fixed error bound is set by how well confidence separates right from
|
| wrong, not by raw accuracy. A reader that is 70% accurate and well calibrated
|
| beats one that is 75% accurate and badly calibrated, because the calibrated one
|
| can answer far more queries at the same guaranteed error rate. So the score is
|
| built from a MARGIN (best vs runner-up), which is what separates, rather than an
|
| absolute similarity, which is not.
|
| """
|
| from __future__ import annotations
|
|
|
| import math
|
| import re
|
| import unicodedata
|
| from dataclasses import dataclass
|
| from typing import Sequence
|
|
|
| from src.chunkers.base import split_sentences, words_of
|
| from src.router import Passage, Span
|
| from src.textnorm import normalise
|
|
|
| __all__ = ["LexicalSpanReader", "NeuralSpanReader", "AnswerType", "detect_answer_type"]
|
|
|
| _WS = re.compile(r"\s+")
|
|
|
|
|
| _DIGITS = re.compile(r"[0-9०-९০-৯੦-੯૦-૯"
|
| r"୦-୯௦-௯౦-౯೦-೯"
|
| r"൦-൯٠-٩۰-۹]")
|
|
|
|
|
| class AnswerType:
|
| NUMERIC = "NUMERIC"
|
| PERSON = "PERSON"
|
| LOCATION = "LOCATION"
|
| ENTITY = "ENTITY"
|
| DESCRIPTION = "DESCRIPTION"
|
|
|
|
|
|
|
| _CUES = {
|
| AnswerType.NUMERIC: (
|
| "how many", "how much", "how long", "how old", "how far", "how fast",
|
| "how tall", "how deep", "how high", "how wide", "how heavy", "how big",
|
| "what year", "what age", "what speed", "what time", "what temperature",
|
| "what percentage", "what is the average", "when did", "when was", "when is",
|
| "cost", "price", "average", "percentage", "temperature", "salary", "rate",
|
| "कितन", "कितना", "कितने", "कब", "एत्तनै",
|
| "எத்தனை", "எவ்வளவு", "எப்போது", "എത്ര", "എപ്പോൾ",
|
| "ఎన్ని", "ఎంత", "ఎప్పుడు", "ಎಷ್ಟು", "ಯಾವಾಗ",
|
| "کتنا", "کتنے", "کب", "কত", "কখন", "કેટલા", "ਕਿੰਨਾ", "କେତେ"),
|
| AnswerType.PERSON: ("who is", "who was", "who did", "who are", "whose",
|
| "कौन", "யார்", "ആര്", "ఎవరు", "ಯಾರು", "کون"),
|
| AnswerType.LOCATION: ("where is", "where are", "where did", "location of",
|
| "कहां", "कहाँ", "எங்கே", "എവിടെ", "ఎక్కడ", "ಎಲ್ಲಿ", "کہاں"),
|
| AnswerType.ENTITY: ("what is the name", "which company", "which country",
|
| "what type", "what kind"),
|
| }
|
|
|
|
|
| def detect_answer_type(query: str) -> str:
|
| """Cheap answer-type prior from the query text.
|
|
|
| NB: query_type exists in the dataset but NOT at inference time, so it has to
|
| be inferred. It is only a prior -- extractability showed the type spread is
|
| 24 pp, too narrow to route on, so it nudges span scores rather than deciding
|
| anything.
|
| """
|
| q = query.lower()
|
| for t, cues in _CUES.items():
|
| if any(c in q for c in cues):
|
| return t
|
| return AnswerType.DESCRIPTION
|
|
|
|
|
| _ES_STEMS = ("s", "x", "z", "ch", "sh")
|
|
|
|
|
| _IND_PREFIX = 5
|
|
|
|
|
| def _fold_indic(t: str) -> str:
|
| """Fold an Indic token to a prefix so inflected forms match.
|
|
|
| The comment below used to say exact match is enough because query and
|
| passage share a language. It is not: these morphologies are agglutinative,
|
| so a live Kannada query for ಕಾರ್ಪೊರೇಷನ್ met ಕಾರ್ಪೊರೇಷನ್ಗಳು in the passage
|
| and overlapped on nothing. Every candidate then scored 0, _confidence
|
| returned 0 for a best score of 0, and the reader shipped whichever sentence
|
| happened to sort first while reporting conf 0.00. It was not choosing.
|
|
|
| A prefix cannot end on a combining mark: Indic vowel signs and viramas are
|
| separate code points, and cutting before the base they attach to leaves a
|
| fragment that matches things it should not.
|
| """
|
| if len(t) <= _IND_PREFIX:
|
| return t
|
| import unicodedata
|
|
|
| p = t[:_IND_PREFIX]
|
| while p and unicodedata.category(p[-1]) in ("Mn", "Mc"):
|
| p = p[:-1]
|
| return p or t[:_IND_PREFIX]
|
|
|
|
|
| def _stem(t: str) -> str:
|
| """Crude suffix strip for Latin script so 'eagle' matches 'eagles'.
|
|
|
| Order is load-bearing: stripping "es" before "s" turns 'eagles' into 'eagl'
|
| while 'eagle' stays 'eagle', so the two stop matching -- the exact bug this
|
| function exists to prevent. "es" is only a plural marker after s/x/z/ch/sh
|
| (boxes, dishes); everywhere else the plural is a bare "s".
|
|
|
| Deliberately not applied to Indic tokens: their morphology is agglutinative
|
| and chopping trailing characters destroys meaning. Query and passage are in
|
| the SAME language, so exact match already works there.
|
| """
|
| if not t.isascii():
|
| return _fold_indic(t)
|
| if len(t) < 4 or not t.isalpha():
|
| return t
|
| if t.endswith("ies") and len(t) > 4:
|
| return t[:-3] + "y"
|
| if t.endswith("es") and len(t) > 4 and t[:-2].endswith(_ES_STEMS):
|
| return t[:-2]
|
| if t.endswith("s") and not t.endswith("ss") and len(t) > 3:
|
| return t[:-1]
|
| if t.endswith("ing") and len(t) > 5:
|
| return t[:-3]
|
| if t.endswith("ed") and len(t) > 4:
|
| return t[:-2]
|
| return t
|
|
|
|
|
| def _norm(s: str) -> str:
|
|
|
|
|
|
|
| return normalise(s, True)
|
|
|
|
|
| def _norm_tokens(s: str) -> list[str]:
|
| return [_stem(t) for t in _norm(s).split()]
|
|
|
|
|
| _STOP = {"the","a","an","of","is","are","was","were","to","in","for","on","and",
|
| "do","does","did","how","what","when","where","who","which","that","this",
|
| "it","its","be","been","by","with","as","at","from","or","not"}
|
|
|
|
|
| def _idf(passages: Sequence[str]) -> dict[str, float]:
|
| """IDF over the retrieved set. Rare query terms should dominate the match;
|
| without this, stopwords decide the span."""
|
| n = max(1, len(passages))
|
| df: dict[str, int] = {}
|
| for p in passages:
|
| for t in set(_norm_tokens(p)):
|
| df[t] = df.get(t, 0) + 1
|
| return {t: math.log(1 + n / c) for t, c in df.items()}
|
|
|
|
|
|
|
| _TARGET_WORDS = {
|
| AnswerType.NUMERIC: 9.0,
|
| AnswerType.LOCATION: 8.0,
|
| AnswerType.PERSON: 10.0,
|
| AnswerType.ENTITY: 12.0,
|
| AnswerType.DESCRIPTION: 16.0,
|
| }
|
|
|
|
|
| @dataclass(slots=True)
|
| class _Cand:
|
| text: str
|
| score: float
|
| chunk_id: str
|
| start: int
|
| end: int
|
|
|
|
|
| class LexicalSpanReader:
|
| """No model. Sentences scored by IDF-weighted query overlap, then a sub-span
|
| search inside the winner when the question wants a short answer.
|
|
|
| Confidence is the MARGIN between the best and runner-up spans, squashed to
|
| [0,1]. Margin separates correct from incorrect far better than an absolute
|
| score, which is what the conformal threshold needs.
|
| """
|
| name = "lexical"
|
|
|
| def __init__(self, max_span_words: int = 25, min_span_words: int = 1,
|
| margin_scale: float = 6.0, prior_weight: float = 0.0,
|
| answer_mode: str = "span"):
|
| self.max_span, self.min_span, self.scale = max_span_words, min_span_words, margin_scale
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| self.prior_weight = prior_weight
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| self.answer_mode = answer_mode if answer_mode in ("span", "sentence") else "span"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| def _score_tokens(self, span_tokens: list[str], q_tokens: set[str],
|
| idf: dict[str, float], atype: str) -> float:
|
| if not span_tokens:
|
| return 0.0
|
|
|
|
|
| content = {t for t in q_tokens if t not in _STOP}
|
| if not content:
|
| content = q_tokens
|
| hit = sum(idf.get(t, 1.0) for t in set(span_tokens) & content)
|
| total = sum(idf.get(t, 1.0) for t in content) or 1.0
|
| cover = hit / total
|
|
|
|
|
|
|
| n = len(span_tokens)
|
| length_pen = math.exp(
|
| -((math.log(max(n, 1) / _TARGET_WORDS.get(atype, 12.0))) ** 2) / 2.0)
|
|
|
| prior = 1.0
|
| joined = " ".join(span_tokens)
|
| if atype == AnswerType.NUMERIC:
|
| prior = 1.35 if _DIGITS.search(joined) else 0.75
|
| elif atype in (AnswerType.PERSON, AnswerType.LOCATION, AnswerType.ENTITY):
|
| caps = sum(1 for t in span_tokens if t[:1].isupper())
|
| prior = 1.0 + 0.25 * min(caps, 3) / 3.0
|
| return cover * length_pen * prior
|
|
|
| def _score_subspan(self, span: list[str], span_at: int, sent: list[str],
|
| q_tokens: set[str], atype: str) -> float:
|
| """Score a candidate answer span INSIDE the winning sentence."""
|
| n = len(span)
|
| if n == 0:
|
| return 0.0
|
| joined = " ".join(span)
|
|
|
|
|
| if atype == AnswerType.NUMERIC:
|
| fit = 1.0 if _DIGITS.search(joined) else 0.05
|
| elif atype in (AnswerType.PERSON, AnswerType.LOCATION, AnswerType.ENTITY):
|
| caps = sum(1 for t in span if t[:1].isupper())
|
| fit = 0.20 + 0.80 * min(caps, 3) / 3.0
|
| else:
|
| fit = 1.0
|
|
|
|
|
| overlap = len({_stem(_norm(t)) for t in span} & q_tokens) / n
|
| novelty = 1.0 - 0.70 * overlap
|
|
|
|
|
| anchors = [i for i, t in enumerate(sent) if _stem(_norm(t)) in q_tokens]
|
| if anchors:
|
| dist = min(abs(span_at - a) for a in anchors)
|
| prox = 1.0 / (1.0 + 0.12 * dist)
|
| else:
|
| prox = 0.6
|
|
|
|
|
|
|
|
|
|
|
| target = _TARGET_WORDS.get(atype, 12.0)
|
| length = math.exp(-((math.log(max(n, 1) / target)) ** 2) / 2.0)
|
|
|
|
|
|
|
|
|
|
|
| boundary = 1.0
|
| if _norm(span[-1]) in _STOP:
|
| boundary *= 0.30
|
| if _norm(span[0]) in _STOP:
|
| boundary *= 0.60
|
|
|
| return fit * novelty * prox * length * boundary
|
|
|
| def read(self, query: str, passages: Sequence[Passage]) -> Span:
|
| if not passages:
|
| return Span("", 0.0, "")
|
| atype = detect_answer_type(query)
|
| texts = [p.text for p in passages]
|
| idf = _idf(texts)
|
| q_tokens = set(_norm_tokens(query))
|
|
|
|
|
|
|
|
|
| raw = [float(p.score) for p in passages]
|
| lo, hi = min(raw), max(raw)
|
| rng = hi - lo
|
|
|
| cands: list[_Cand] = []
|
| for p in passages:
|
| if self.prior_weight <= 0 or rng <= 0:
|
| prior_mult = 1.0
|
| else:
|
| rel = (float(p.score) - lo) / rng
|
| prior_mult = (1.0 - self.prior_weight) + self.prior_weight * rel
|
| words = words_of(p.text)
|
| norm_words = _norm_tokens(p.text)
|
| if len(norm_words) != len(words):
|
| norm_words = [_stem(_norm(w)) or w.lower() for w in words]
|
| cursor = 0
|
| for sent in split_sentences(p.text):
|
| n = len(words_of(sent))
|
| if n == 0:
|
| continue
|
| s, e = cursor, cursor + n
|
| cursor = e
|
| sc = self._score_tokens(norm_words[s:e], q_tokens, idf, atype) * prior_mult
|
| cands.append(_Cand(" ".join(words[s:e]), sc, p.chunk_id, s, e))
|
|
|
| if not cands:
|
| return Span("", 0.0, passages[0].chunk_id)
|
| cands.sort(key=lambda c: -c.score)
|
| best = cands[0]
|
|
|
|
|
|
|
|
|
|
|
| if atype == AnswerType.NUMERIC and not _DIGITS.search(best.text):
|
| withnum = [c for c in cands if _DIGITS.search(c.text)]
|
| if withnum and withnum[0].score >= 0.35 * best.score:
|
| best = withnum[0]
|
|
|
|
|
|
|
|
|
|
|
|
|
| sentence_span = best
|
| if atype != AnswerType.DESCRIPTION and self.answer_mode == "span":
|
| p = next((x for x in passages if x.chunk_id == best.chunk_id), passages[0])
|
| words = words_of(p.text)[best.start:best.end]
|
| if 1 < len(words) <= 80:
|
| sub, sub_sc = None, 0.0
|
| for i in range(len(words)):
|
| for L in range(self.min_span, min(self.max_span, len(words) - i) + 1):
|
| sc = self._score_subspan(words[i:i + L], i, words, q_tokens, atype)
|
| if sc > sub_sc:
|
| sub_sc, sub = sc, (i, i + L)
|
|
|
| if sub and sub_sc >= 0.45:
|
| i, j = sub
|
| best = _Cand(" ".join(words[i:j]), best.score, best.chunk_id,
|
| best.start + i, best.start + j)
|
|
|
|
|
|
|
| runner = cands[1].score if len(cands) > 1 else 0.0
|
| conf = self._confidence(sentence_span.score, runner)
|
| return Span(best.text, conf, best.chunk_id, best.start, best.end)
|
|
|
| def _confidence(self, best: float, runner: float) -> float:
|
| """Margin -> [0,1]. Absolute scores are not comparable across queries;
|
| the gap to the runner-up is."""
|
| if best <= 0:
|
| return 0.0
|
| margin = (best - runner) / best
|
| strength = min(1.0, best)
|
| return 1.0 / (1.0 + math.exp(-self.scale * (0.5 * margin + 0.5 * strength - 0.5)))
|
|
|
|
|
| def __call__(self, query: str, passages: Sequence[Passage]) -> Span:
|
| return self.read(query, passages)
|
|
|
|
|
| class NeuralSpanReader:
|
| """bge-m3 encoder + start/end heads. Falls back to lexical without a checkpoint.
|
|
|
| Kept deliberately thin: the router only needs (text, score, chunk_id), so the
|
| model can be swapped or trained later without touching anything downstream.
|
| """
|
| name = "neural"
|
|
|
| def __init__(self, model_path: str | None = None, checkpoint: str | None = None,
|
| device: str = "cuda", max_len: int = 384,
|
| fallback: LexicalSpanReader | None = None):
|
| self.fallback = fallback or LexicalSpanReader()
|
| self.model = None
|
| if not (model_path and checkpoint):
|
| return
|
| try:
|
| import torch
|
| from transformers import AutoModel, AutoTokenizer
|
| self.torch = torch
|
| self.tok = AutoTokenizer.from_pretrained(model_path)
|
| enc = AutoModel.from_pretrained(
|
| model_path, torch_dtype=torch.float16 if "cuda" in device else torch.float32)
|
| hidden = enc.config.hidden_size
|
| self.head = torch.nn.Linear(hidden, 2)
|
| state = torch.load(checkpoint, map_location="cpu")
|
| enc.load_state_dict(state["encoder"], strict=False)
|
| self.head.load_state_dict(state["head"])
|
| self.model = enc.to(device).eval()
|
| self.head = self.head.to(device).to(self.model.dtype).eval()
|
| self.device, self.max_len = device, max_len
|
| except Exception as exc:
|
| print(f" reader: no checkpoint loaded ({exc}); using lexical fallback")
|
| self.model = None
|
|
|
| def read(self, query: str, passages: Sequence[Passage]) -> Span:
|
| if self.model is None or not passages:
|
| return self.fallback.read(query, passages)
|
| torch = self.torch
|
| best = None
|
| with torch.inference_mode():
|
| for p in passages[:4]:
|
| enc = self.tok(query, p.text, truncation="only_second",
|
| max_length=self.max_len, return_offsets_mapping=True,
|
| return_tensors="pt").to(self.device)
|
| offsets = enc.pop("offset_mapping")[0].tolist()
|
| h = self.model(**enc).last_hidden_state
|
| logits = self.head(h)[0].float()
|
| start_lp = torch.log_softmax(logits[:, 0], -1)
|
| end_lp = torch.log_softmax(logits[:, 1], -1)
|
| n = start_lp.shape[0]
|
|
|
| scores = start_lp[:, None] + end_lp[None, :]
|
| mask = torch.triu(torch.ones(n, n, device=scores.device), 0) - \
|
| torch.triu(torch.ones(n, n, device=scores.device), 41)
|
| scores = scores.masked_fill(mask <= 0, -1e9)
|
| flat = int(scores.argmax())
|
| i, j = flat // n, flat % n
|
| lp = float(scores.view(-1)[flat])
|
| if best is None or lp > best[0]:
|
| a, b = offsets[i][0], offsets[j][1]
|
| best = (lp, p.text[a:b], p.chunk_id, i, j)
|
| if best is None:
|
| return self.fallback.read(query, passages)
|
| lp, text, cid, i, j = best
|
| return Span(text.strip(), float(math.exp(min(0.0, lp))), cid, i, j)
|
|
|
| def __call__(self, query: str, passages: Sequence[Passage]) -> Span:
|
| return self.read(query, passages)
|
|
|