- Published on
FAISS RAG 메모리 폭증 OOM 해결 체크리스트
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
RAG를 FAISS로 붙이면 처음엔 ‘빠르고 싸다’가 장점인데, 일정 규모를 넘기면 어느 날 갑자기 프로세스가 죽습니다. 로그에는 Killed, 쿠버네티스에는 OOMKilled, 혹은 파이썬에서는 MemoryError만 남고요. 문제는 FAISS 자체 메모리 + 파이썬/넘파이 배열 + 임베딩 배치 + 청크/메타데이터 + 캐시/워크큐가 한 프로세스에 겹치면서, 특정 트래픽 패턴에서 메모리가 “계단식”으로 치솟는다는 점입니다.
이 글은 “FAISS RAG에서 메모리 폭증(OOM)이 나면 무엇부터 확인하고, 어떤 순서로 고치면 되는지”를 체크리스트로 제공합니다. 단순히 k를 줄이거나 인스턴스를 키우는 이야기가 아니라, 재현/측정 → 원인 분류 → 단기 완화 → 구조적 해결까지 현업 기준으로 정리합니다.
0) 먼저 결론부터: OOM은 보통 4가지 축에서 난다
- 인덱스 타입/정밀도 문제:
IndexFlat*를 대용량에 쓰거나,float32를 고집해서 메모리가 선형으로 폭증 - 로드/복제 방식 문제: 워커마다 인덱스를 개별 로드(프로세스 수만큼 N배), GPU로 올리며 중복
- 검색 파이프라인 문제: 배치 임베딩/검색에서 임시 배열이 큰 피크를 만들거나, 결과 후처리에서 객체가 누적
- 운영/코드 누수 문제: 요청 캐시/세션/큐/리트라이가 누적되거나, 파이썬 레퍼런스가 남아 GC가 못 치움
아래 체크리스트는 이 4축을 빠르게 좁혀가는 방식입니다.
1) 재현과 측정부터: “RSS/피크/증가율”을 분리하라
OOM을 잡을 때 가장 흔한 실수는 top에서 메모리 숫자만 보고 감으로 고치는 겁니다. 아래 3가지를 분리해서 봐야 합니다.
- RSS(상주 메모리): 실제 프로세스가 점유
- 피크(peak): 특정 요청/배치에서 순간적으로 튀는 메모리
- 증가율(leak-like growth): 요청이 쌓일수록 계속 증가(누수 가능)
Python에서 최소 계측 코드(서버에 바로 심기)
import os
import psutil
import time
proc = psutil.Process(os.getpid())
def mem_mb():
return proc.memory_info().rss / (1024 * 1024)
# 요청 핸들러 앞/뒤에 찍기
start = mem_mb()
# ... embed + faiss.search + postprocess ...
end = mem_mb()
print(f"mem_rss_mb start={start:.1f} end={end:.1f} delta={end-start:.1f}")
- 요청 1건당 delta가 크면: 임시 배열/후처리 객체가 크거나 배치가 과함
- 요청이 반복될수록 end가 계속 올라가면: 캐시/리스트 누적/레퍼런스 누수/워커 재시작 필요
FAISS 인덱스 자체 메모리 추정(대략)
IndexFlatIP/L2는 기본적으로 벡터를 그대로 들고 있어 대략N * d * 4 bytes(float32)- 예: 1,000,000개 * 1536차원 * 4B ≈ 6.1GB (인덱스만)
- 여기에 ID/메타, 파이썬 오브젝트, 캐시까지 합치면 8~12GB는 금방 갑니다.
즉, Flat 인덱스는 “소규모/프로토타입”에 적합하지, 대규모 RAG 상시 서빙에는 위험합니다.
2) 체크리스트 A: 인덱스 타입과 정밀도가 메모리를 터뜨리는 경우
2-1) IndexFlat*를 대규모에 쓰고 있나?
- 증상: 인덱스 로드 직후 RSS가 이미 한계에 근접
- 해결 방향: IVF/PQ/HNSW 등으로 전환하거나, 최소한 float16/양자화로 메모리 절감
권장 전환 옵션(메모리 관점)
- IVF + PQ: 메모리 절감 폭이 큼(정확도/튜닝 필요)
- HNSW: 검색 품질 좋고 빠르지만 그래프 오버헤드로 메모리도 꽤 듦(Flat보단 낫거나 비슷한 케이스 존재)
- OPQ + IVF-PQ: 품질/압축 균형을 더 잘 맞춤
2-2) float32를 고집하고 있나? (임베딩 dtype 포함)
임베딩/인덱스/검색 입력 dtype이 모두 float32면 메모리 피크가 커집니다.
- 임베딩 모델 출력이 float32 → 넘파이 변환 과정에서 복사
- FAISS에 넣을 때 contiguous 보장 위해 또 복사
임베딩 배열을 명시적으로 float32/contiguous로 “한 번만” 만들기
import numpy as np
def to_faiss_array(x):
# x: list/torch tensor/np array
arr = np.asarray(x, dtype=np.float32)
if not arr.flags['C_CONTIGUOUS']:
arr = np.ascontiguousarray(arr)
return arr
- 핵심: 변환/복사를 여러 번 하지 않도록 경로를 고정
- 가능하면 임베딩 생성 단계에서부터 contiguous float32로 받는 것이 가장 좋습니다.
3) 체크리스트 B: “워커 수만큼 인덱스가 복제”되는 경우
서빙에서 가장 흔한 OOM 원인은 이겁니다.
- Gunicorn/Uvicorn 워커 4개
- 각 워커가 시작할 때
faiss.read_index()호출 - 결과: 인덱스 메모리가 4배
3-1) 멀티프로세스 모델인가? (Gunicorn workers, Celery prefork 등)
ps aux | grep gunicorn에서 worker 수 확인- 쿠버네티스면 컨테이너 안에서 프로세스 수 확인
3-2) preload_app/Copy-on-Write(COW)로 공유가 가능한가?
리눅스에서는 부모 프로세스에서 인덱스를 로드하고 fork하면, 수정하지 않는 한 페이지가 공유되어 메모리가 절약됩니다.
Gunicorn 예시
gunicorn app:app \
-k uvicorn.workers.UvicornWorker \
--workers 4 \
--preload
--preload로 부모에서 인덱스를 로드- 주의: 워커가 인덱스를 수정(추가/삭제)하면 COW가 깨져 다시 N배로 늘 수 있음
3-3) “인덱스는 단일 프로세스 + RPC”로 분리하는 것도 정답
대규모 인덱스라면, API 서버 워커마다 들고 있기보다:
- FAISS 검색 전용 프로세스(또는 별도 서비스) 1~2개
- API 서버는 gRPC/HTTP로 질의
이 구조가 메모리 안정성 면에서 압도적으로 유리합니다.
4) 체크리스트 C: 검색/후처리 파이프라인이 피크를 만드는 경우
4-1) 배치 임베딩/배치 검색에서 임시 배열이 터지나?
예: 한 요청에서 쿼리 200개를 한 번에 임베딩 → (200, 1536) 배열 생성 여기에 FAISS가 내부적으로 거리/점수 배열을 만들면 피크가 커집니다.
해결: 마이크로배칭
def batched(iterable, n):
buf = []
for x in iterable:
buf.append(x)
if len(buf) == n:
yield buf
buf = []
if buf:
yield buf
# queries: list[str]
for chunk in batched(queries, 16):
q_emb = embed(chunk) # (<=16, d)
D, I = index.search(to_faiss_array(q_emb), k)
# accumulate results
- 배치 크기 16/32부터 시작해서 피크를 관찰하며 올리세요.
4-2) k와 fetch_k가 과도한가?
RAG에서 흔히:
- 1차 검색
k=200 - 리랭킹 후 top 10
이때 메모리뿐 아니라 CPU도 낭비됩니다. 리랭킹이 필요하다면, MMR/필터링/메타 조건으로 1차 후보를 합리적으로 제한하세요.
리랭킹/청킹 전략으로 품질을 유지하면서 후보 수를 줄이는 방법은 LangChain LlamaIndex RAG에서 답변이 반복되고 환각될 때 리랭커와 청킹 전략 토큰 예산으로 정확도 2배 올리는 디버깅 체크리스트도 함께 참고하면 좋습니다.
4-3) 메타데이터 후처리에서 “파이썬 객체”가 폭증하나?
FAISS는 벡터만 다루기 때문에, 보통은 I(id 배열)로 문서를 찾아오는 단계에서 문제가 생깁니다.
list에 dict를 계속 append- 전역 캐시에 결과를 쌓아둠
- pandas DataFrame으로 변환(복사 + 오브젝트 오버헤드)
해결: 결과 객체 최소화 + 즉시 직렬화
- 내부 표현은 가능한 한 정수 id 배열 + 별도 스토리지 조회로 유지
- API 응답 직전에만 필요한 필드만 묶기
5) 체크리스트 D: GPU/컨테이너 환경에서의 “숨은 중복”
5-1) GPU로 인덱스를 올리며 CPU 인덱스를 그대로 유지하나?
FAISS GPU 사용 시 흔한 패턴:
- CPU index 로드
faiss.index_cpu_to_gpu(...)로 GPU index 생성- CPU index를 계속 참조(해제 안 됨)
이러면 CPU + GPU에 이중으로 메모리가 듭니다.
해결: CPU 인덱스 참조 제거 + 스코프 정리
gpu_index = faiss.index_cpu_to_gpu(res, 0, cpu_index)
# cpu_index를 더 이상 쓰지 않는다면
del cpu_index
import gc
gc.collect()
또한 컨테이너에서 GPU가 꼬이면 디버깅 시간이 길어지니, 기본 점검은 Docker에서 NVIDIA GPU가 안 잡힐 때 nvidia-smi는 되는데 컨테이너에서 CUDA not found 뜨는 7가지 원인과 해결을 같이 보세요.
5-2) 쿠버네티스에서 메모리 제한이 너무 타이트한가?
requests와limits가 같고 여유가 없으면 스파이크에 바로 죽습니다.- 피크 메모리가 큰 워크로드(배치 임베딩/리랭킹)는 headroom이 필요합니다.
6) 실전 트러블슈팅: 증상별로 빠르게 좁히기
증상 1) “인덱스 로드 직후” OOM
- 원인 후보
- Flat 인덱스 자체가 너무 큼
- 워커별 중복 로드
- mmap/온디스크 로딩 없이 전부 RAM 적재
- 즉시 조치
- 워커 수 1로 줄여 재현
--preload적용 가능 여부 확인- IVF/PQ로 재빌드 계획 수립
증상 2) 트래픽이 오르면 “계단식”으로 RSS가 증가하다 OOM
- 원인 후보
- 요청 캐시/세션 누적
- 결과 리스트/전역 변수 누수
- 배치 크기/
k가 큰 요청이 간헐적으로 들어옴
- 즉시 조치
- 요청 단위
delta로깅 - 가장 큰 요청 payload를 샘플링(쿼리 수, k, 필터 조건)
- 마이크로배칭 +
k상한 적용
- 요청 단위
증상 3) 특정 엔드포인트만 OOM
- 원인 후보
- 리랭킹/후처리에서 DataFrame/대형 JSON 구성
- 문서 원문을 너무 많이 붙여 응답 구성 중 메모리 피크
- 즉시 조치
- 원문은 “필요한 일부”만 가져오고, 나머지는 lazy fetch
- 응답 직렬화 전에 중간 구조를 최소화
7) Best Practice: OOM을 구조적으로 막는 설계 패턴
7-1) “인덱스는 불변(immutable) + 롤링 배포”로 운영
- 서빙 프로세스는 인덱스를 읽기 전용으로 유지
- 인덱스 업데이트는 새 파일로 빌드 → 새 버전 로드 → 트래픽 스위치
이렇게 하면 COW 공유도 깨지지 않고, 메모리 변동도 예측 가능해집니다.
7-2) 문서 저장소/메타데이터는 FAISS 밖으로 분리
- FAISS에는 벡터와 id만
- 본문/메타는 DB/오브젝트 스토리지/키-값 스토어
이 패턴은 검색 품질/운영 측면에서도 유리합니다. (예: 필터링/권한/테넌트 분리)
7-3) 장애 대응: OOM은 “재시작”만으로 끝내면 재발한다
- OOMKilled 재시작은 증상을 숨길 뿐입니다.
- 반드시 “피크 vs 누수 vs 중복 로드”를 분리해 원인을 제거해야 합니다.
운영에서 재시도/큐가 엮여 메모리와 트래픽이 증폭되는 경우도 많습니다. 백그라운드 작업이 있다면 Redis 기반 Celery 유령 작업 근절하기 무한 재시도와 중복 실행을 부르는 acks_late prefetch_multiplier visibility_timeout 충돌 디버깅 체크리스트처럼 “중복 실행/무한 재시도”를 먼저 차단하세요.
8) 최종 점검용 OOM 해결 체크리스트(복붙용)
- OOM 시점이 로드 직후 / 트래픽 증가 후 / 특정 요청 중 어디인가?
- 인덱스 타입이
IndexFlat*인가? (대규모면 IVF/PQ/HNSW 검토) - 벡터 수(N), 차원(d), dtype(float32) 기준으로 인덱스 메모리를 계산해봤는가?
- 멀티프로세스 워커가 인덱스를 각자 로드하고 있지 않은가?
- 가능하면
--preload로 COW 공유 - 더 크면 검색 프로세스를 분리(RPC)
- 가능하면
- 임베딩/검색 입력 배열이 불필요하게 복사되고 있지 않은가? (dtype/contiguous)
- 배치 임베딩/배치 검색에서 마이크로배칭을 적용했는가?
-
k,fetch_k상한을 두고, 후보 수를 품질 전략으로 줄였는가? - 후처리에서 pandas/DataFrame/대형 dict 리스트 등 파이썬 오브젝트 폭증이 없는가?
- GPU 사용 시 CPU+GPU로 이중 보관하고 있지 않은가?
- 쿠버네티스 메모리
limits에 피크를 감당할 headroom이 있는가?
결론: 메모리는 “인덱스”가 아니라 “구조”에서 터진다
FAISS RAG의 OOM은 대개 인덱스 하나의 문제가 아니라, 인덱스 타입 선택 + 프로세스 모델 + 배치/후처리 방식이 합쳐져 폭발합니다. 오늘 바로 할 일은 간단합니다.
- 요청 단위 RSS delta를 찍어 피크인지 누수인지를 분리하고, 2) 워커 중복 로드를 제거하며, 3) Flat이라면 IVF/PQ 같은 압축 인덱스로 전환 계획을 세우세요.
이 체크리스트대로 한 단계씩 좁혀가면 “가끔 죽는 RAG”를 “예측 가능한 RAG”로 바꿀 수 있습니다.