ir

ir is an information-retrieval substrate for agentic systems: one “find the relevant things in this corpus” contract over skills, packages, documents, sessions, MCP tools or any mapping. It retrieves candidates, commits to a small high-precision subset (or abstains), and loads each committed item’s payload only when asked.

For developers with a pulse →

For agents: the agentic layer

Skills. ir ships two Agent Skills:

Skill

Use it to

Real files

ir-search

search a user’s ir corpora and review the hits for real relevance

ir/data/skills/ir-search/ (ships with pip install ir)

ir-dev-workflow

work on ir itself: module map, tests, conventions, the ir/raglab boundary

skills/ir-dev-workflow/ (repository only)

gh skill install i2mint/ir ir-search --agent claude-code        # GitHub CLI with the `skill` command
npx skills add i2mint/ir --skill ir-search                       # or the skills CLI

The consumer skill also travels inside the installed package. Find it with python -c "import ir, os; print(os.path.join(ir.__path__[0], 'data', 'skills'))", then link it with the skill package (skill link-skills <that path>) or copy the folder into ~/.claude/skills/. In a clone of this repository, Claude Code finds both skills through the relative symlinks in .claude/skills/ (on Windows, clone with git config core.symlinks true and symlink permission, or install the skills as above).

Subagent and instructions. .claude/agents/ir-discover-agent.md is a reviewing search subagent: it writes several queries, reads and grades the candidates, reformulates, and returns only the verified few. .claude/CLAUDE.md holds the project instructions a coding agent needs, including the ir/raglab boundary.

What an agent can do with ir:

  • Give itself one search tool over a large catalogue (skills, MCP tools, packages, documents) instead of loading every schema into context.

  • Commit to the few results worth acting on, or abstain when nothing fits (min_score=, or min_score="auto" with a calibrated floor).

  • Search several corpora in one call (ir.discover(["skills", "packages"], query)) and get JSON-ready results (.to_dict(), ir.tools.search).

A minimal runnable example, offline and with no model download (the light embedder is a numpy-only hashing embedder):

import ir
from ir.store import CorpusStore

tools = [
    {"name": "fetch_url", "description": "Fetch a web page over HTTP and return its text"},
    {"name": "send_email", "description": "Send an email message to a recipient"},
    {"name": "list_files", "description": "List the files in a directory"},
]
source = ir.CorpusSource.from_mcp_tools(tools)  # embeds name + description only
corpus = ir.build(source, store=CorpusStore.memory(), embedder="light")

result = ir.discover(corpus, "fetch the text of a web page", mode="dense")
assert [item.name for item in result.results] == ["fetch_url"]

nothing = ir.discover(corpus, "bake a chocolate cake", mode="dense", min_score=0.3)
assert nothing.abstained  # no tool clears the floor, so the agent is told so

Looking for a search agent?

ir is a retrieval substrate, not a search agent — it has no agent of its own. ir.discover is a deterministic, single-shot retrieve → select → disclose pipeline: it embeds your query verbatim (no LLM query rewriting) and commits via a score rule (no LLM reviewing the results). There is no planner and no back-edge (evaluate → reformulate → search again) — and that back-edge is precisely what makes a system an agent.

If you want the agent — a planner, an LLM formulator that rewrites queries, and the evaluator→reformulate loop over one or more ir corpora — reach for raglab, which is built on top of ir (raglab imports ir; ir never imports raglab, so it stays installable-light and offline-by-default).

Prefer to drive it from Claude Code instead of a coded agent? ir ships a reviewing search subagent and an ir-search skill (see the agentic layer) — a lightweight “raglab-in-instructions” that formulates queries, reads and judges candidates against your real intent, filters, and reformulates, all on your Claude subscription (see ir_10).

Quickstart

pip install ir
import ir

# Define a corpus, build the index (incremental), then discover.
source = ir.CorpusSource.from_skills()  # your agent skills (read through `priv`)
# Other sources: from_packages(), from_md_reports(), from_claude_sessions(),
# from_files(root), from_mapping(docs, name=...), from_mcp_tools(tools),
# from_records(fetcher="mypkg.recall:records")  # records another package owns
corpus = ir.build(source)  # embed + persist under XDG dirs
result = ir.discover(corpus, "how do I deploy the app to the server")

for item in result.results:
    print(item.score, item.name)  # the committed few (or result.abstained)
print(result.to_dict())  # JSON-serializable (qh / HTTP ready)

Install

ir is light by default — numpy + dol for storage, plus ef / vd for embedding and lexical/hybrid retrieval. Python ≥ 3.10.

