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 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=, ormin_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?
iris a retrieval substrate, not a search agent — it has no agent of its own.ir.discoveris a deterministic, single-shotretrieve → select → disclosepipeline: 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
ircorpora — reach forraglab, which is built on top ofir(raglabimportsir;irnever importsraglab, so it stays installable-light and offline-by-default).Prefer to drive it from Claude Code instead of a coded agent?
irships a reviewing search subagent and anir-searchskill (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 (seeir_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, viasentence-transformers), downloaded on first build (needs network) and cached under~/.cache/ir. For a fast, offline, dependency-light run — tests, CI, quick experiments — passembedder="light"(a numpy-only hashing embedder):ir.build(source, embedder="light"). irsetsUSE_TF=0on import sotransformersdoes not pull in TensorFlow (which crashes on some numpy ABIs); importirbefore anything that importstransformers.- Case generation (
ir.eval_gen) and the optional LLM selector need an LLM viaaix— 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 — seeir_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.
Metadata
Release files for ir 0.1.49
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| ir-0.1.49.tar.gz | 376.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| ir-0.1.49-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 571.0 kB
Release files / ir-0.1.49.tar.gz
| Download URL | ir-0.1.49.tar.gz |
|---|---|
| Size | 376.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f335fd8e079e2f8bd778dce62de455732a78be6208a3ec34182fac786a175bb7
|
|
BLAKE2b-256 checksum How to use checksums |
a304b00fa25048687434d327972d055a0c28e7b3c0b803f1048958405ff7d6e8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.13.1 {"installer":{"name":"uv","version":"0.13.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / ir-0.1.49-py3-none-any.whl
| Download URL | ir-0.1.49-py3-none-any.whl |
|---|---|
| Size | 194.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
c2c891de9c242ea0ffbca984481f7a2e2984997f6e1fde5bc82429417d138df0
|
|
BLAKE2b-256 checksum How to use checksums |
a865b86e5430fb85345ac3e8317e6dee1660a266565b6233ef6b74cdfefd4a19
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.13.1 {"installer":{"name":"uv","version":"0.13.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|