Skip to content

feat: CRAG pipeline hardening — hybrid retrieval, citations, config unification#8

Draft
dtype2100 wants to merge 2 commits into
masterfrom
cursor/rag-improvement-roadmap-630a
Draft

feat: CRAG pipeline hardening — hybrid retrieval, citations, config unification#8
dtype2100 wants to merge 2 commits into
masterfrom
cursor/rag-improvement-roadmap-630a

Conversation

@dtype2100

Copy link
Copy Markdown
Owner

Summary

Implements the recommended improvement roadmap for the Advanced-RAG CRAG pipeline, closing the gap between designed architecture and the live query path.

P0 — Runtime fixes

  • Introduce ChunkHit helpers (app/rag/types.py) to unify dict/string chunk handling
  • Fix expansion_policy and grounding_evaluator type mismatches
  • Return structured SourceCitation objects from /api/v1/query instead of raw dicts

P1 — Retrieval & ingest integration

  • Wire hybrid_retrieve node to hybrid_retriever (vector + BM25 RRF) with optional multi-query fusion
  • Add corpus_registry synced during ingest for BM25 corpus + parent docstore
  • Enable parent-child chunking on ingest with metadata-aware context expansion
  • Pass top_k from API request into graph state

P1 — Configuration consolidation

  • Move VECTOR_BACKEND, RERANKER_BACKEND, MULTI_QUERY, USE_PARENT_CHILD_CHUNKING, API_KEY, and judge LLM overrides into Settings
  • Update providers/factory to read from central config
  • Expand .env.example

P2 — Production readiness

  • Optional API key auth on protected routes (X-API-Key)
  • LLM readiness probe on /api/v1/health (llm field)
  • SSE streaming endpoint: POST /api/v1/query/stream
  • New integration/unit tests (72 passing)
  • Makefile PATH fix for pytest/ruff in ~/.local/bin

P3 — Documentation

  • README updated to match current CRAG architecture and project layout
  • Optional dependency groups in pyproject.toml (bm25, reranker, loaders)

Test plan

  • make lint passes
  • make test — 72 tests passing
  • Manual: ingest docs → query with vLLM running
  • Manual: verify BM25 hybrid retrieval after ingest (pip install -e ".[bm25]")
  • Manual: test SSE stream via POST /api/v1/query/stream
Open in Web Open in Cursor 

…nification

- Add ChunkHit helpers and fix dict/string type mismatches in policies/evaluators
- Wire hybrid_retrieve to hybrid_retriever + BM25 corpus with multi-query RRF
- Enable parent-child ingest, corpus registry, and metadata-aware expansion
- Return structured SourceCitation objects from /query API
- Consolidate env settings (VECTOR_BACKEND, RERANKER, MULTI_QUERY, API_KEY)
- Add optional API key auth, LLM health probe, and SSE /query/stream endpoint
- Expand tests (72 passing), Makefile PATH fix, README and .env.example update

Co-authored-by: jwlee <dtype2100@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 35770c96-71a0-4158-8058-14360b1b7c2a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cursor/rag-improvement-roadmap-630a

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request upgrades the RAG pipeline to a self-corrective RAG (CRAG) architecture, introducing hybrid retrieval (vector + BM25), parent-child chunking, context expansion, and an SSE streaming endpoint, alongside optional API key authentication. Feedback on these changes highlights a critical process isolation issue where the in-memory registry fails to sync across async ARQ workers or multi-worker API setups, suggesting Redis as a shared store. Additionally, the review identifies a performance bottleneck in the BM25 corpus registry, an event loop blocking issue in the async health endpoint due to synchronous HTTP calls, and opportunities for defensive programming when handling missing metadata or non-dictionary retrieval results.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +56 to +57
docstore = {p["metadata"]["chunk_id"]: p["text"] for p in parents}
register_docstore(docstore)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

⚠️ Process Isolation Issue with In-Memory Registry

Calling register_docstore and register_documents updates an in-memory registry (_docstore and _corpus_docs) inside the current process. This introduces a major architectural issue in production:

  1. ARQ Worker Isolation: When asynchronous ingestion is enabled (INGEST_QUEUE_ASYNC=true), the ingestion pipeline runs in the background ARQ worker process (make worker). The worker will update its own in-memory registry, but the API worker process (handling /api/v1/query) will remain empty. As a result, BM25 hybrid retrieval and parent-child expansion will fail on the query path.
  2. Multi-Worker API: Even with synchronous ingestion, if the FastAPI app is run with multiple Uvicorn workers (e.g., --workers 4), the in-memory state will not be shared or synchronized across the processes.