Notes for the default (semantic) path:

  • The default embedder is all-MiniLM-L6-v2 (384-dim, via sentence-transformers), downloaded on first build (needs network) and cached under ~/.cache/ir. For a fast, offline, dependency-light run — tests, CI, quick experiments — pass embedder="light" (a numpy-only hashing embedder): ir.build(source, embedder="light").

  • ir sets USE_TF=0 on import so transformers does not pull in TensorFlow (which crashes on some numpy ABIs); import ir before anything that imports transformers.

  • Case generation (ir.eval_gen) and the optional LLM selector need an LLM via aix — install the extra, pip install "ir[llm]". Scoring and evaluation themselves stay offline. The LLM is an injected callable at every seam, so it can run on a Claude subscription instead of a metered key — see ir_10 (MCP sampling for connectors / the Agent SDK for Claude Code).

Install on a fresh server

A machine with only a system python3 cannot run ir (no numpy, dol, ef, vd, cw). One command gives you an ir on PATH, in its own isolated environment:

uv tool install ir          # puts `ir` on PATH (~/.local/bin)
ir discover packages "cache"

Or install into an environment you own: uv pip install --python <env>/bin/python ir (a distro-managed system Python refuses this; use uv tool install). If you run ir where its dependencies are missing, it now says which ones and prints these commands, instead of a bare ModuleNotFoundError from ir/base.py.

Where the index comes from. Nothing is shipped or synced: each machine builds its own indexes from what is on that machine. The first ir discover on a machine that never built one prints corpus 'packages' is empty; build it first: ir build packages. Run it once (ir build skills, ir build packages, …); later builds are incremental, and ir maintain --all (or ir schedule) keeps them fresh. Indexes live under ~/.local/share/ir.

How long a first build takes. With the light embedder (no model download), ir build packages over about 2,800 records took 12 s on a small server. With the default MiniLM embedder the first build also downloads the model (about 90 MB) and embeds on CPU, so budget minutes (not measured here); uv pip install --python <python> 'ef[sentence-transformers]' enables it. Without that package ir build warns and builds with the light embedder.

A corpus is pinned to the embedder it was built with (its embedder_id, shown by ir info), so installing or removing sentence-transformers later does not break querying. To switch embedders, re-embed with ir build NAME.

The pipeline

ir is a six-stage pipeline, each stage a small, swappable seam:

Stage

Entry point

What it does

source

CorpusSource

what is in the corpus + what counts as stale

index

ir.build

decompose artifacts into embeddable surfaces, embed, persist (incremental, idempotent)

retrieve

ir.search

hard metadata filter + dense / lexical / hybrid ranking

expand

ir.expand

stitch a hit’s stored neighborhood (sentence window / whole artifact) into the passage downstream reads — opt-in

select

ir.select

commit to a distractor-robust subset, or abstain

disclose

ir.disclose

load the heavy payload (SKILL.md body, package pointer, file text) for committed items — append-only

ir.discover chains retrieve → select → disclose into the single agent-callable (and qh-exposable) tool. Pass a list of corpus names for single-shot federated discovery across several corpora:

ir.discover(["skills", "packages"], "deploy the app")  # fan-out → fuse → select
ir.discover(
    ["skills", "packages"], q, min_score="auto"
)  # gate each source on its own floor

Each source is searched and gated on its own calibrated abstention floor before any merging; the survivors then rank-fuse (weighted RRF via ir.fuse_hits) — raw scores never cross a source boundary, because scores from different corpora / embedders / modes live on incommensurable scales. Every hit carries its corpus name as hit.source, so same-id artifacts from different corpora stay distinct, attributable results. The caller names the sources; ir never chooses the set (source planning belongs to the agent layer — see raglab).

Retrieve

hits = ir.search(corpus, "deploy app", mode="hybrid")  # dense | lexical | hybrid (RRF)

Dense is exact brute-force cosine; lexical is Okapi BM25; hybrid fuses both by Reciprocal Rank Fusion (the strongest default for short, identifier-heavy capability text). Lexical/hybrid reuse vd; dense needs only numpy.

Hybrid has a second fusion, fusion="blend" — a magnitude-preserving score blend instead of rank-RRF. RRF discards score magnitude, which is exactly what abstention calibration needs, so blend separates in-scope from out-of-scope queries far better (and even beats dense); the tradeoff is lower lexical recall on terse corpora, so RRF stays the default. Use blend when abstention matters — see ir_08.

