Skip to content

Asking Questions ​

/paper-rag:ask answers a question grounded in your active library. Retrieval and prose generation are split cleanly: the underlying query.py script only ranks and returns chunks as JSON — it never writes prose. Claude reads that JSON and writes the cited answer.

/paper-rag:ask <question> [--k 5] [--type text|figure|abstract] [--pmid PMID]
    [--where '<json>'] [--embedding-model M] [--dense-only] [--lexical-only] [--rrf-k 60]

How retrieval works ​

Each query runs two retrieval legs and fuses their rankings:

  1. Dense leg — your question is embedded with the library's embedding model and matched against Chroma via nearest-neighbor search.
  2. Lexical leg — a BM25 search over the same chunks' text, using the index built at ingest time.

Dense and lexical live in two separate pools — different stores, built independently at ingest time, each queried on its own at ask time:

paper-rag's two knowledge poolsingest.py writes chunks into two separate pools, a Chroma dense-vector pool and a BM25 lexical pool. ask.py's question queries both pools independently. Each pool returns its own ranking, and RRF fuse combines them into one ranked chunk list. ingest.py · writes chunks into both poolsingest.pywrites chunksask.py · questionask.pyquestiondense pool · Chroma collection (vector embeddings)dense poolChroma · vector embeddingslexical pool · BM25 inverted term indexlexical poolBM25 · inverted term indexRRF fuse · Σ 1/(k+rank)RRF fuseΣ 1/(k+rank)ranked chunks · top k, returned as JSONranked chunkstop k, as JSONwrite (ingest)read (query)ranked result
Dense pool and lexical pool are independent stores — no shared index, no cross-references. ingest.py writes new chunks into both; each ask writes nothing, it only reads both, independently, then RRF fuse combines the two rankings. If one pool is stale or missing (e.g. a deleted bm25/ dir), the other still answers on its own — see --dense-only / --lexical-only below.

The two rankings are combined with Reciprocal Rank Fusion (RRF): each chunk's score is 1/(rrf_k + dense_rank) + 1/(rrf_k + lexical_rank) (a term is omitted if the chunk didn't appear in that leg's results). This rewards chunks that rank well in either leg — a chunk ranked highly by exact keyword match but poorly by embedding similarity (or vice versa) still surfaces near the top, which plain dense-only search would miss. --rrf-k (default 60) controls how much the fusion favors top ranks over lower ones — lower values weight rank position more aggressively.

Use --dense-only or --lexical-only to isolate one leg, e.g. for debugging why a particular chunk isn't surfacing.

If the BM25 index for the collection is missing, the query silently falls back to dense-only and prints a warning to stderr — this shouldn't normally happen, since ingesting always rebuilds the index, but can happen if a home's bm25/ directory was deleted by hand.

Filtering results ​

  • --k N (default 5) — number of chunks returned, after fusion.
  • --claims-k N (default 3) — claim hits in the default merged strategy.
  • --strategy merged|chunks|claims|mixed — see Claims. Ignored with --type.
  • --type text|figure|abstract|claim — restrict to one row type (single search).
  • --pmid PMID — restrict to one paper.
  • --where '<json>' — a raw Chroma-style filter, for anything --type/--pmid don't cover. Supports $and, $or, $eq, $ne, $in, $nin, and bare equality, applied identically to both the dense and lexical legs (the lexical leg re-implements this filter logic itself, so scoped queries don't leak un-tagged papers into BM25 results).

--type and --pmid are shorthand that get folded into the same filter --where builds internally — you can't combine --where with --type/--pmid to add clauses; --where replaces them if given. Passing both --type and --pmid (without --where) combines them with $and.

Example — scope a query to one paper's figures:

/paper-rag:ask what does figure 2 show --pmid 31978945 --type figure

Example — scope to a project tag with --where (see Tags & Homes for tagging):

/paper-rag:ask summarize the methods --where '{"tags": "meta-analysis"}'

Claims ​

See Extracting Claims for how claims are made and checked.

Papers can carry extracted claims: short, standalone assertions written by Claude at ingest and stored as claim rows beside the chunks (see /paper-rag:extract-claims). Each claim records the ids of the chunks that support it (source_chunk_ids).

By default ask uses the merged strategy:

  1. one hybrid search over claims (--claims-k), one over chunks (--k);
  2. each claim hit gets its supporting chunks attached as source_chunks (--no-expand-claims turns this off);
  3. chunks already covered by a claim's sources are dropped from the chunk list.

Claude then writes the answer from the chunk text in source_chunks; the claim is a pointer to the right paper and passage, not the quoted wording. Papers without claims are found through their chunks alone, so merged equals plain chunk search on a library with no claims. --strategy chunks|claims|mixed run the alternatives, and /paper-rag:eval-retrieval compares them on your own labelled questions.

Claims are checked when stored: every source chunk must belong to the paper, each claim must carry an evidence_span quoted verbatim from a source chunk, and every number in the claim (and in effect_value / uncertainty_interval) must appear in those sources. Optional structured fields (population, intervention, comparator, outcome, direction, effect_value, effect_measure, uncertainty_interval, study_design) are stored as metadata and returned on claim hits; they are for display and comparison, not for filtering. Each claim also gets a source_level (abstract, fulltext or mixed), derived from the types of the chunks it cites. It is stored as metadata, so --where '{"source_level": "abstract"}' works with --type claim.

Embedding model ​

--embedding-model M queries a specific model's collection instead of the home's default. If nothing was ever ingested with that model, the query returns an empty result and a warning listing which collections do exist — relay this warning rather than treating an empty answer as "no matching papers."

Citations and the References block ​

Each returned chunk carries id, type, doc_key, text, and — when applicable — pmid, title, section, page, path, dense_rank, lexical_rank, and rrf_score. From these, Claude:

  • Grounds every claim in a retrieved chunk, citing inline by PMID and title (e.g. "(Kim et al., PMID 12345678)"), noting section/page where useful.
  • For type: "figure" hits, mentions the figure and links its path (the saved PNG).
  • For type: "abstract" hits, cites it as the paper's abstract, with no page/section.
  • Says plainly when no relevant chunks were returned, rather than guessing.

After the prose answer, Claude collects the distinct PMIDs (or doc_keys, if a cited paper has no PMID) actually cited, runs /paper-rag:cite on them, and appends the BibTeX output verbatim under a ## References heading — see Citing & Filtering for what that BibTeX looks like.