어느 순간부터 RAG 파이프라인을 짜는 것보다 운영하는 게 더 어렵다는 걸 알게 됐다. 처음에는 임베딩 모델을 골라서 벡터 DB에 인덱싱하고, 유사도 검색으로 컨텍스트를 뽑아서 LLM에 넘기면 끝이라고 생각했다. 실제로 돌려보기 전까지는 그렇게 생각했다.
그런데 막상 프로덕션에 올리면 다른 세계가 펼쳐진다. 검색 정확도가 쿼리 유형마다 들쭉날쭉하고, FastAPI 서버는 동시 요청 몇 개에 응답이 뭉개지고, 벡터 DB 인덱스와 메타데이터 사이에 동기화 불일치가 생기고, LangGraph 에이전트 노드는 예상 못한 지점에서 멈춘다. 이 문제들을 하나씩 풀면서 쌓인 것들이 지금의 내 운영 기준이 됐다.
이 글은 그 기준들을 한 자리에 모은 것이다. RAG를 처음 시작하는 사람보다는, 이미 만들어봤는데 실제 운영에서 막히는 지점이 있는 사람에게 더 유용할 것이다.
검색 인프라 선택 – PostgreSQL 하나로 갈 것인가, Qdrant를 따로 띄울 것인가
RAG 파이프라인 설계에서 가장 먼저 부딪히는 결정이 이거다. 이미 PostgreSQL을 쓰고 있다면 pgvector로 벡터 검색까지 한 곳에서 해결하고 싶은 게 자연스럽다. 운영할 시스템이 하나 더 늘어나는 게 싫으니까.
pgvector로 시작하면 좋은 점이 분명하다. 기존 SQL 쿼리와 결합해서 메타데이터 필터링을 바로 쓸 수 있고, 데이터 동기화 문제 자체가 없다. 문서를 삽입하거나 삭제할 때 벡터 인덱스와 메타데이터가 같은 트랜잭션 안에서 처리된다. 문서 수가 수십만 건 수준이라면 HNSW 인덱스로 응답 속도도 충분히 나온다.
문제는 하이브리드 검색을 제대로 구현하려고 할 때 생긴다. BM25 점수와 벡터 유사도를 결합하는 과정에서, 두 점수의 분포 자체가 달라서 단순 합산이 잘 작동하지 않는다. RRF(Rank Reciprocal Fusion)로 순위 기반 결합을 하려면 두 쿼리를 따로 실행하고 애플리케이션 레이어에서 합치는 로직을 직접 짜야 한다. 이게 생각보다 코드량이 많고 레이턴시도 늘어난다.
Qdrant로 가면 이 부분이 깔끔해진다. Dense 벡터와 Sparse 벡터를 하나의 쿼리로 동시에 처리하고, RRF 결합까지 내부에서 해준다. ColBERT 기반 reranking도 같은 파이프라인 안에서 처리할 수 있다. 검색 품질이 제품의 핵심 경쟁력이거나, 정확한 키워드 매칭과 의미 검색이 둘 다 정밀해야 하는 도메인이라면 Qdrant가 맞다.
내가 지금 운영하는 구조는 둘을 나눠 쓰는 거다. 사용자 데이터와 메타데이터는 PostgreSQL에, 검색 핵심 인덱스는 Qdrant로 분리했다. 다만 이렇게 하면 두 시스템 사이의 동기화를 명확하게 설계해야 한다. PostgreSQL을 원본(source of truth)으로 두고, Qdrant는 검색 인덱스 역할로만 쓰는 역할 분리가 일관되게 지켜져야 한다. 이 순서가 한 번이라도 흔들리면, 이미 삭제된 문서가 검색 결과에 계속 나오는 유령 문서 문제가 생긴다. 실제로 그 문제를 겪었다. Qdrant vs PostgreSQL 하이브리드 검색 비교 글에서 그 경험을 자세히 정리했다.
BM25를 PostgreSQL 안에서 – pg_textsearch로 Elasticsearch 없이 해결하기
벡터 검색만으로 부족할 때 선택지가 하나 더 생겼다. PostgreSQL 안에서 진짜 BM25 알고리즘을 쓸 수 있게 해주는 익스텐션들이 나오면서, Elasticsearch를 따로 띄우지 않아도 키워드 정밀도를 올릴 수 있게 됐다.
기존 PostgreSQL의 tsvector와 ts_rank는 진짜 BM25가 아니다. 문서 길이 정규화와 TF-IDF 기반으로 동작하는 자체 랭킹 방식이라, 특히 기술 문서나 에러 코드처럼 정확한 키워드 매칭이 중요한 쿼리에서 정밀도가 아쉬웠다. pg_textsearch나 pg_search 같은 익스텐션은 Tantivy를 PostgreSQL 안에 통합해서, k1과 b 파라미터를 설정할 수 있는 진짜 BM25 인덱스를 만든다.
LangGraph 에이전트의 검색 노드에 이걸 통합할 때 핵심은 두 검색을 병렬로 실행하는 거다. BM25 쿼리와 벡터 검색을 순차적으로 실행하면 레이턴시가 그냥 더해지지만, asyncio.gather로 묶으면 둘 중 느린 쪽의 시간만 든다. FastAPI에서 비동기 블로킹을 잡는 것과 같은 원리다.
다만 한국어 토크나이징은 아직 주의가 필요하다. 형태소 분석기에 따라 BM25 검색 품질이 달라지고, 기본 설정으로는 한국어에서 기대만큼 나오지 않을 수 있다. 이 부분은 아직 진행 중인 숙제다.
FastAPI로 RAG를 서빙할 때 – 비동기라고 다 같은 비동기가 아니다
RAG 파이프라인을 FastAPI 위에 올리면 당연히 비동기로 잘 동작할 거라고 생각하기 쉽다. 나도 그렇게 생각했다가 동시 요청 몇 개에 응답 시간이 뭉개지는 걸 경험하고 나서야 제대로 들여다봤다.
문제의 원인은 세 군데에 있었다.
첫 번째는 임베딩 생성 함수가 async def 안에서 동기 방식으로 호출되고 있었다는 점이다. async def로 선언된 엔드포인트 안에서 동기 함수를 그냥 호출하면, 그 함수가 실행되는 동안 이벤트 루프 전체가 블로킹된다. 다른 요청이 그 시간 동안 아무것도 처리하지 못하고 기다린다. 비동기 프레임워크를 쓴다고 해서 그 안의 모든 코드가 자동으로 비동기로 동작하는 게 아니다.
두 번째는 DB 커넥션 풀 크기가 기본값으로 설정돼 있어서, 동시 요청이 풀 크기를 넘으면 대기열에 쌓이는 문제였다. 로컬 테스트에서는 동시 요청이 거의 없으니까 안 보이다가, 운영 환경에서 트래픽이 몰리면 바로 병목이 된다.
세 번째는 토크나이저를 매 요청마다 새로 로드하는 로직이 있었다는 점이다. 이걸 FastAPI의 lifespan 이벤트를 써서 서버 시작 시점에 한 번만 로드하도록 바꾸니까, 매 요청마다 반복되던 초기화 비용이 사라졌다. 이 세 가지를 고치고 나서 동시 요청 처리 시간이 눈에 띄게 안정됐다. FastAPI RAG 서버 비동기 디버깅 기록에 그 과정을 전부 담았다.
LangGraph 에이전트와 MCP – 연결은 쉽고 운영은 어렵다
LangGraph 에이전트에서 사내 시스템 데이터를 직접 가져와야 하는 경우, 기존에는 각 데이터 소스마다 함수를 따로 짜서 에이전트 도구로 등록하는 방식을 썼다. 이 함수들이 LangGraph 안에서만 쓰이는 구조라, 같은 데이터를 다른 AI 도구에서도 쓰고 싶으면 연동 로직을 처음부터 다시 짜야 했다.
MCP로 사내 검색 API를 한 번 노출해두니까 LangGraph 에이전트, Claude Desktop, 다른 팀의 AI 도구에서 다 같이 가져다 쓸 수 있게 됐다. 그리고 실시간으로 바뀌는 데이터를 RAG 인덱스 갱신 없이 그대로 가져올 수 있다는 점도 좋았다.
문제는 로컬에서 동작 확인하는 것과 프로덕션에서 운영하는 것 사이의 간극이다. 테스트 환경에서 stdio transport로 인증 없이 바로 동작하던 게, 실제 운영에 올리면서 OAuth 기반 인증과 TLS 종단 처리를 새로 구성해야 했다. MCP 스펙 자체의 인증 부분이 이 시기에 계속 바뀌고 있어서, 참고하던 예제가 이미 구버전인 경우도 있었다. 반나절 걸렸던 설정이 프로덕션 인증 붙이는 데 일주일이 걸렸다.
MCP 서버가 잠깐이라도 응답을 안 하면 에이전트가 엉뚱한 답을 만들어내는 것도 문제였다. degradation mode를 만들어서, 도구가 실패하면 에이전트에게 “이 도구는 현재 사용할 수 없다”고 구조적으로 알려주는 방식으로 해결했다. 이걸 넣기 전에는 도구 실패가 에이전트 환각으로 이어지는 일이 종종 있었다.
AI 코딩 도구 – 구현보다 설계에 집중할 수 있게 됐다
RAG 파이프라인을 유지보수하면서 가장 많이 바뀐 건 코드를 쓰는 방식이다. Claude Code를 RAG 디버깅에 처음 붙였을 때, 7개 파일에 걸쳐 있던 청킹 버그의 원인을 30분 걸릴 걸 2분 만에 짚어줬다. LangGraph Supervisor 노드의 조건 분기 버그도 내가 직접 코드를 읽으면서 추적하는 것보다 훨씬 빠르게 찾아냈다.
Codex는 다른 결에서 유용하다. 여러 작업을 병렬로 돌리는 구조에서는 Codex 데스크톱 앱의 worktree 기반 병렬 에이전트가 Claude Code보다 자연스럽다. 한쪽에서 청킹 로직을 수정하면서 다른 쪽에서 인덱스 재생성 스크립트를 테스트하는 식의 작업에서 편하다.
둘 다 공통적으로 느끼는 건, AI 코딩 도구를 잘 쓰는 날은 내가 더 많이 생각하고 덜 타이핑하는 날이라는 거다. 구현의 물리적 행위를 위임하면서 설계에 더 집중하게 됐다. 다만 코드의 인과관계는 설명해줘도, “왜 이 구조를 쓰는가”는 여전히 내 판단이다. LangGraph에서 어느 노드에 interrupt를 걸지, 체크포인터를 어느 타이밍에 쓸지 같은 설계 결정은 AI 도구가 대신해주지 않는다.
RAG를 운영한다는 것
RAG 파이프라인을 만드는 것과 운영하는 것은 완전히 다른 문제라는 걸 다시 확인하게 된다. 검색 품질은 모델보다 청킹 전략과 하이브리드 검색 설계에 더 많이 달려 있고, 서비스 안정성은 파이프라인 설계보다 FastAPI 서버의 비동기 구조와 DB 커넥션 관리에 더 많이 달려 있다. 인프라 선택도 “어떤 벡터 DB가 더 좋은가”보다 “우리 팀이 운영할 수 있는가”가 더 중요한 기준이 됐다.
이 글에서 다룬 각각의 주제는 별도 글에서 더 깊게 다뤘다. 실제 코드와 트러블슈팅 디테일이 필요하다면 각 글을 직접 보는 게 낫다. 이 글은 그 글들 사이의 연결을 보여주는 지도에 가깝다.
검색 인프라에서 시작해서 서버 성능, 에이전트 연동, AI 코딩 도구 활용까지 — 이 흐름이 지금 내가 RAG 시스템을 운영하면서 매일 마주하는 레이어들이다. 그리고 어느 레이어 하나가 흔들리면 나머지가 전부 흔들린다는 것도, 실제로 운영해보고 나서야 제대로 이해하게 됐다.