surface_weights={"deps": 2.0} multiplies each surface-hit’s first-stage (fused) score by its kind’s weight before the per-artifact collapse, so a high-precision surface such as Package(embed_deps=True)’s deps can outrank prose chunks. The default weighs every surface equally. Weighted scores need their own abstention floor. To sweep weights on one corpus, map it under several labels in ir.eval.compare_indexings(..., search_kw_by_label={"deps x2": {"surface_weights": {"deps": 2.0}}}).

Precision stage: cross-encoder rerank (opt-in)

First-stage retrieval is tuned for recall; a cross-encoder re-scores the top fetch_k candidates against the query jointly, which suppresses jargon-sharing distractors. Single-shot and agent-free (the evaluator back-edge stays in raglab):

hits = ir.search(
    corpus,
    "audio DAG pipelines",
    mode="hybrid",
    rerank="cross-encoder",  # ef's cross-encoder, loaded lazily
    rerank_text="artifact",
)  # read the whole artifact, not the matched surface

pip install 'ir[rerank]' brings sentence-transformers; without it the search warns once and returns the first-stage ranking. rerank= also takes any ef.Reranker callable (a stub, a hosted model), and rerank_text= a (corpus, meta) -> str callable. The "cross-encoder" spec squashes the model’s raw logits through a sigmoid into (0, 1), so relative selectors keep working. A reranked hit’s score is the reranker’s, on its own scale, so its abstention floor is calibrated and stored separately (a callable you pass needs a stable rerank_id attribute for that): pass the same rerank= / rerank_text= to ir.eval.calibrate_min_score and ir.discover(..., min_score="auto") (see ir.rerank).

Expand

passage = ir.expand(hit, corpus)  # ±1 chunk window (default)
passage = ir.expand(hit, corpus, policy=ir.parent_policy())  # the whole artifact

The matched chunk is rarely the unit you want to read, and the whole document is usually too much. ir.expand stitches a hit’s stored sibling segments into a mid-granularity Passage (retrieve → expand → rerank), with chunker overlap deduped and the hit’s identity and score untouched. Policies are injectable (NeighborhoodPolicy); sentence_window_policy(k) and parent_policy() ship. It also works through the disclosure seam:

ir.disclose(sel, expand=ir.sentence_window_policy(2), corpus=corpus)
ir.discover(
    "skills", q, expand=ir.sentence_window_policy()
)  # passages on committed items

Each Disclosure then carries the stitched text as .passage (additive; summary stays the matched surface, body stays the pointer’s payload).

Select

sel = ir.select(hits)  # conservative default: stay within rel of top, cap at max_k
sel = ir.select(hits, min_score=0.4)  # opt in to abstention ("nothing applies")
sel = ir.select(
    hits, strategy="score_gap"
)  # elbow cut, or "top_k" / "rel_threshold" / a callable

The abstention floor is mode-specific (dense cosine, BM25, and RRF live on different scales), so rather than guess min_score, calibrate it from a case file and let discover load it:

ev.calibrate_min_score(
    corpus, cases, mode="dense", persist=True
)  # learn + store the floor
ir.discover(
    corpus, query, mode="dense", min_score="auto"
)  # abstain by the calibrated floor

Calibration separates in-scope from out-of-scope query top-scores and picks the floor that best splits them — see ir_07; it works best on dense / lexical (hybrid’s RRF scores barely separate). min_score defaults to None (never abstain), so abstention stays fully opt-in.

The conservative defaults (max_k=3, rel=0.9) are tuned, not guessed — see ir_06; re-tune for your own corpus with ev.sweep_selector / ir sweep-select.

Selection is relative (ratios to the top score), so one selector works across dense / hybrid / lexical whose absolute scales differ by orders of magnitude. The result carries auditable signals and a reason — no opaque “confidence” float. An optional LLM selector (make_llm_selector, lazy on aix, injectable for tests) falls back to the heuristic on any failure.

Disclose

payloads = ir.disclose(sel, level="body")  # "metadata" (no I/O) | "body" | "bundled"

Disclosure is a pure read that follows the pointer already stored on each hit (skill_path / path); it never mutates the ranked hits and tolerates a stale pointer. Keeping the agent’s context append-only (to protect the prompt cache) is then the caller’s discipline — ir hands back additive payloads.

Graph & traverse (opt-in)

Artifacts refer to each other — a package depends on packages, a skill has a parent. ir models those as a semantic link graph: a typed-edge links view on the store, populated at build time by an EdgeExtractor.

corpus = ir.build(
    source, edge_extractor=ir.default_edge_extractor
)  # deps→REF, parent→PARENT
graph = ir.CorpusGraph(corpus)
graph.neighbors("contaix", edge_type="REF")  # the package's dependencies

