Retrieval-Augmented Generation¶
rowvoi.rag applies the same two engines to retrieval. Nothing new is
computed – set cover and mutual information are reused as they are, over a
different universe.
Tabular |
RAG |
|---|---|
Cover row pairs with columns |
Cover claims with chunks (cost = tokens) |
Next column by mutual information |
Next clarifying question (cost = patience) |
Sequential acquisition |
Next retrieval probe (cost = latency) |
Everything below is deterministic and needs only pandas and numpy. Producing
the matrices is what needs a language model, and that boundary is
rowvoi.rag.protocols.
Minimal sufficient context¶
Top-k ranks each chunk independently, so it will spend the budget on five chunks supporting the same claim while a sixth claim goes unsupported. Set cover optimizes the selection jointly, and because chunk cost is token count it prefers several small chunks over one large catch-all.
from rowvoi.rag import Chunk, select_context
chunks = [
Chunk("doc1#p2", "The Pro plan costs $40/seat/mo.", tokens=180),
Chunk("doc2#p1", "v4.2 shipped March 3rd, 2026.", tokens=150),
Chunk("faq#p1", "Pricing, releases, and SSO.", tokens=1550),
]
claims = ["pro_plan_price", "v42_release_date"]
# A SupportJudge fills this in; see "Where the LLM goes" below.
support = {
"pro_plan_price": {"doc1#p2", "faq#p1"},
"v42_release_date": {"doc2#p1", "faq#p1"},
}
selection = select_context(chunks, claims, support)
print(sorted(selection.chunks))
print(selection.total_cost, "tokens")
print(f"{selection.coverage:.0%} of claims covered")
['doc1#p2', 'doc2#p1']
330.0 tokens
100% of claims covered
The single faq#p1 chunk covers both claims by itself, but at 1550 tokens.
Minimizing chunk count is not minimizing context.
A claim that nothing supports is a retrieval failure, and it is reported rather than quietly dropped:
gap = select_context(chunks, [*claims, "refund_policy"], support)
print(f"{gap.coverage:.0%}")
print(sorted(gap.missing_claims))
67%
['refund_policy']
When the budget binds rather than the coverage target, ask for the order instead of the set:
from rowvoi.rag import plan_context_path
path = plan_context_path(chunks, claims, support)
print(path.prefix_for_budget(200))
print(path.coverage_curve())
['doc2#p1']
[(150.0, 0.5), (330.0, 1.0)]
epsilon_claims trades a fraction of the claims for a smaller prompt.
Clarifying questions¶
When the retrieved set is genuinely ambiguous, ask rather than guess. Given a
matrix of the answer each candidate implies, rowvoi picks the question
that splits them best per unit of user effort.
from rowvoi.rag import next_question, question_values
answers = {
"Which deployment?": ["cloud", "cloud", "self", "self"],
"Which version?": ["v4", "v3", "v4", "v3"],
"Are you an admin?": ["yes", "yes", "yes", "yes"],
}
for question, bits in question_values(answers).items():
print(f"{bits:.2f} bits {question}")
1.00 bits Which deployment?
1.00 bits Which version?
0.00 bits Are you an admin?
“Are you an admin?” scores exactly zero: every candidate answers it the same way, so it cannot narrow anything. A relevance-ranked list would still ask it.
Costs express user patience – a yes/no question is cheap, “paste your config” is not – and ranking is by information per unit cost:
suggestion = next_question(answers, costs={"Which version?": 5.0})
print(suggestion.col)
Which deployment?
Folding an answer back in is soft: candidates are reweighted, not dropped, so one surprising answer cannot eliminate the right candidate outright.
from rowvoi import CandidateState
from rowvoi.rag import observe_answer
state = CandidateState.uniform(range(4))
state = observe_answer(state, answers, "Which deployment?", "self", noise=0.02)
print(state.posterior.round(2).tolist())
[0.01, 0.01, 0.49, 0.49]
Adaptive retrieval¶
The same machinery, with retrieval probes instead of questions and latency instead of patience. Stop when the posterior is sharp enough rather than at a fixed k.
from rowvoi import StopRules
from rowvoi.rag import RetrievalSession
outcomes = {
"bm25": ["hit", "hit", "miss", "miss"],
"metadata:version": ["v4", "v3", "v4", "v3"],
"rerank": ["1st", "2nd", "3rd", "4th"],
}
class ScriptedRunner:
"""Stands in for a retrieval backend; candidate 3 is the right one."""
def run(self, probe):
return outcomes[probe][3]
session = RetrievalSession(
outcomes,
runner=ScriptedRunner(),
prior=[0.20, 0.15, 0.45, 0.20], # retrieval scores, wrongly favoring 2
costs={"bm25": 1.0, "metadata:version": 1.0, "rerank": 12.0},
noise=0.05,
)
session.run(StopRules(epsilon_posterior=0.15, target_unique=False))
print("probes:", [step.probe for step in session.history])
print("best candidate:", session.best_candidate)
print(f"confidence: {session.state.max_posterior:.0%}")
print("cost:", session.cumulative_cost)
probes: ['bm25', 'metadata:version']
best candidate: 3
confidence: 86%
cost: 2.0
The prior favored candidate 2; two cheap probes overturned it, and the expensive reranker was never needed. Demanding more confidence would buy it – that tradeoff is a threshold you set rather than a k you guessed.
Where the LLM goes¶
The matrices above – which chunks support which claims, what answer each
question gets – are what a model produces.
rowvoi.rag.protocols defines that boundary:
ClaimExtractor,
SupportJudge,
QuestionGenerator,
AnswerPredictor and
ProbeRunner.
Implement them yourself, or install the claude extra and use the bundled
Anthropic-backed versions:
pip install "rowvoi[claude]"
from rowvoi.rag import extract_and_select
from rowvoi.rag.claude import ClaudeClaimExtractor, ClaudeSupportJudge
selection = extract_and_select(
query,
chunks,
extractor=ClaudeClaimExtractor(),
judge=ClaudeSupportJudge(),
)
rowvoi.rag.claude is never imported by rowvoi.rag, so the core keeps
its pandas-and-numpy-only footprint. It fills each matrix in a single request
and places chunk text behind a prompt-cache breakpoint, since the chunks are
the large stable half of the prompt and the questions are what vary.
A runnable walkthrough of all three capabilities, needing no API key, is in
examples/rag/rag_pipeline_demo.py.