raglab
raglab — the agentic-search / RAG orchestration layer on top of ir.
raglab turns a retrieval substrate into a Composable Search Agent (the ir_09
architecture): a small set of injected roles — Planner, Formulator,
Retriever, Evaluator, Reranker, Citer — wired by a control loop whose back-edge
(evaluator → reformulate) is what makes it an agent rather than a DAG. Concrete
tools live at the leaves: an ir corpus becomes one Retriever via
ir.as_retriever.
Quick start (the no-LLM thin slice — runs offline):
import ir
import raglab
# register ir corpora as the agent's sources, then search across them:
sources = raglab.ir_sources("skills", "reports", mode="hybrid")
agent = raglab.make_search_agent(sources)
results = agent("how do I deploy the app") # ranked ir.SearchHits
Inject an LLM formulator (query rewrite/HyDE) and evaluator (sufficiency
+ refinement) to turn on query understanding and the back-edge. Dependency
direction is one-way: raglab imports ir; ir never imports raglab.
> Fresh start (v0.2.0+). This repo took over the raglab PyPI name; the older
> backend now lives at raglab_bak. Development is just beginning.
- class raglab.Budget(max_rounds: int = 3, max_sources_per_task: int = 4, max_results_per_task: int = 50)[source]
Loop bounds: the safety net under the (harder) sufficiency decision.
- class raglab.Citer(*args, **kwargs)[source]
Confirm/annotate that each result supports its use (identity by default).
- class raglab.Evaluator(*args, **kwargs)[source]
Judge relevance + sufficiency; optionally emit a refinement (back-edge).
- class raglab.Formulator(*args, **kwargs)[source]
Turn a sub-task + one source into concrete low-level queries.
- class raglab.Judgement(relevant: Sequence[SearchHit], sufficient: bool, refinement: SubTask | None = None)[source]
An evaluator’s verdict over a round’s results.
relevantis the kept subset;sufficientsays whether to stop; a non-Nonerefinementis the back-edge — the next sub-task to re-query with. The pass-through default returnssufficient=Trueand no refinement (no loop).
- class raglab.LowLevelQuery(source: str, query: str, params: Mapping[str, ~typing.Any]=<factory>)[source]
One concrete query against one source.
queryis the text handed to the source’sRetriever;paramsare per-call retriever overrides (e.g.mode/filter/kfor an ir corpus). A formulator turns aSubTaskinto these.
- class raglab.Planner(*args, **kwargs)[source]
Decompose a query into sub-tasks and select sources for each.
- class raglab.Query(text: str, constraints: Mapping[str, ~typing.Any]=<factory>)[source]
A user intent: free text plus optional structured constraints.
- class raglab.Reranker(*args, **kwargs)[source]
Produce the final ordering over the (cross-source) merged results.
- raglab.Result
alias of
SearchHit
- class raglab.SingleContextAgent(sources: ~collections.abc.Mapping[str, ~typing.Callable[[...], list[~ir.base.SearchHit]]], planner: ~raglab.agent.Planner = <function single_subtask_planner>, formulator: ~raglab.agent.Formulator = <function identity_formulator>, evaluator: ~raglab.agent.Evaluator = <function passthrough_evaluator>, reranker: ~raglab.agent.Reranker = <function rrf_reranker>, citer: ~raglab.agent.Citer = <function identity_citer>, budget: ~raglab.agent.Budget = <factory>)[source]
One ReAct-style loop, sequential sub-tasks (ir_09 §7 — the cheap default).
The loop is fixed; every decision is an injected role. Promotion to a multi-agent orchestrator (ir_09 §7) swaps this class while keeping the same role contracts. The back-edge is the single line
current = judged.refinementin_run_task()— that is what makes this an agent.- citer() Sequence[SearchHit]
No-op citer (verification needs a generated claim — that lives in srag).
- evaluator(results: Sequence[SearchHit]) Judgement
Pass-through critic: keep everything, declare sufficient, never re-query.
- formulator(source: str) list[LowLevelQuery]
Identity formulator: the sub-goal verbatim as one query (no rewrite/HyDE).
- planner(sources: Mapping[str, Callable[[...], list[SearchHit]]]) list[SubTask]
Trivial planner: one sub-task over all registered sources, no decomposition.
- reranker() Sequence[SearchHit]
Cross-source merge (the default fan-in): fuse by rank, never by raw score.
Groups the accumulated pool by
hit.sourceand delegates the merge toir.fuse_hits()(ir is the SSOT for hit operations): within each source raw scores order and dedup that source’s hits — one scale, sound — and across sources only ranks interact (Reciprocal Rank Fusion), so heterogeneous embedders / modes can never mis-order the merge, and collidingartifact_ids from different sources stay distinct results (identity is(source, artifact_id)).A single-source pool (or an untagged one — hits with no
source) keeps its raw scores and exactlyscore_reranker()’s ordering; fused, rank-derived scores only appear when there is genuinely something to fuse. Each fused hit keeps its pre-fusion magnitude asmetadata["source_score"]. For per-source weights, anotherrrf_k, or opt-in cross-source duplicate merging, usemake_rrf_reranker().
- class raglab.SubTask(goal: str, sources: tuple[str, ...])[source]
A planner’s unit of work: a sub-goal bound to a set of registered sources.
- raglab.identity_citer(results: Sequence[SearchHit]) Sequence[SearchHit][source]
No-op citer (verification needs a generated claim — that lives in srag).
- raglab.identity_formulator(task: SubTask, source: str) list[LowLevelQuery][source]
Identity formulator: the sub-goal verbatim as one query (no rewrite/HyDE).
- raglab.ir_sources(*names: str, **search_defaults: Any) dict[str, Callable[[...], list[SearchHit]]][source]
A source registry
{name: Retriever}backed by namedircorpora.Each name is bound to
ir.as_retriever(name, **search_defaults), opened eagerly. For the lazy live view over everything registered (a corpus opens only when its key is first used), useir.retrievers()instead — the agent accepts either, or anyMapping[name, Retriever].search_defaults(e.g.mode="hybrid") apply to every source.
- raglab.make_llm_evaluator(*, judge: Callable[[...], tuple[bool, str | None] | str] | None = None, select_strategy: str = 'conservative', select_kwargs: Mapping[str, Any] | None = None, prerank: Reranker | None = None, prompt: str = 'A search agent is pursuing this goal:\n\n{goal}\n\nA calibrated selector reviewed the retrieved candidates and committed to the\nresults below (it abstained if this list is empty):\n\n{results}\n\nDecide whether these results are SUFFICIENT to satisfy the goal.\n- If they are, reply with exactly: SUFFICIENT\n- If they are not, reply with: INSUFFICIENT\n then, on the next line, a single improved search query that would retrieve what\n is still missing. Keep it a terse search phrase — no prose, no numbering.\n', max_result_chars: int = 500, **prompt_function_kwargs: Any) Evaluator[source]
An LLM-backed raglab
Evaluator(turns on the back-edge).Relevance is ir’s: each round the accumulated results are passed through
ir.select()and the committed subset becomes theJudgement.relevant(so the LLM stays in its lane — LLM relevance is known-fragile, ir_01 §3). Sufficiency is the LLM’s: it reads the committed subset (informed by ir’s model-freesufficienthint via abstention) and decides whether the goal is satisfied. When it is not, the judge’s improved query becomes arefinementSubTaskover the same sources — the back-edge.- Parameters:
judge – an injectable
(goal, results) -> (sufficient, refinement)callable (a test double, or your own router); a raw text reply is also accepted and parsed. When omitted it is built lazily onoa.select_strategy – ir selection strategy for the relevance decision (default
"conservative"— distractor-robust).select_kwargs – extra args forwarded to
ir.select()(e.g.max_k,rel).min_scoreis allowed only while the round’s pool keeps raw scores (single-source, orprerank=score_reranker): an absolute floor is per-(corpus, mode, embedder), so a round whose pool was rank-fused across sources raises rather than silently mass-abstaining on ordinal RRF scores.prerank – the per-round merge that puts the accumulated pool best-first (and deduped) before
ir.select. Defaults torrf_reranker()— a round’s pool can already mix sources (a SubTask spans several), so the same rank-based, scale-safe merge as the fan-in applies; single-source rounds keep raw scores. Injectscore_reranker()for sources known to share one score scale.max_result_chars – per-result text truncation in the judge prompt.
Safe fallback: any judge error returns
refinement=None— the control loop’s break condition — so a failing judge can never fabricate an endless loop. Sufficiency without a refinement query is likewise treated as a stop.
- raglab.make_llm_formulator(*, formulate: Callable[[str], str | Sequence[str]] | None = None, params: Mapping[str, Any] | None = None, **make_kwargs: Any) Formulator[source]
An LLM-backed raglab
Formulator.Adapts an ir-style query formulator (
str -> str | [str]) to the role contract(SubTask, source) -> [LowLevelQuery]: the sub-goal text is rewritten / expanded into one or more search queries, each wrapped as aLowLevelQueryagainstsource.- Parameters:
formulate – an injectable ir-style formulator (a test double, or one you built). When omitted it is built once via
ir.make_llm_formulator()(**make_kwargsare forwarded — e.g.n,prompt,rewriter); that call is offline (oais imported lazily only when the formulator is invoked), soimport raglabstays offline.params – per-call retriever overrides (e.g.
{"mode": "hybrid", "k": 5}) attached to every emittedLowLevelQuery.
The boundary: this returns queries, never SubTasks. ir’s formulator already falls back to identity on any failure, so the emitted list is never empty — a formulator must never make retrieval worse than the raw sub-goal.
- raglab.make_rrf_reranker(*, rrf_k: int = 60, weights: Mapping[str, float] | None = None, identity: Callable[[SearchHit], Any] | str | None = None) Reranker[source]
A parametrized
rrf_reranker()(per-source trust weights,rrf_k).- Parameters:
rrf_k – the RRF rank constant (standard default 60).
weights – optional per-source trust dial, by source name (default 1.0 each) — biases the merge without ever comparing raw scores.
identity – opt-in cross-source duplicate detection (e.g.
"pointer") — seeir.retrieve.Identity. Default: never merge across sources.
- raglab.make_search_agent(sources: Mapping[str, Callable[[...], list[SearchHit]]], *, planner: Planner | None = None, formulator: Formulator | None = None, evaluator: Evaluator | None = None, reranker: Reranker | None = None, citer: Citer | None = None, budget: Budget | None = None) SingleContextAgent[source]
Build a
SingleContextAgentover sources with smart defaults.sourcesis aMapping[name, Retriever]— e.g.{"skills": ir.as_retriever("skills")},ir_sources(), or the lazyir.retrievers()view. Every role defaults to its no-LLM thin-slice implementation, somake_search_agent(sources)("query")just works; inject an LLMformulator/evaluatorto turn on rewriting and the back-edge.A custom
Retrievermust returnir.SearchHitinstances (theResultalias): the loop stamps provenance (hit.source) on its output, so duck-typed hit objects raise at the tagging step.
- raglab.passthrough_evaluator(task: SubTask, results: Sequence[SearchHit]) Judgement[source]
Pass-through critic: keep everything, declare sufficient, never re-query.
- raglab.rrf_reranker(results: Sequence[SearchHit]) Sequence[SearchHit][source]
Cross-source merge (the default fan-in): fuse by rank, never by raw score.
Groups the accumulated pool by
hit.sourceand delegates the merge toir.fuse_hits()(ir is the SSOT for hit operations): within each source raw scores order and dedup that source’s hits — one scale, sound — and across sources only ranks interact (Reciprocal Rank Fusion), so heterogeneous embedders / modes can never mis-order the merge, and collidingartifact_ids from different sources stay distinct results (identity is(source, artifact_id)).A single-source pool (or an untagged one — hits with no
source) keeps its raw scores and exactlyscore_reranker()’s ordering; fused, rank-derived scores only appear when there is genuinely something to fuse. Each fused hit keeps its pre-fusion magnitude asmetadata["source_score"]. For per-source weights, anotherrrf_k, or opt-in cross-source duplicate merging, usemake_rrf_reranker().
- raglab.score_reranker(results: Sequence[SearchHit]) Sequence[SearchHit][source]
Magnitude merge: one surface per artifact, ordered by descending raw score.
Delegates to
ir.base.best_per_artifact()(ir is the SSOT for hit operations): an artifact retrieved by several queries / rounds — common once the back-edge re-queries — survives once, at its highest score. Identity is(source, artifact_id), so two sources’ same-id artifacts never collapse.A plain score sort compares raw scores across sources, which is only sound when every source shares one score scale (same embedder + mode) — it is the explicit homogeneous-sources opt-in. The default fan-in is
rrf_reranker(), which never compares raw scores across sources.