ir.traverse walks that structure at query time under a pluggable WalkPolicy (score frontier → select → expand → stop). Safety is the operator’s: a visited-set, depth cap, and node budget live in traverse itself, so even a cyclic graph and a never-stopping policy terminate. The shipped collapsed_tree_policy is pure-vector — it routes a query that matches an artifact’s summary down to that artifact’s best chunk:

hits = ir.traverse(query, corpus, policy=ir.collapsed_tree_policy())

Dependencies are also a ranking signal, seeded by the caller. graph.reverse_neighbors("networkx") lists a library’s dependents and graph.fan_in(...) counts them. ir.reverse_dependency_policy(seeds=[...]) walks those reverse edges, so a package that depends on a domain library but never says so in prose is still found. At ranking time, ir.search(..., dep_boost=ir.dependency_evidence_boost({"networkx"}, weight=0.02)) adds score to candidates whose deps include a listed library. ir never chooses the libraries: that is the agent layer’s planning decision.

policy = ir.reverse_dependency_policy(seeds=["networkx"], fan_in_weight=0.1)
walked = ir.traverse(query, corpus, policy=policy)
hits = ir.fuse_hits({"flat": ir.search(corpus, query, mode="hybrid"), "deps": walked})

The summary a query routes on can be an artifact’s own short field — or an LLM-authored synopsis. ir.with_synopsis wraps any indexing strategy to add one synopsis surface per artifact at build time (the document-summary-index pattern: build-time cost, ≈free at query time), and that synopsis becomes the collapsed-tree router:

strat = ir.with_synopsis(
    ir.Chunked(), synthesize=my_summarizer
)  # or default (lazy aix)
corpus = ir.build(ir.CorpusSource.from_mapping(docs, name="d", strategy=strat))
hits = ir.traverse(
    q, corpus, policy=ir.collapsed_tree_policy()
)  # routes via the synopsis

synthesize is injectable (a test double or your own summarizer); omitted, it is built lazily on aix so import ir stays offline. Synopses are derived state with a stamped synthesizer identity, so a prompt/model change re-synthesizes only the affected artifacts on the next incremental build — no silent staleness.

Pay only where it helps. when= gates synthesis per artifact (default "always"). ir.thin_description_predicate selects packages whose description is empty or short — their README is the only topical signal — so the LLM runs on those few and is never called for the well-described rest:

strat = ir.with_synopsis(ir.Package(), when=ir.thin_description_predicate)
# threshold: when=ir.make_thin_description_predicate(min_chars=80)

The predicate’s identity (when_id) is part of the strategy id, so changing it re-processes the corpus and re-synthesizes every artifact the new gate selects.

Flat top-k stays the default — traverse is opt-in, and a policy earns its keep only by beating flat+rerank on your eval set (a strong flat retriever wins simple lookup; graph methods cost far more). Results are ordinary SearchHits with additive metadata["walk_depth"] / ["seed"] provenance, so select / disclose compose unchanged. This is the semantic link graph (cyclic, query-time) — distinct from ef.artifact_graph (the acyclic build-time derivation DAG).

Evaluation

ir.eval scores discovery quality offline (reusing ef’s retrieval metrics):

from ir import eval as ev

cases = ev.load_cases("skills_eval.jsonl")  # query + gold artifact_id(s)
ev.evaluate_discovery(
    corpus, cases, mode="hybrid"
)  # recall@k / NDCG@k / MRR / MAP + failure taxonomy
ev.evaluate_selection(
    corpus, cases, strategy="conservative"
)  # conditional commit rate + selection P/R/F1
ev.sweep_selector(corpus, cases)  # tune max_k × rel; .best() / .frontier() / .table()
ev.distractor_robustness_curve(source.scope, probes)  # accuracy vs catalog size

evaluate_selection’s headline is the conditional commit rate — the selection decision isolated from retrieval (did the selector keep the gold, given retrieval surfaced it?). sweep_selector scores a whole max_k × rel grid against the cases off one retrieval pass, so the selector defaults can be read off the data (.best()) rather than guessed. Generate cases by back-translation with ir.eval_gen (needs an LLM; scoring stays offline).

CLI

