- Published on
OpenAI API+LangChain RAG 지연 500ms 달성법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
RAG에서 500ms 이하 지연을 만들려면, 단순히 더 빠른 모델을 고르는 것만으로는 부족합니다. 실제로는 검색 단계(벡터 검색, 필터링, 재랭킹), 컨텍스트 구성(문서 로딩, 토큰화, 프롬프트 템플릿 렌더링), 네트워크 왕복(LLM 호출) 이 합쳐져 지연이 만들어집니다.
이 글은 OpenAI API와 LangChain을 기준으로, 사용자 요청부터 첫 토큰 출력까지의 p95를 500ms 이하로 내리는 것을 목표로 합니다. 전제는 다음과 같습니다.
- 목표는 “정확도 최대”가 아니라 “정확도를 유지하면서 지연 최소화”
- RAG는 실시간 응답이 중요한 챗봇, 검색형 QnA, 고객센터 자동화에 적용
- 단일 요청 기준이 아니라 p95/p99 관점으로 최적화
아래 최적화는 대부분 독립적으로 적용 가능하지만, 효과가 큰 순서대로 보면
- 계측과 병목 분리
- 검색 비용 절감
- 컨텍스트 토큰 절감
- LLM 호출 최적화(스트리밍 포함)
- 캐시와 워밍업
순으로 접근하는 게 가장 빠릅니다.
1) 500ms 목표를 현실적으로 쪼개기
500ms는 “전체 응답 완료”가 아니라 첫 토큰까지(TTFB) 로 잡는 게 실무에서 유리합니다. 사용자 체감은 첫 토큰이 나오면 급격히 좋아지기 때문입니다.
권장 예산 예시는 다음과 같습니다.
- 라우팅 및 인증:
10~30ms - 벡터 검색(TopK):
10~40ms - 재랭킹(가능하면 생략):
0~80ms - 컨텍스트 구성:
5~30ms - LLM 호출(TTFB):
150~300ms(리전/네트워크에 따라 변동)
중요한 포인트는 검색과 컨텍스트를 100ms 안쪽으로 밀어 넣어야, LLM 호출 변동성이 있어도 500ms를 지킬 수 있다는 점입니다.
2) 먼저 계측: 어디서 시간이 새는지 분해
최적화는 “감”이 아니라 구간별 타이밍으로 해야 합니다. LangChain은 내부 추적 도구가 있지만, 최소한 아래처럼 단계별 시간을 로깅하세요.
import time
from contextlib import contextmanager
@contextmanager
def timer(name: str):
t0 = time.perf_counter()
try:
yield
finally:
dt = (time.perf_counter() - t0) * 1000
print(f"{name}: {dt:.1f}ms")
# 예시 파이프라인
with timer("retrieve"):
docs = retriever.invoke(query)
with timer("build_context"):
context = "\n\n".join(d.page_content for d in docs)
with timer("llm"):
answer = llm.invoke({"question": query, "context": context})
여기서 자주 나오는 병목은 다음과 같습니다.
- 벡터 DB 네트워크 RTT가 크거나, 필터가 느려서 검색이
100ms+ - TopK를 너무 크게 가져와서 컨텍스트 조합이 느리고 토큰이 폭증
- 재랭킹을 LLM으로 하고 있어서
300ms+추가 - 매 요청마다 임베딩을 다시 계산(쿼리 임베딩은 어쩔 수 없지만, 캐시 가능)
인프라 레벨에서 타임아웃 이슈가 섞이면 디버깅이 더 어려워집니다. 운영에서 504가 보이면 애플리케이션 병목과 게이트웨이 설정을 동시에 확인해야 합니다. 이 부분은 AWS ALB 504 Gateway Timeout 원인·해결 12가지도 함께 참고하면 좋습니다.
3) 검색 단계 최적화: TopK, 필터, 인덱스 전략
3.1 TopK를 줄이고, “가져온 뒤 줄이기”를 피하기
가장 흔한 실수는 k=20이나 k=50을 가져온 뒤, 애플리케이션에서 다시 추리는 방식입니다. 이 방식은
- 벡터 DB 응답 크기 증가
- 네트워크 전송 증가
- 컨텍스트 구성 비용 증가
- 토큰 증가로 LLM 비용과 지연 증가
를 동시에 유발합니다.
권장 패턴은:
- 1차 검색
k=6~10 - 문서 chunk 크기를 작게(예:
200~400토큰) 유지 - 필요하면 “후속 질문”으로 recall을 보완
3.2 메타데이터 필터를 인덱스 레벨로 내리기
예를 들어 tenant_id, product, lang 같은 필터는 애플리케이션에서 거르지 말고, 벡터 DB가 지원하는 필터로 내려야 합니다.
- 필터가 인덱스를 타지 않으면 오히려 느려질 수 있으니, DB별 권장 인덱싱을 확인
- 필터 조건이 복잡하면 “사전 파티셔닝(컬렉션 분리)”이 더 빠른 경우도 많음
3.3 하이브리드 검색은 “정확도”보다 “지연”을 먼저 설계
BM25와 벡터를 섞는 하이브리드는 정확도에 좋지만, 잘못하면 지연이 늘어납니다.
- 가장 빠른 형태는 “벡터 TopK만”
- 하이브리드가 필요하면 “BM25 TopK
20” 같은 큰 후보군을 만들지 말고, 작은 후보군으로 시작
3.4 재랭킹은 가능하면 제거하거나 경량화
LLM 재랭킹은 지연을 크게 늘립니다. 대안은:
- 재랭킹 없이 TopK를 줄이고 chunk 품질을 올리기
- 경량 cross-encoder 재랭커를 별도 서비스로 두되, p95를 엄격히 관리
- 재랭킹이 필요할 때만 조건부로 실행(예: 검색 점수가 낮을 때)
4) 컨텍스트 토큰을 줄이면 지연이 바로 줄어든다
RAG 지연을 줄이는 가장 확실한 방법은 LLM에 보내는 토큰을 줄이는 것입니다. 토큰은 비용뿐 아니라 처리 시간에도 직결됩니다.
4.1 “문서 원문” 대신 “요약 인덱스”를 추가
실시간으로 긴 문서를 붙이지 말고, 오프라인에서 문서별 요약을 만들어 두고 우선 사용합니다.
- 1차 응답은 요약 인덱스 기반
- 사용자가 더 깊은 근거를 요구하면 원문 chunk를 추가 로딩
이 패턴은 “정확도”를 크게 해치지 않으면서도 p95를 안정적으로 낮춥니다.
4.2 컨텍스트 템플릿을 최소화
프롬프트에 불필요한 지시문과 예시가 많으면 토큰이 늘어납니다. RAG에서는 특히
- 시스템 지시문은 짧고 강하게
- 출력 포맷을 강제하는 규칙은 핵심만
- 문서 인용 규칙도 간결하게
가 효과적입니다.
다음은 과도한 장문 프롬프트 대신, 짧은 템플릿 예시입니다.
from langchain_core.prompts import ChatPromptTemplate
prompt = ChatPromptTemplate.from_messages([
("system", "You answer using only the provided context. If missing, say you don't know."),
("human", "Question: {question}\n\nContext:\n{context}")
])
4.3 문서 결합은 문자열 덧붙이기보다 구조화
문서마다 출처를 짧게 붙이고, 문서 수를 제한하세요.
def build_context(docs, max_docs=6):
parts = []
for i, d in enumerate(docs[:max_docs], start=1):
src = d.metadata.get("source", "unknown")
parts.append(f"[doc{i} src={src}]\n{d.page_content}")
return "\n\n".join(parts)
5) OpenAI API 호출 최적화: 스트리밍, 연결, 모델 선택
5.1 스트리밍으로 TTFB 체감 지연 줄이기
500ms 목표는 “첫 토큰” 기준일 때 특히 스트리밍이 유리합니다. LangChain에서 스트리밍을 켜고, 서버는 SSE로 그대로 흘려보내세요.
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(
model="gpt-4o-mini",
temperature=0,
streaming=True,
)
async def stream_answer(chain, inp):
async for chunk in chain.astream(inp):
# chunk를 SSE로 전달
yield chunk
주의할 점:
- 스트리밍을 켰다고 모델 내부 지연이 사라지지는 않지만, 사용자 체감은 크게 좋아집니다.
- 프록시나 로드밸런서에서 버퍼링이 켜져 있으면 스트리밍이 막힙니다.
5.2 모델 선택은 “지연 예측 가능성”이 중요
정확도 최상 모델 하나로 모든 트래픽을 처리하면 p95가 흔들립니다. 실무에서는
- 기본은 경량 모델
- 어려운 질문만 상위 모델로 승격
이 형태가 지연과 비용에 모두 유리합니다.
5.3 요청 페이로드 줄이기
- 불필요한 메타데이터 제거
- 컨텍스트 길이 제한
- 출력 길이 제한(
max_tokens) 설정
출력 토큰이 길어지면 “완료 시간”은 늘지만, TTFB에도 간접 영향이 있을 수 있습니다(서버/네트워크/큐잉).
6) LangChain 파이프라인 병렬화: 할 수 있는 건 동시에
RAG는 순차 파이프라인처럼 보이지만, 실제로는 병렬화 여지가 있습니다.
- 쿼리 정규화와 쿼리 임베딩 준비
- 검색과 사용자 세션 로딩
- 검색 결과를 받는 즉시 컨텍스트 빌드
Python에서는 asyncio.gather로 쉽게 병렬화할 수 있습니다.
import asyncio
async def handle(query: str):
# 예: 세션 로딩과 검색을 병렬로
session_task = asyncio.create_task(load_session())
docs_task = asyncio.create_task(retrieve_docs(query))
session, docs = await asyncio.gather(session_task, docs_task)
context = build_context(docs)
return await llm.ainvoke({"question": query, "context": context})
병렬화는 평균 지연을 줄이기보다 p95를 안정화하는 데 특히 좋습니다.
7) 캐시 전략: “임베딩”과 “검색 결과”를 캐시하라
500ms를 안정적으로 달성하려면 캐시가 거의 필수입니다.
7.1 쿼리 임베딩 캐시
동일/유사 쿼리가 반복되는 도메인(FAQ, 고객센터)에서는 임베딩 캐시만으로도 큰 효과가 납니다.
- 키: 정규화된 쿼리 문자열
- 값: 임베딩 벡터
- TTL:
10~60분또는 LRU
7.2 검색 결과 캐시
- 키:
(tenant_id, normalized_query, filters_hash) - 값: 문서 ID 리스트와 스코어
- TTL: 인덱스 업데이트 주기에 맞춤
주의: 검색 결과 캐시는 최신성 요구가 강하면 TTL을 짧게 가져가야 합니다.
7.3 프롬프트 결과 캐시(조건부)
질문이 완전히 동일하고, 근거 문서가 동일할 때만 안전합니다. 그렇지 않으면 “틀린 답을 빠르게” 제공할 위험이 있습니다.
8) 운영에서 흔한 함정: 캐시 무효화와 라우트 캐시
Next.js를 프론트로 쓰는 경우, 라우트 캐시나 fetch 캐시 때문에 “데이터가 갱신 안 되는 것처럼” 보이면서 디버깅 시간이 늘어납니다. RAG 튜닝은 계측이 핵심이므로, 캐시 레이어를 명확히 통제하세요. 관련해서는 Next.js Route Cache로 데이터가 갱신 안될 때도 도움이 됩니다.
9) 레퍼런스 아키텍처: 500ms를 위한 권장 구성
다음은 지연을 목표로 한 현실적인 구성입니다.
- API 서버: FastAPI 또는 Next.js API Route
- 벡터 DB: 리전 근접 배치(애플리케이션과 동일 리전)
- 인덱스: chunk
200~400토큰, 메타데이터 필터 인덱싱 - 검색:
k=6~10, 필요 시 조건부 재랭킹 - 컨텍스트: 요약 인덱스 우선, 원문은 후속 단계
- LLM: 스트리밍 활성화, 기본 경량 모델 + 승격
- 캐시: Redis로 임베딩/검색 결과 캐시
10) 실전 체크리스트
아래 체크리스트를 위에서부터 적용하면, 대개 p95가 눈에 띄게 내려갑니다.
- 단계별 타이밍을 찍어
retrieve,context,llm를 분리했는가 - 벡터 검색
k가 과도하지 않은가(6~10부터 시작) - 메타데이터 필터가 DB 레벨에서 처리되는가
- 재랭킹이 꼭 필요한 경우에만 실행되는가
- 컨텍스트 토큰을 제한하고, 요약 인덱스를 도입했는가
- 스트리밍이 실제로 클라이언트까지 전달되는가(중간 버퍼링 제거)
- 임베딩/검색 캐시가 있는가
- 애플리케이션과 벡터 DB, OpenAI 호출의 네트워크 경로가 최단인가
11) 결론: 500ms는 “모델”이 아니라 “파이프라인” 문제
RAG 지연을 500ms 이하로 낮추는 핵심은, LLM 호출 자체보다 그 전에 발생하는 불필요한 작업을 제거하는 데 있습니다.
- 검색은 작고 정확하게
- 컨텍스트는 짧고 고품질로
- 스트리밍으로 체감을 줄이고
- 캐시로 p95를 눌러라
이 네 가지를 동시에 적용하면, OpenAI API와 LangChain 조합에서도 충분히 500ms 목표에 근접할 수 있습니다.