Retrieval pipeline¶
Every query runs through a RetrieverPipeline — an sklearn-shaped chain of
named, addressable steps (Pipeline([(name, step), …])). A RetrieverPipeline
is a RetrieverStep, so pipelines compose recursively: nest one as a step
inside another for sub-routing, and address any step by name
(pipeline["fetch"]) for introspection or testing.
The default chunk-search pipeline (dense + graph expansion)¶
The shipped default (python/pydocs_mcp/pipelines/chunk_search_graph.yaml) is a
seven-step dense + reference-graph chain:
pre_filter— parse + validate + scope-split; writes a typed result tostate.scratchfor the fetcher.dense_fetcher— embed the query, ANN-search the.tqsidecar; writes the candidate set (the ANN index score is used directly as relevance — no separate scorer step).metadata_post_filter— apply any remainingSearchQuery.post_filterin-memory.graph_expand— seed from the top dense hits, add their 1-hop caller/callee neighbours (the structurally-adjacent code an embedding alone misses).top_k_filter— sort by relevance, keep top K.limit— cap the final item count.token_budget_formatter— render the composite chunk for MCP output.
The former BM25-only chain (chunk_search.yaml) remains a shipped preset. On the
RepoQA benchmark, this dense + graph default lifts recall@10 from 0.40 (BM25) to
0.77 on standard queries and to 1.00 on structurally-reachable answers.
Dense, hybrid, late-interaction, and tree-reasoning retrieval¶
Several more retrieval modes ship as opt-in pipeline presets:
Dense (
chunk_search_dense.yaml,chunk_search_dense_ranked.yaml) — aDenseFetcherStepqueries the TurboQuant vector store using embeddings from the configuredEmbedder(FastEmbedBAAI/bge-small-en-v1.5by default; OpenAI and the on-devicesentence_transformersprovider —Qwen/Qwen3-Embedding-0.6B,Alibaba-NLP/gte-modernbert-base, or the code-strongcodefuse-ai/F2LLM-v2-0.6B— optional). Its ANN index score is used directly as relevance, so the dense presets carry no separate scorer step.Hybrid (
chunk_search_hybrid.yaml,chunk_search_hybrid_ranked.yaml) — aParallelStepruns the BM25 and dense branches concurrently, then anRRFFusionStepmerges them with reciprocal-rank fusion into one ranking, followed by a post-fusionDenseScorerStepre-rank over the fused candidates.Graph (dense + reference-graph expansion) (
chunk_search_graph.yaml,chunk_search_graph_ranked.yaml) — aGraphExpandStepseeds from the top dense hits and pulls in their 1-hop reference-graph neighbours (callers / callees / overriding subclass methods), merging them into the dense ranking bymax(dense_sim, seed_sim · decay)— embedding-centric (no RRF/BM25). Recovers the structurally-adjacent answer a dense embedder misses; degrades to dense-only when the index has no reference graph. See the structural-recall benchmark.Late-interaction — opt-in multi-vector (ColBERT / PyLate via fast-plaid) MaxSim scoring; enable with the
[late-interaction]extra andlate_interaction.enabled: true.LLM tree-reasoning (
tree_only.yaml,chunk_search_with_tree_reasoning_parallel.yaml,chunk_search_with_tree_reasoning_after.yaml) — vectorless RAG: anLlmTreeReasoningStepwalks the project’sDocumentNodetree (each node enriched with its real signature, decorators, and a docstring excerpt) with an LLM and fetches the nodes it selects — no embeddings required. Opt-in via thellm:config section; can run standalone, in a branch parallel to hybrid, or as a two-stage reranker over a BM25/dense candidate set (rerank_candidates).
Select a preset by pointing the chunk pipeline at it in your config overlay (see
Configuration); the default is dense + graph expansion
(chunk_search_graph.yaml).
Graph analytics (opt-in)¶
Two further reference-graph signals are computed at index time when enabled
(both off by default; PageRank/community detection needs the [graph] extra —
pip install 'pydocs-mcp[graph]'):
Node scores (
reference_graph.node_scores.enabled: true) — a single post-index pass computes in-degree, PageRank, and Louvain community per symbol into anode_scorestable. Two rerank steps consume it:centrality_prior(boost structurally central “god-node” APIs by a normalised PageRank/in-degree prior — rerank-only, can’t hurt recall) andcommunity_diversity(greedy MMR-by-community so the top-k spans subsystems instead of near-duplicates from one module). Add either step to a chunk pipeline YAML.Parent rollup (
parent_rollupstep in a chunk pipeline YAML) — when several results are children of one symbol or document section (e.g. three methods of the same class), the step replaces them with the parent’s own indexed chunk at the group’s best rank. Collapse requires at least two co-retrieved siblings AND a per-kind share of the parent’s children, tuned viamin_coverage_by_kind(defaults:class: 0.3,module: 0.6,markdown_heading: 0.5; a supplied mapping replaces the defaults wholesale) with themin_coveragefallback (0.5) for other kinds. Place it aftertop_k_filterand beforelimit:- name: rollup type: parent_rollup params: min_coverage: 0.5 min_coverage_by_kind: class: 0.3 module: 0.6 markdown_heading: 0.5
Similar edges (
reference_graph.similar_edges.enabled: true,top_m: N) — thesynthesize_similar_edgesingestion stage addskind='similar'embedding-kNN edges between each symbol and its nearest neighbours, densifying the AST graph sograph_expand(withkinds: [calls, inherits, similar]) can reach semantically-related code that has no call/inherit edge.similaredges are excluded from node-score centrality (which stays structural). Note: the kNN runs over chunks embedded in the current index pass, so a complete similar-edge graph requires a fullindex --force; an incremental reindex of a touched package recomputes them from its re-embedded chunks only.
Embedder inference runs on CPU by default. Pass --gpu to serve / index /
watch to move it onto CUDA — same vectors, same cache, lower latency (see
GPU inference).
Routing¶
ConditionalStep and RouteStep route per query type — e.g. send long or
structural queries down a different branch than short keyword lookups — without
modifying the branches themselves.
Building pipelines in Python¶
For tests, benchmarks, or embedded usage, build an IngestionPipeline and a
RetrieverPipeline programmatically, no YAML required:
import asyncio
import tempfile
from pathlib import Path
from pydocs_mcp.application import ProjectIndexer
from pydocs_mcp.db import open_index_database
from pydocs_mcp.extraction import (
AstMemberExtractor,
PipelineChunkExtractor,
StaticDependencyResolver,
build_ingestion_pipeline,
)
from pydocs_mcp.models import SearchQuery
from pydocs_mcp.retrieval.config import AppConfig
from pydocs_mcp.retrieval.formatters import ChunkFormatter
from pydocs_mcp.retrieval.pipeline import (
PerCallConnectionProvider,
RetrieverPipeline,
RetrieverState,
)
from pydocs_mcp.retrieval.steps import (
BM25ScorerStep,
ChunkFetcherStep,
LimitStep,
MetadataPostFilterStep,
TokenBudgetStep,
TopKFilterStep,
)
from pydocs_mcp.storage.factories import (
build_connection_provider,
build_sqlite_indexing_service,
build_sqlite_uow_factory,
)
from pydocs_mcp.storage.sqlite import SqliteChunkRepository, SqliteFilterAdapter
async def main() -> None:
# 1. Fresh SQLite + ingestion pipeline from default AppConfig
db_path = Path(tempfile.mkstemp(suffix=".sqlite")[1])
open_index_database(db_path).close()
config = AppConfig.load()
indexer = ProjectIndexer(
indexing_service=build_sqlite_indexing_service(db_path),
dependency_resolver=StaticDependencyResolver(),
chunk_extractor=PipelineChunkExtractor(pipeline=build_ingestion_pipeline(config)),
member_extractor=AstMemberExtractor(),
uow_factory=build_sqlite_uow_factory(db_path),
)
await indexer.index_project(Path("/path/to/your/project"))
await SqliteChunkRepository(provider=build_connection_provider(db_path)).rebuild_index()
# 2. RetrieverPipeline composed from named, addressable steps
provider = PerCallConnectionProvider(cache_path=db_path)
pipeline = RetrieverPipeline(
name="chunk_search",
steps=(
("fetch", ChunkFetcherStep(provider=provider, filter_adapter=SqliteFilterAdapter())),
("score", BM25ScorerStep(name="bm25_scorer")),
("post_filter", MetadataPostFilterStep(name="metadata_post_filter")),
("topk", TopKFilterStep(name="top_k_filter")),
("limit", LimitStep(name="limit")),
("budget", TokenBudgetStep(formatter=ChunkFormatter(), budget=2000, name="token_budget_formatter")),
),
)
# 3. Run a search
state = await pipeline.run(RetrieverState(query=SearchQuery(terms="async retry")))
if state.result is not None:
print(state.result.items[0].text[:500])
asyncio.run(main())