"""CLI for running offline LangRAG retrieval benchmarks.""" from __future__ import annotations import argparse import asyncio import hashlib import importlib import importlib.util import json import os import platform import subprocess import sys import time from copy import deepcopy from datetime import datetime, timezone from pathlib import Path from typing import Any from benchmarks.metrics import dedupe_in_order, evaluate_query, mean_metrics from benchmarks.sdk_stubs import install_stubs install_stubs() from components.knowledge_engine.langrag import LangRAG # noqa: E402 from langbot_plugin.api.entities.builtin.rag import ( # noqa: E402 DocumentStatus, FileMetadata, FileObject, IngestionContext, ParseContext, ParseResult, RetrievalContext, TextSection, ) from benchmarks.local_runtime import LocalBenchmarkPlugin # noqa: E402 REPO_ROOT = Path(__file__).resolve().parents[1] DEFAULT_DATASET = REPO_ROOT / "benchmarks/datasets/mini_zh.json" DEFAULT_CONFIG = REPO_ROOT / "benchmarks/configs/local_retrieval.json" DEFAULT_OUT_DIR = REPO_ROOT / "benchmarks/runs" DEFAULT_PARSER_REPO = REPO_ROOT.parents[1] / "misc/GeneralParsers" def _read_json(path: Path) -> dict[str, Any]: with path.open("r", encoding="utf-8") as file: return json.load(file) def _sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as file: for chunk in iter(lambda: file.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def _git_commit() -> str | None: try: result = subprocess.run( ["git", "rev-parse", "HEAD"], check=True, capture_output=True, text=True, cwd=REPO_ROOT, ) return result.stdout.strip() except Exception: return None def _git_dirty() -> bool | None: try: result = subprocess.run( ["git", "status", "--short"], check=True, capture_output=True, text=True, cwd=REPO_ROOT, ) return bool(result.stdout.strip()) except Exception: return None def _merge_settings(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: result = deepcopy(base) result.update(deepcopy(override)) return result def _raw_bytes(document: dict[str, Any]) -> bytes: raw = document.get("raw_content", document.get("content", "")) return raw.encode(document.get("encoding", "utf-8")) def _filename(document: dict[str, Any]) -> str: return document.get("filename", f"{document.get('title', document['id'])}.txt") def _mime_type(document: dict[str, Any]) -> str: return document.get("mime_type", "text/plain") def _make_sections(section_rows: list[dict[str, Any]]) -> list[TextSection]: return [ TextSection( content=row["content"], heading=row.get("heading"), level=row.get("level", 0), page=row.get("page"), metadata=row.get("metadata", {}), ) for row in section_rows ] def _load_general_parsers_class(parser_repo: Path): components_dir = parser_repo / "components" package_dir = components_dir / "general_parsers" init_path = package_dir / "__init__.py" if not init_path.exists(): raise FileNotFoundError(f"GeneralParsers package not found: {package_dir}") root_name = "_langbot_parser_components" if root_name not in sys.modules: spec = importlib.util.spec_from_loader(root_name, loader=None) module = importlib.util.module_from_spec(spec) module.__path__ = [str(components_dir)] sys.modules[root_name] = module package_name = f"{root_name}.general_parsers" if package_name not in sys.modules: spec = importlib.util.spec_from_file_location( package_name, init_path, submodule_search_locations=[str(package_dir)], ) if spec is None or spec.loader is None: raise ImportError(f"Could not load parser package from {init_path}") module = importlib.util.module_from_spec(spec) sys.modules[package_name] = module spec.loader.exec_module(module) module = importlib.import_module(f"{package_name}.general_parsers") return module.GeneralParsers async def _general_parse( document: dict[str, Any], parser_repo: Path, plugin: LocalBenchmarkPlugin, ) -> ParseResult: parser_class = _load_general_parsers_class(parser_repo) parser = parser_class() parser.plugin = plugin return await parser.parse( ParseContext( file_content=_raw_bytes(document), filename=_filename(document), mime_type=_mime_type(document), metadata={"dataset_document_id": document["id"]}, ) ) async def _parsed_content_for_document( document: dict[str, Any], parser_mode: str, parser_repo: Path, plugin: LocalBenchmarkPlugin, ) -> ParseResult | None: if parser_mode == "internal": return None if parser_mode == "general_parsers": return await _general_parse(document, parser_repo, plugin) if parser_mode != "preparsed": raise ValueError(f"Unknown parser_mode: {parser_mode}") content = document.get("content", document.get("raw_content", "")) return ParseResult( text=content, sections=_make_sections(document.get("sections", [])), metadata={ "dataset_document_id": document["id"], "dataset_title": document.get("title", document["id"]), "parser_mode": parser_mode, }, ) def _make_ingestion_context( document: dict[str, Any], knowledge_base_id: str, creation_settings: dict[str, Any], parsed_content: ParseResult | None, ) -> IngestionContext: return IngestionContext( file_object=FileObject( metadata=FileMetadata( filename=_filename(document), file_size=len(_raw_bytes(document)), mime_type=_mime_type(document), document_id=document["id"], knowledge_base_id=knowledge_base_id, ), storage_path=f"dataset://{document['id']}", ), knowledge_base_id=knowledge_base_id, creation_settings=creation_settings, parsed_content=parsed_content, ) def _make_retrieval_context( query: dict[str, Any], knowledge_base_id: str, creation_settings: dict[str, Any], retrieval_settings: dict[str, Any], ) -> RetrievalContext: return RetrievalContext( query=query["query"], knowledge_base_id=knowledge_base_id, creation_settings=creation_settings, retrieval_settings=retrieval_settings, filters=query.get("filters", {}), ) async def _run_experiment( dataset: dict[str, Any], experiment: dict[str, Any], k_values: list[int], parser_repo: Path, ) -> dict[str, Any]: name = experiment["name"] knowledge_base_id = f"bench_{name}" engine = LangRAG() plugin = LocalBenchmarkPlugin() from benchmarks.state_fixture import attach_installation_state await attach_installation_state(plugin) engine.plugin = plugin parser_mode = experiment.get("parser_mode", "preparsed") creation_settings = _merge_settings( { "embedding_model_uuid": "local-hash-256", "index_type": "chunk", "chunk_size": 512, "overlap": 50, }, experiment.get("creation_settings", {}), ) retrieval_settings = _merge_settings( { "top_k": max(k_values), "search_type": "vector", "query_rewrite": "off", "rerank": "off", }, experiment.get("retrieval_settings", {}), ) retrieval_settings["top_k"] = max( int(retrieval_settings.get("top_k", 0)), max(k_values), ) started = time.perf_counter() parser_seconds = 0.0 parsed_sections = 0 ingestion_results = [] for document in dataset["documents"]: storage_path = f"dataset://{document['id']}" plugin.storage[storage_path] = _raw_bytes(document) parser_started = time.perf_counter() parsed_content = await _parsed_content_for_document( document=document, parser_mode=parser_mode, parser_repo=parser_repo, plugin=plugin, ) parser_seconds += time.perf_counter() - parser_started if parsed_content is not None: parsed_sections += len(parsed_content.sections or []) context = _make_ingestion_context( document=document, knowledge_base_id=knowledge_base_id, creation_settings=creation_settings, parsed_content=parsed_content, ) result = await engine.ingest(context) ingestion_results.append( { "document_id": result.document_id, "status": getattr(result.status, "value", result.status), "chunks_created": result.chunks_created, "error_message": result.error_message, } ) ingest_seconds = time.perf_counter() - started failed = [ row for row in ingestion_results if row["status"] != getattr(DocumentStatus.COMPLETED, "value", "completed") ] if failed: raise RuntimeError(f"Experiment {name} ingestion failed: {failed}") query_rows = [] metric_rows = [] retrieval_started = time.perf_counter() for query in dataset["queries"]: context = _make_retrieval_context( query=query, knowledge_base_id=knowledge_base_id, creation_settings=creation_settings, retrieval_settings=retrieval_settings, ) response = await engine.retrieve(context) retrieved = [] for entry in response.results: metadata = entry.metadata retrieved.append( { "id": entry.id, "document_id": metadata.get("document_id"), "document_name": metadata.get("document_name"), "distance": entry.distance, "score": entry.score, "heading_path": metadata.get("heading_path", ""), } ) retrieved_document_ids = [ item["document_id"] for item in retrieved if item.get("document_id") ] ranked_document_ids = dedupe_in_order(retrieved_document_ids) metrics = evaluate_query( relevant_document_ids=query["relevant_document_ids"], retrieved_document_ids=ranked_document_ids, k_values=k_values, ) metrics.update( _heading_metrics( expected_headings=query.get("relevant_headings", []), retrieved=retrieved, k_values=k_values, ) ) metric_rows.append(metrics) query_rows.append( { "id": query["id"], "query": query["query"], "relevant_document_ids": query["relevant_document_ids"], "ranked_document_ids": ranked_document_ids, "retrieved": retrieved, "metrics": metrics, } ) retrieve_seconds = time.perf_counter() - retrieval_started summary = mean_metrics(metric_rows) summary.update( { "parser_mode": parser_mode, "documents": len(dataset["documents"]), "queries": len(dataset["queries"]), "chunks_created": sum(row["chunks_created"] for row in ingestion_results), "vectors_stored": plugin.vector_count(knowledge_base_id), "vectors_with_heading_path": _count_heading_vectors( plugin, knowledge_base_id, ), "parsed_sections": parsed_sections, "embedding_calls": plugin.embedding_calls, "llm_calls": plugin.llm_calls, "parser_seconds": parser_seconds, "ingest_seconds": ingest_seconds, "retrieve_seconds": retrieve_seconds, } ) return { "name": name, "description": experiment.get("description", ""), "parser_mode": parser_mode, "creation_settings": creation_settings, "retrieval_settings": retrieval_settings, "summary": summary, "ingestion": ingestion_results, "queries": query_rows, } def _count_heading_vectors(plugin: LocalBenchmarkPlugin, collection_id: str) -> int: return sum( 1 for record in plugin.collections.get(collection_id, {}).values() if record.metadata.get("heading_path") ) def _heading_metrics( expected_headings: list[str], retrieved: list[dict[str, Any]], k_values: list[int], ) -> dict[str, float]: if not expected_headings: return {} lowered = [heading.lower() for heading in expected_headings] result = {} for k in k_values: top_items = retrieved[:k] hit = any( expected in (item.get("heading_path") or "").lower() for item in top_items for expected in lowered ) result[f"heading_hit@{k}"] = 1.0 if hit else 0.0 return result async def run_benchmark( dataset_path: Path, config_path: Path, out_dir: Path, run_id: str | None = None, parser_repo: Path = DEFAULT_PARSER_REPO, ) -> dict[str, Any]: dataset = _read_json(dataset_path) config = _read_json(config_path) k_values = [ int(k) for k in config.get("metrics", {}).get("k_values", [1, 3, 5]) ] timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") if run_id is None: run_id = f"{timestamp}_{_sha256(config_path)[:8]}_{_sha256(dataset_path)[:8]}" experiments = [] for experiment in config["experiments"]: experiments.append( await _run_experiment( dataset=dataset, experiment=experiment, k_values=k_values, parser_repo=parser_repo, ) ) result = { "run": { "run_id": run_id, "timestamp_utc": timestamp, "git_commit": _git_commit(), "git_dirty": _git_dirty(), "python": sys.version, "platform": platform.platform(), "dataset_path": str(dataset_path), "dataset_sha256": _sha256(dataset_path), "config_path": str(config_path), "config_sha256": _sha256(config_path), "parser_repo": str(parser_repo), }, "dataset": { "id": dataset.get("id", dataset_path.stem), "description": dataset.get("description", ""), "documents": len(dataset["documents"]), "queries": len(dataset["queries"]), }, "config": { "id": config.get("id", config_path.stem), "description": config.get("description", ""), "k_values": k_values, }, "experiments": experiments, } run_dir = out_dir / run_id run_dir.mkdir(parents=True, exist_ok=True) output_path = run_dir / "results.json" result["run"]["output_path"] = str(output_path) with output_path.open("w", encoding="utf-8") as file: json.dump(result, file, ensure_ascii=False, indent=2) return result def _print_summary(result: dict[str, Any]) -> None: k_values = result["config"]["k_values"] primary_k = 3 if 3 in k_values else k_values[-1] metric_names = [ f"hit@{primary_k}", f"all_relevant@{primary_k}", f"recall@{primary_k}", f"mrr@{primary_k}", f"ndcg@{primary_k}", ] if any( f"heading_hit@{primary_k}" in experiment["summary"] for experiment in result["experiments"] ): metric_names.append(f"heading_hit@{primary_k}") header = [ "experiment", "parser", "vectors", "headings", "chunks", "parser_s", "ingest_s", "retrieve_s", *metric_names, ] print("\t".join(header)) for experiment in result["experiments"]: summary = experiment["summary"] row = [ experiment["name"], experiment["parser_mode"], str(summary["vectors_stored"]), str(summary["vectors_with_heading_path"]), str(summary["chunks_created"]), f"{summary['parser_seconds']:.4f}", f"{summary['ingest_seconds']:.4f}", f"{summary['retrieve_seconds']:.4f}", *(f"{summary[name]:.4f}" for name in metric_names), ] print("\t".join(row)) print(f"\nWrote benchmark results to {result['run']['output_path']}") def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--dataset", type=Path, default=DEFAULT_DATASET) parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG) parser.add_argument("--out", type=Path, default=DEFAULT_OUT_DIR) parser.add_argument("--run-id", default=None) parser.add_argument( "--parser-repo", type=Path, default=Path(os.environ.get("LANGBOT_PARSER_REPO", DEFAULT_PARSER_REPO)), ) return parser.parse_args() def main() -> None: args = parse_args() result = asyncio.run( run_benchmark( dataset_path=args.dataset, config_path=args.config, out_dir=args.out, run_id=args.run_id, parser_repo=args.parser_repo, ) ) _print_summary(result) if __name__ == "__main__": main()