Suggested Resolution

To make this production-ready, you should persist and share this state across processes. Since Redis is already required for the async queue, you can use Redis as the shared store:

  • Store the parent docstore in Redis using a Hash (HSET/HGET).
  • For BM25, consider persisting the serialized BM25 index/corpus to Redis or a shared volume, or leverage a database/search backend that natively supports hybrid search (like Qdrant's hybrid search or pgvector) instead of an in-memory BM25 registry.

Comment on lines +7 to +45
_corpus_docs: list[dict[str, Any]] = []
_docstore: dict[str, str] = {}


def register_documents(docs: list[dict[str, Any]]) -> None:
"""Append indexed chunks to the BM25 corpus, deduplicating by text."""
seen = {d["text"] for d in _corpus_docs}
for doc in docs:
text = doc.get("text", "")
if text and text not in seen:
seen.add(text)
_corpus_docs.append(
{
"text": text,
"score": 0.0,
"metadata": dict(doc.get("metadata") or {}),
}
)


def register_docstore(entries: dict[str, str]) -> None:
"""Merge parent-id → parent-text mappings into the docstore."""
_docstore.update(entries)


def get_corpus_docs() -> list[dict[str, Any]]:
"""Return a snapshot of the in-memory BM25 corpus."""
return list(_corpus_docs)


def get_docstore() -> dict[str, str]:
"""Return a snapshot of the parent docstore."""
return dict(_docstore)


def clear() -> None:
"""Reset corpus and docstore (primarily for tests)."""
_corpus_docs.clear()
_docstore.clear()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

⚡ Performance Bottleneck: $O(N^2)$ Batch Ingestion

Currently, register_documents builds a new set of all existing document texts (seen = {d["text"] for d in _corpus_docs}) on every single call. As the corpus grows, this set creation becomes increasingly expensive, leading to $O(N^2)$ time complexity for batch ingestion.

We can optimize this to $O(1)$ per document by maintaining a persistent module-level set _seen_texts to track already registered document texts.

_corpus_docs: list[dict[str, Any]] = []
_docstore: dict[str, str] = {}
_seen_texts: set[str] = set()


def register_documents(docs: list[dict[str, Any]]) -> None:
    """Append indexed chunks to the BM25 corpus, deduplicating by text."""
    for doc in docs:
        text = doc.get("text", "")
        if text and text not in _seen_texts:
            _seen_texts.add(text)
            _corpus_docs.append(
                {
                    "text": text,
                    "score": 0.0,
                    "metadata": dict(doc.get("metadata") or {}),
                }
            )


def register_docstore(entries: dict[str, str]) -> None:
    """Merge parent-id → parent-text mappings into the docstore."""
    _docstore.update(entries)


def get_corpus_docs() -> list[dict[str, Any]]:
    """Return a snapshot of the in-memory BM25 corpus."""
    return list(_corpus_docs)


def get_docstore() -> dict[str, str]:
    """Return a snapshot of the parent docstore."""
    return dict(_docstore)


def clear() -> None:
    """Reset corpus and docstore (primarily for tests)."""
    _corpus_docs.clear()
    _docstore.clear()
    _seen_texts.clear()

Comment thread app/core/llm_health.py
Comment on lines +14 to +30
def probe_llm() -> str:
"""Return a short status string for the configured LLM backend."""
if settings.llm_backend == "openai":
if not settings.openai_api_key:
return "not_configured"
return "configured"

try:
url = f"{settings.vllm_base_url.rstrip('/')}/models"
with httpx.Client(timeout=2.0) as client:
response = client.get(url)
if response.status_code == 200:
return "ready"
return f"error: HTTP {response.status_code}"
except Exception as exc:
logger.debug("LLM health probe failed: %s", exc)
return f"unreachable: {exc}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

🔄 Event Loop Blocking in Async Endpoint

The probe_llm function performs a synchronous HTTP request using httpx.Client with a 2.0-second timeout. Since this function is called directly inside the asynchronous /health endpoint (async def health()), it will block the ASGI event loop for up to 2 seconds if the LLM backend is slow or unreachable. This can degrade the performance of the entire application.

We should make probe_llm asynchronous using httpx.AsyncClient so it can be awaited without blocking the event loop.

Suggested change
def probe_llm() -> str:
"""Return a short status string for the configured LLM backend."""
if settings.llm_backend == "openai":
if not settings.openai_api_key:
return "not_configured"
return "configured"
try:
url = f"{settings.vllm_base_url.rstrip('/')}/models"
with httpx.Client(timeout=2.0) as client:
response = client.get(url)
if response.status_code == 200:
return "ready"
return f"error: HTTP {response.status_code}"
except Exception as exc:
logger.debug("LLM health probe failed: %s", exc)
return f"unreachable: {exc}"
async def probe_llm() -> str:
"""Return a short status string for the configured LLM backend."""
if settings.llm_backend == "openai":
if not settings.openai_api_key:
return "not_configured"
return "configured"
try:
url = f"{settings.vllm_base_url.rstrip('/')}/models"
async with httpx.AsyncClient(timeout=2.0) as client:
response = await client.get(url)
if response.status_code == 200:
return "ready"
return f"error: HTTP {response.status_code}"
except Exception as exc:
logger.debug("LLM health probe failed: %s", exc)
return f"unreachable: {exc}"

Comment thread app/api/v1/health.py
status="ok",
llm_backend=settings.llm_backend,
llm_model=settings.llm_model,
llm=probe_llm(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Await the asynchronous probe_llm() function to prevent blocking the event loop.

Suggested change
llm=probe_llm(),
llm=await probe_llm(),

Comment on lines +58 to +61
for child in children:
parent_id = child["metadata"].get("parent_id")
if parent_id and parent_id in docstore:
child["metadata"]["parent_text"] = docstore[parent_id]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

🛡️ Defensive Programming: Safe Metadata Access

If child does not contain a "metadata" key, or if "metadata" is None, accessing child["metadata"].get("parent_id") will raise a KeyError or AttributeError. We should safely access the metadata dictionary.

Suggested change
for child in children:
parent_id = child["metadata"].get("parent_id")
if parent_id and parent_id in docstore:
child["metadata"]["parent_text"] = docstore[parent_id]
for child in children:
meta = child.get("metadata")
if meta is None:
meta = {}
child["metadata"] = meta
parent_id = meta.get("parent_id")
if parent_id and parent_id in docstore:
meta["parent_text"] = docstore[parent_id]

Comment on lines +10 to +30
def build_source_citations(results: list[dict[str, Any]]) -> list[SourceCitation]:
"""Convert retrieval dicts into structured ``SourceCitation`` models.

Args:
results: List of ``{text, score, metadata}`` retrieval hits.

Returns:
Ordered list of citations for the API response.
"""
citations: list[SourceCitation] = []
for result in results:
meta = result.get("metadata") or {}
citations.append(
SourceCitation(
text=str(result.get("text", "")),
source=str(meta.get("source", "unknown")),
page=str(meta.get("page", "")),
score=float(result.get("score", 0.0)),
)
)
return citations

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

🛡️ Defensive Programming: Handle Non-Dict Results

To prevent potential runtime AttributeErrors (e.g., if a retriever or mock returns plain strings instead of dictionaries), we should defensively handle cases where a result is a string rather than a dictionary.

def build_source_citations(results: list[str | dict[str, Any]]) -> list[SourceCitation]:
    """Convert retrieval dicts into structured ``SourceCitation`` models.

    Args:
        results: List of ``{text, score, metadata}`` retrieval hits.

    Returns:
        Ordered list of citations for the API response.
    """
    citations: list[SourceCitation] = []
    for result in results:
        if isinstance(result, str):
            citations.append(SourceCitation(text=result))
            continue
        meta = result.get("metadata") or {}
        citations.append(
            SourceCitation(
                text=str(result.get("text", "")),
                source=str(meta.get("source", "unknown")),
                page=str(meta.get("page", "")),
                score=float(result.get("score", 0.0)),
            )
        )
    return citations

- Route low grounding scores through run_judge before retry (hybrid loop)
- Add mark_rejected node and wire judge outcomes in CRAG graph
- Redis-backed chat history with in-memory fallback; session_id multi-turn
- Include chat_history in generate_answer prompt context
- Add React Web UI (Chat + Studio), CORS, and /api/v1/studio endpoints
- Add eval smoke tests (tests/evals/) and make test-evals target
- 79 tests passing

Co-authored-by: jwlee <dtype2100@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants