feat: CRAG 개선 루프 순서 재정렬 (analysis → feedback)#10
Conversation
- Split liveness/readiness health endpoints for Kubernetes and Docker - Enable optional API key auth on data endpoints via API_KEY env var - Add Prometheus metrics, request ID tracing, rate limiting, security headers - Register global exception handlers with request_id in error responses - Add GitHub Actions CI workflow and fix Makefile to use python -m - Add Docker healthcheck and curl in Dockerfile - Add integration tests for ops endpoints (73 tests passing) Co-authored-by: jwlee <dtype2100@users.noreply.github.com>
…→feedback loop - Define canonical improvement loop in app/core/improvement_loop.py - Restructure LangGraph: wire run_judge, add test_retrieval phase - Add unified route_after_feedback router with retry_retrieval/generation paths - Reorder run_evals.py to match loop phases; add run_feedback_eval.py - Add unit tests for feedback routing (78 tests passing) Co-authored-by: jwlee <dtype2100@users.noreply.github.com>
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Code Review
This pull request introduces robust operational features to the RAG pipeline, including API key authentication, rate limiting, security headers, Prometheus metrics, and structured health/readiness probes. It also refactors the CRAG LangGraph pipeline to follow a structured 'improvement loop' with new nodes and feedback routing. Feedback on these changes focuses on critical performance and security improvements: addressing blocking synchronous calls in asynchronous endpoints, resolving potential memory leaks and proxy IP detection issues in the rate limiter, preventing timing attacks during API key verification, parallelizing dependency checks in health probes, and optimizing the RAG graph to bypass answer generation when no relevant contexts are retrieved.
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.
| if self._limit <= 0 or request.url.path in _EXEMPT_PATHS: | ||
| return await call_next(request) | ||
|
|
||
| client_ip = request.client.host if request.client else "unknown" |
There was a problem hiding this comment.
현재 구현은 request.client.host를 사용하여 클라이언트 IP를 가져오고 있습니다. 하지만 애플리케이션이 역방향 프록시(Nginx, Traefik, AWS ALB 등) 뒤에 배포되는 경우, 모든 요청의 IP가 프록시 서버의 IP로 인식되어 모든 사용자가 동일한 레이트 리밋 버킷을 공유하게 되는 문제가 발생할 수 있습니다. X-Forwarded-For 또는 X-Real-IP 헤더를 확인하여 실제 클라이언트 IP를 식별하도록 개선하는 것이 좋습니다.
| client_ip = request.client.host if request.client else "unknown" | |
| client_ip = request.headers.get("x-forwarded-for") | |
| if client_ip: | |
| client_ip = client_ip.split(",")[0].strip() | |
| else: | |
| client_ip = request.client.host if request.client else "unknown" |
| def __init__(self, app, limit_per_minute: int) -> None: | ||
| super().__init__(app) | ||
| self._limit = max(limit_per_minute, 0) | ||
| self._windows: dict[str, deque[float]] = defaultdict(deque) |
There was a problem hiding this comment.
| try: | ||
| count = ingest_documents(raw_docs) |
There was a problem hiding this comment.
ingest 엔드포인트는 async def로 정의되어 있지만, 내부적으로 동기식 블로킹 I/O 작업인 ingest_documents(raw_docs)를 직접 호출하고 있습니다. 이로 인해 해당 작업이 실행되는 동안 FastAPI의 단일 이벤트 루프가 완전히 차단(block)되어 다른 모든 요청 처리가 대기하게 됩니다. 이를 방지하기 위해 asyncio.to_thread를 사용하여 별도의 스레드 풀에서 실행하도록 수정해야 합니다.
| try: | |
| count = ingest_documents(raw_docs) | |
| try: | |
| import asyncio | |
| count = await asyncio.to_thread(ingest_documents, raw_docs) |
| try: | ||
| results = search_documents(req.query, top_k=req.top_k) |
There was a problem hiding this comment.
semantic_search 엔드포인트 역시 async def 내부에서 동기식 블로킹 함수인 search_documents를 직접 호출하여 이벤트 루프를 차단합니다. asyncio.to_thread를 사용하여 비동기적으로 실행하도록 개선해야 합니다.
| try: | |
| results = search_documents(req.query, top_k=req.top_k) | |
| try: | |
| import asyncio | |
| results = await asyncio.to_thread(search_documents, req.query, top_k=req.top_k) |
|
|
||
| @router.post("/query", response_model=ChatResponse, tags=["rag"]) | ||
| async def rag_query(req: ChatRequest) -> ChatResponse: | ||
| async def rag_query(req: ChatRequest, _: None = Depends(RequireAPIKey)) -> ChatResponse: |
There was a problem hiding this comment.
| vector = await check_vectorstore() | ||
| redis = await check_redis() | ||
| llm = await check_llm() |
There was a problem hiding this comment.
health 엔드포인트에서 check_vectorstore(), check_redis(), check_llm() 비동기 함수들을 순차적으로 await 호출하고 있습니다. 이로 인해 전체 헬스체크 응답 시간이 각 체크 시간의 합만큼 늘어나게 됩니다. 이 함수들은 서로 독립적이므로 asyncio.gather를 사용하여 병렬로 실행하면 응답 속도를 크게 개선할 수 있습니다.
import asyncio
vector, redis, llm = await asyncio.gather(
check_vectorstore(),
check_redis(),
check_llm(),
)| vector = await check_vectorstore() | ||
| redis = await check_redis() | ||
| llm = await check_llm() |
There was a problem hiding this comment.
readiness 엔드포인트에서도 마찬가지로 의존성 체크를 순차적으로 실행하고 있습니다. asyncio.gather를 사용하여 병렬로 실행하도록 개선하는 것을 권장합니다.
| vector = await check_vectorstore() | |
| redis = await check_redis() | |
| llm = await check_llm() | |
| import asyncio | |
| vector, redis, llm = await asyncio.gather( | |
| check_vectorstore(), | |
| check_redis(), | |
| check_llm(), | |
| ) |
| if not settings.auth_enabled: | ||
| return | ||
|
|
||
| if api_key != settings.api_key: |
There was a problem hiding this comment.
api_key != settings.api_key와 같은 일반적인 문자열 비교 연산은 실행 시간이 문자열의 일치하는 길이에 따라 달라지므로 **타이밍 공격(Timing Attack)**에 취약할 수 있습니다. 보안에 민감한 API 키 검증에는 일정한 시간 동안 비교를 수행하는 secrets.compare_digest를 사용하는 것이 안전합니다.
| if api_key != settings.api_key: | |
| import secrets | |
| if not secrets.compare_digest(api_key, settings.api_key): |
| response.headers.setdefault("X-Content-Type-Options", "nosniff") | ||
| response.headers.setdefault("X-Frame-Options", "DENY") | ||
| response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") | ||
| response.headers.setdefault("X-XSS-Protection", "1; mode=block") |
There was a problem hiding this comment.
X-XSS-Protection 헤더의 1; mode=block 설정은 현대적인 브라우저에서는 더 이상 지원되지 않거나 오히려 새로운 클라이언트 사이드 취약점을 유발할 수 있어 권장되지 않습니다. 현대적인 보안 표준에 따라 이 헤더를 0으로 설정하여 비활성화하고, 대신 강력한 Content Security Policy(CSP)를 적용하는 것을 권장합니다.
| response.headers.setdefault("X-XSS-Protection", "1; mode=block") | |
| response.headers.setdefault("X-XSS-Protection", "0") |
| filtered = filter_relevant(contexts) | ||
| logger.info( | ||
| "Test phase: %d → %d contexts passed relevance filter", | ||
| len(contexts), | ||
| len(filtered), | ||
| ) | ||
| return {"expanded_contexts": filtered} |
There was a problem hiding this comment.
test_retrieval 단계에서 검색된 문서들이 관련성 필터(filter_relevant)를 통과하지 못해 expanded_contexts가 빈 리스트가 되는 경우에도, 그래프는 그대로 다음 단계인 generate_answer와 run_judge로 진행됩니다. 이로 인해 컨텍스트가 없는 상태에서 무의미한 답변 생성 및 평가를 위해 고비용의 LLM 호출이 연이어 발생하게 됩니다. 관련 문서가 전혀 없는 경우, 답변 생성을 건너뛰고 즉시 쿼리 재작성 및 재검색(feedback/retry) 단계로 분기할 수 있도록 조건부 라우팅을 추가하는 것을 고려해 보세요.
Summary
CRAG 파이프라인과 오프라인 eval 루프를 프로젝트에 맞는 개선 루프 순서로 재정렬했습니다.
Runtime (LangGraph) 변경
이전: analyze → rewrite → retrieve → expand/rerank → generate → grounding → retry
이후:
analyze_querydecide_rewrite→rewrite_queryhybrid_retrievetest_retrieval(expand + rerank + relevance filter)generate_answer→run_judge(신규 연결)evaluate_groundingretry_retrieval/retry_generation/retry_with_policy/finalize_*run_judge가 그래프에 연결되지 않던 문제 수정route_after_feedback로 judge + grounding 기반 피드백 라우팅 통합Offline eval (
make evals) 변경scripts/run_evals.py가app/core/improvement_loop.py의 phase 순서를 따릅니다.run_feedback_eval.pyTesting