ir build skills                          # build/update a preset corpus
ir search skills "deploy the app"        # rank candidates (retrieval only)
ir search skills "deploy the app" --rerank cross-encoder  # + cross-encoder precision stage (ir[rerank])
ir discover skills "deploy the app"      # retrieve -> select
ir discover skills "deploy the app" --disclose       # + load bodies
ir discover skills "deploy the app" --min-score auto # + calibrated abstention
ir build sessions                        # index recent Claude Code sessions (turn pairs)
ir search sessions "numpy abi error" --mode lexical   # find past sessions
ir ls                                    # list corpora + record counts
ir coverage reports                      # disk-vs-index coverage (silent-gap detector)
ir info skills                           # config, stats, policy, calibrated floors
ir maintain --all                        # run due background work (idempotent; cron-friendly)
ir schedule                              # install/inspect the OS job that runs it
ir register notes files --root ~/notes --pattern '.*\.md$'  # register a custom corpus
ir rm notes                              # unregister (keeps built data)
ir eval-gen skills skills_eval.jsonl     # generate eval cases (needs aix/LLM)
ir eval skills skills_eval.jsonl         # score retrieval on a case file
ir eval-select skills skills_eval.jsonl  # score the selection stage
ir sweep-select skills skills_eval.jsonl # tune the selector (max_k × rel) on your corpus
ir calibrate-min-score skills skills_eval.jsonl --persist  # calibrate the abstention floor

Keeping an index fresh

ir maintain does the work that a corpus’s policy says is due — an incremental rebuild when reindex is due, LLM synopses inside their downtime window — and no-ops everything that is not. It is idempotent, so it is safe to run as often as you like.

Something has to call it, though, and a corpus that nobody rebuilds keeps answering searches confidently from months-old content. ir schedule installs that caller — a launchd agent on macOS, a crontab block elsewhere — and then lets you operate it:

ir schedule                # ensure a schedule exists (creates one if there is none)
ir schedule --status       # what is scheduled, when it last ran, and whether the OS loaded it
ir schedule --every 30m    # change the interval (15m / 2h / 1d, or plain minutes)
ir schedule --restart      # reload it after upgrading ir or switching interpreter
ir schedule --remove       # stop it and delete the definition
ir schedule --dry-run      # print the plist/crontab that would be written

Bare ir schedule is idempotent in the way that matters: it creates a schedule when there is none, and when one already exists it reports it and the commands above rather than silently reinstalling over an interval you tuned.

The job is pinned to the absolute interpreter that installed it, because neither launchd nor cron resolves ir on your shell’s PATH, and it carries the environment ir reads ($PP, $PTH_FILEPATH, IR_*, XDG_*) — launchd starts jobs with a near-empty environment, and without those the packages and reports corpora fail every time the job fires while it still exits 0.

An existing definition is data, not a template. Only a fresh install snapshots your shell; --every and --restart carry the stored environment forward untouched, so operating a working schedule from a shell that happens to lack $PP never silently re-points it at a different corpus store. To re-snapshot deliberately: ir schedule --remove && ir schedule.

--status is the health check. It reports the last run and the last log line, and flags three failures that otherwise look like success: an interpreter that no longer exists (a rebuilt pyenv version or recreated venv leaves a job that fires on time and does nothing), a job whose environment is missing something your shell has, and a non-zero exit status from the last run. --restart re-pins the interpreter.

ir still does not run a scheduler. It writes a definition, hands it to the OS, and exits: no daemon, no in-process timer. launchd/cron remain the executor, exactly as ir.policy describes.

For developers with a pulse

Set up and run the tests. The suite is hermetic (light embedder, in-memory stores, no network) and doctests count:

git clone https://github.com/i2mint/ir && cd ir
uv venv -q .venv && . .venv/bin/activate
uv pip install -q -e ".[dev]"
python -m pytest -q --doctest-modules ir tests
ruff check ir && ruff format --check ir

Contributing. The ir-dev-workflow skill (skills/ir-dev-workflow/SKILL.md) is also the human contributor guide: the module map, the CLI grammar golden, the conventions review enforces, and the ir/raglab boundary. New arguments default to today’s behaviour. Don’t bump the version: CI does that when a pull request merges to master, and the merge publishes to PyPI. thorwhalen/raglab depends on ir, so keep its tests green.

Design. The design is grounded in a set of capability-discovery research reports and eval-run findings under misc/docs/ (ir_01–ir_10): the single-search-tool pattern, indexing & embedding strategy, evaluation, the ef + vd reuse analysis, the dense-vs-lexical-vs-hybrid eval, selector tuning, abstention-floor calibration, magnitude-preserving fusion, the composable search-agent architecture (ir_09) and LLM routing (ir_10). ir is light by default (numpy / dol) and reuses the ecosystem (ef, vd, and aix for the opt-in LLM seams) only where it composes cleanly.

Where to ask. Open an issue on i2mint/ir. Issue #1 is the roadmap and epic #38 tracks the agentic-search direction.

This documentation as a single file: ir.md (Markdown, for agents).

built 2026-10-14 21:13 UTC from 537ca94 (master) · ir 0.1.48 · about this build