"""BRAG - Bayesian RAG. Closed-form Bayesian orchestration for retrieval-augmented QA. The orchestrator itself makes no LLM calls: every decision is a few arithmetic operations on conjugate (Beta) posteriors. Actions (each is an LLM call made by the *executor*, never by the policy): R1 : read top-k retrieved passages -> answer or NONE, plus a follow-up search query R2 : retrieve with the follow-up query, read old + new passages -> answer or NONE (needs R1) CB : m parallel closed-book samples -> ONE vote (their mode), reliability by agreement level Relaxations R1' self-samples of one LLM are one source (vote = mode, reliability kappa_g by agreement g) R2' concentrated distractors: a wrong vote lands on a given rival w.p. eta (K_eff = 1/eta) R3' reliability hyperpriors are population-level, calibrated offline on labelled data, frozen R4' separate reliabilities per action type R5' control by information rate: a* = argmax_a D_a / c_a, stop when P_err * D_a* / c_a* <= lambda Posterior (per-query reliabilities integrated out in closed form): log w_i = sum_t [lnB(a_t+n_ti, b_t+N_t-n_ti) - lnB(a_t, b_t+N_t)] - n_i ln eta ; log w_residual = -ln eta """ from __future__ import annotations import json, math, re, string, threading, time from collections import Counter from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass, field from typing import Callable import numpy as np from scipy.special import betaln from rank_bm25 import BM25Okapi # ----------------------------------------------------------------- text utilities _PUNCT = set(string.punctuation) _STOP = set('a an the of in on at to for by with from and or is was were are be been who what which when ' 'where whose how did does do that this as its it his her their'.split()) def norm(s: str) -> str: s = (s or '').lower() s = ''.join(ch for ch in s if ch not in _PUNCT) s = re.sub(r'\b(a|an|the)\b', ' ', s) return ' '.join(s.split()) def is_none(a) -> bool: return a is None or norm(str(a)) in ('none', '', 'unknown', 'not stated', 'not mentioned') def tokenize(s: str) -> list[str]: return [w for w in re.findall(r'\w+', s.lower()) if w not in _STOP] def est_tokens(s: str) -> int: return max(1, len(s) // 4) class Clusters: """Answer strings -> clusters (equal after normalisation, or token containment).""" def __init__(self): self.reps: list[str] = [] def id(self, a: str) -> int: n = norm(a); nt = set(n.split()) for i, r in enumerate(self.reps): rt = set(r.split()) if n == r or (nt and rt and (nt <= rt or rt <= nt)): return i self.reps.append(n) return len(self.reps) - 1 # ----------------------------------------------------------------- retrieval class Corpus: def __init__(self, passages: list[str]): if not passages: raise ValueError('corpus is empty') self.passages = passages self.bm25 = BM25Okapi([tokenize(p) or ['_'] for p in passages]) @classmethod def from_json(cls, path: str) -> 'Corpus': rows = json.load(open(path)) return cls([f"{r['title']}: {r['text']}" for r in rows]) def search(self, query: str, k: int, exclude: set[int] = frozenset()) -> list[int]: sc = self.bm25.get_scores(tokenize(query) or ['_']) order = np.argsort(-sc) return [int(j) for j in order[:k + len(exclude)] if int(j) not in exclude][:k] # ----------------------------------------------------------------- LLM executor LLM = Callable[[list[dict], float, int], tuple[str, int, int]] # messages, temperature, max_tokens SYS = 'You are a precise question-answering component. Follow the output format exactly.' def p_cb(q): return [{'role': 'system', 'content': SYS}, {'role': 'user', 'content': f'Answer from your own knowledge. Give only a short answer (1-6 words), ' f'always a best guess.\nQuestion: {q}\nAnswer:'}] def _ctx(ps): return '\n'.join(f'[{i + 1}] {p}' for i, p in enumerate(ps)) def p_r1(q, ps): return [{'role': 'system', 'content': SYS}, {'role': 'user', 'content': 'Use ONLY the passages. Return JSON {"answer": "...", "followup": "..."} where answer is a short ' 'answer (1-6 words) if the passages TOGETHER answer the question, else "NONE"; followup is the single ' 'most useful next search query for the missing information (name the bridge entity if known). ' f'Always give a followup.\n\nPassages:\n{_ctx(ps)}\n\nQuestion: {q}\nJSON:'}] def p_r2(q, ps): return [{'role': 'system', 'content': SYS}, {'role': 'user', 'content': 'Use ONLY the passages; combine facts across passages for multi-hop questions. Give only a short ' 'answer (1-6 words), or NONE if the passages do not contain the answer.' f'\n\nPassages:\n{_ctx(ps)}\n\nQuestion: {q}\nAnswer:'}] def clean_answer(t: str) -> str: t = (t or '').strip().strip('"').strip() t = re.sub(r'^(final\s+)?answer\s*:\s*', '', t, flags=re.I) return t.split('\n')[0].strip().strip('."') def parse_r1(t: str) -> tuple[str, str]: m = re.search(r'\{.*\}', t or '', flags=re.S) if m: try: d = json.loads(m.group(0)) return clean_answer(str(d.get('answer', 'NONE'))), str(d.get('followup', '')).strip() except json.JSONDecodeError: pass a = re.search(r'"?answer"?\s*[:=]\s*"([^"]*)"', t or '', flags=re.I) f = re.search(r'"?followup"?\s*[:=]\s*"([^"]*)"', t or '', flags=re.I) return (clean_answer(a.group(1)) if a else 'NONE'), (f.group(1).strip() if f else '') def hf_llm(model: str, token: str | None, provider: str = 'auto', timeout: float = 60.0) -> LLM: """model: a Hugging Face model id (served by HF Inference Providers) or the URL of an OpenAI-compatible endpoint (e.g. a dedicated HF Inference Endpoint, TGI or vLLM server).""" from huggingface_hub import InferenceClient if model.startswith(('http://', 'https://')): client = InferenceClient(base_url=model, api_key=token, timeout=timeout) else: client = InferenceClient(model=model, token=token, provider=provider, timeout=timeout) def call(messages, temperature, max_tokens): r = client.chat_completion(messages, max_tokens=max_tokens, temperature=max(temperature, 0.01)) text = r.choices[0].message.content or '' u = getattr(r, 'usage', None) tin = getattr(u, 'prompt_tokens', None) or sum(est_tokens(m['content']) for m in messages) tout = getattr(u, 'completion_tokens', None) or est_tokens(text) return text, int(tin), int(tout) return call # ----------------------------------------------------------------- calibration DEFAULT_CAL = { # overwritten by data/calibration.json when present 'rel': {'R1': [29, 6], 'R2': [54, 11]}, 'ans': {'R1': [34, 58], 'R2': [64, 28]}, 'kap': {'1': [2, 30], '2': [15, 24], '3': [14, 11]}, 'pg': {'1': 0.333, '2': 0.409, '3': 0.258}, 'eta': 0.591} def _kl(rho, eta, q=1.0): rho = min(max(rho, 1e-6), 1 - 1e-6) return q * (rho - (1 - rho) * eta) * math.log(rho / ((1 - rho) * eta)) def gold_match(pred, gold: list[str]) -> int: if is_none(pred): return 0 p = norm(pred) return int(any(norm(g) and (norm(g) in p or (len(p) > 3 and p in norm(g))) for g in gold)) # ----------------------------------------------------------------- the orchestrator @dataclass class Result: answer: str | None trace: list = field(default_factory=list) llm_calls: int = 0 tokens_in: int = 0 tokens_out: int = 0 rounds: int = 0 llm_seconds: float = 0.0 policy_microseconds: float = 0.0 stopped_because: str = '' def to_dict(self): return dict(answer=self.answer, stopped_because=self.stopped_because, llm_calls=self.llm_calls, tokens_in=self.tokens_in, tokens_out=self.tokens_out, rounds=self.rounds, llm_wall_seconds=round(self.llm_seconds, 3), policy_microseconds_total=round(self.policy_microseconds, 1), trace=self.trace) class BRAG: def __init__(self, llm: LLM, calibration: dict | None = None, lam: float = 0.05, k: int = 5, cb_samples: int = 3, cb_temperature: float = 0.7): c = json.loads(json.dumps(calibration or DEFAULT_CAL)) c['kap'] = {int(g): v for g, v in c['kap'].items()}; c['pg'] = {int(g): v for g, v in c['pg'].items()} self.P, self.llm, self.lam, self.k = c, llm, lam, k self.m, self.temp = cb_samples, cb_temperature # closed-form posterior over observed answer clusters + residual def posterior(self, votes): eta = self.P['eta']; cl = sorted({i for _, i in votes}); lw = {i: 0.0 for i in cl} by = {} for t, i in votes: by.setdefault(t, Counter())[i] += 1 for t, cnt in by.items(): a, b = self.P['kap'][t] if isinstance(t, int) else self.P['rel'][t]; N = sum(cnt.values()) for i in cl: n = cnt.get(i, 0) lw[i] += betaln(a + n, b + N - n) - betaln(a, b + N) - n * math.log(eta) v = np.array([lw[i] for i in cl] + [-math.log(eta)]); v -= v.max(); p = np.exp(v); p /= p.sum() return cl, p def rates(self, done): eta = self.P['eta']; out = {} for t in ('R1', 'R2'): if t in done or (t == 'R2' and 'R1' not in done): continue a, b = self.P['ans'][t]; ra, rb = self.P['rel'][t] out[t] = _kl(ra / (ra + rb), eta, a / (a + b)) / 1.0 if 'CB' not in done: d = sum(self.P['pg'][g] * _kl(self.P['kap'][g][0] / sum(self.P['kap'][g]), eta) for g in (1, 2, 3)) out['CB'] = d / self.m return out _lock = threading.Lock() def _call(self, res, messages, temperature, max_tokens): t0 = time.perf_counter(); text, ti, to = self.llm(messages, temperature, max_tokens) with self._lock: res.llm_calls += 1 res.tokens_in += ti; res.tokens_out += to return text def run(self, question: str, corpus: Corpus) -> Result: res = Result(answer=None); C = Clusters(); votes = []; raw = {}; done = set(); st = {} while True: t0 = time.perf_counter() cl, p = self.posterior(votes) pbest = float(p[:-1].max()) if cl else 0.0; perr = 1 - pbest rates = self.rates(done); act = None; why = '' if not rates: why = 'no actions left' else: a_star = max(rates, key=rates.get) if perr * rates[a_star] > self.lam: act = a_star else: why = f'P_err*D/c = {perr * rates[a_star]:.4f} <= lambda = {self.lam}' dt = 1e6 * (time.perf_counter() - t0); res.policy_microseconds += dt step = dict(posterior={(C.reps[i] if i < len(C.reps) else str(i)): round(float(pp), 4) for i, pp in zip(cl, p[:-1])}, residual=round(float(p[-1]), 4), p_error=round(perr, 4), info_rate_per_call={k_: round(v, 4) for k_, v in rates.items()}, action=act or 'STOP', policy_us=round(dt, 1)) if act is None: res.stopped_because = why; res.trace.append(step); break done.add(act); res.rounds += 1; tw = time.perf_counter() if act == 'CB': with ThreadPoolExecutor(self.m) as ex: outs = list(ex.map(lambda _: self._call(res, p_cb(question), self.temp, 24), range(self.m))) samples = [clean_answer(o) for o in outs]; good = [s for s in samples if not is_none(s)] step['observation'] = {'samples': samples} if good: ids = [C.id(s) for s in good]; mo, g = Counter(ids).most_common(1)[0] votes.append((g, mo)); raw.setdefault(mo, good[ids.index(mo)]) step['observation']['vote'] = good[ids.index(mo)]; step['observation']['agreement'] = g elif act == 'R1': idx = corpus.search(question, self.k); st['idx1'] = idx ps = [corpus.passages[j][:600] for j in idx] ans, fu = parse_r1(self._call(res, p_r1(question, ps), 0.0, 96)); st['followup'] = fu or question step['observation'] = {'answer': ans, 'followup': fu, 'passages': [x[:80] for x in ps]} if not is_none(ans): cid = C.id(ans); votes.append(('R1', cid)); raw.setdefault(cid, ans) else: # R2 idx2 = corpus.search(st['followup'], self.k, exclude=set(st['idx1'])) ps = [corpus.passages[j][:600] for j in st['idx1'] + idx2] ans = clean_answer(self._call(res, p_r2(question, ps), 0.0, 32)) step['observation'] = {'answer': ans, 'new_passages': [corpus.passages[j][:80] for j in idx2]} if not is_none(ans): cid = C.id(ans); votes.append(('R2', cid)); raw.setdefault(cid, ans) step['wall_seconds'] = round(time.perf_counter() - tw, 3); res.llm_seconds += step['wall_seconds'] res.trace.append(step) cl, p = self.posterior(votes) if cl: res.answer = raw[cl[int(np.argmax(p[:-1]))]] else: # nothing usable observed: forced closed-book answer (charged) tw = time.perf_counter() res.answer = clean_answer(self._call(res, p_cb(question), 0.0, 24)); res.rounds += 1 res.llm_seconds += time.perf_counter() - tw res.stopped_because += ' | forced closed-book answer' return res # ------------------------------------------------------------ calibration with a new LLM def calibrate(self, labelled: list[dict], corpus: Corpus) -> dict: """labelled: [{question, gold:[aliases]}]. Runs every action once per question and returns calibration pseudo-counts (+1 smoothing) for this LLM, retriever and corpus.""" rel = {'R1': [1, 1], 'R2': [1, 1]}; ans = {'R1': [1, 1], 'R2': [1, 1]} kap = {g: [1, 1] for g in (1, 2, 3)}; pg = Counter({1: 1, 2: 1, 3: 1}); pairs = coll = 0 dummy = Result(answer=None) for ex_ in labelled: q, gold = ex_['question'], ex_['gold'] if isinstance(ex_['gold'], list) else [ex_['gold']] idx = corpus.search(q, self.k); ps = [corpus.passages[j][:600] for j in idx] a1, fu = parse_r1(self._call(dummy, p_r1(q, ps), 0.0, 96)) idx2 = corpus.search(fu or q, self.k, exclude=set(idx)) a2 = clean_answer(self._call(dummy, p_r2(q, ps + [corpus.passages[j][:600] for j in idx2]), 0.0, 32)) for t, a in (('R1', a1), ('R2', a2)): if is_none(a): ans[t][1] += 1 else: ans[t][0] += 1; c = gold_match(a, gold); rel[t][0] += c; rel[t][1] += 1 - c with ThreadPoolExecutor(self.m) as ex: outs = [clean_answer(o) for o in ex.map(lambda _: self._call(dummy, p_cb(q), self.temp, 24), range(self.m))] good = [s for s in outs if not is_none(s)] if good: C = Clusters(); ids = [C.id(s) for s in good]; mo, g = Counter(ids).most_common(1)[0] ok = gold_match(good[ids.index(mo)], gold); kap[g][0] += ok; kap[g][1] += 1 - ok; pg[g] += 1 wrong = [x for x in (a1, a2, outs[0] if outs else None) if not is_none(x) and not gold_match(x, gold)] C = Clusters(); ids = [C.id(x) for x in wrong] for i in range(len(ids)): for j in range(i + 1, len(ids)): pairs += 1; coll += ids[i] == ids[j] tot = sum(pg.values()) return dict(rel=rel, ans=ans, kap={str(g): v for g, v in kap.items()}, pg={str(g): pg[g] / tot for g in pg}, eta=(1 + coll) / (2 + pairs), n_questions=len(labelled), llm_calls=dummy.llm_calls)