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.