Published on

FAISS RAG 메모리 폭증 OOM 해결 체크리스트

Authors

RAG를 FAISS로 붙이면 처음엔 ‘빠르고 싸다’가 장점인데, 일정 규모를 넘기면 어느 날 갑자기 프로세스가 죽습니다. 로그에는 Killed, 쿠버네티스에는 OOMKilled, 혹은 파이썬에서는 MemoryError만 남고요. 문제는 FAISS 자체 메모리 + 파이썬/넘파이 배열 + 임베딩 배치 + 청크/메타데이터 + 캐시/워크큐가 한 프로세스에 겹치면서, 특정 트래픽 패턴에서 메모리가 “계단식”으로 치솟는다는 점입니다.

이 글은 “FAISS RAG에서 메모리 폭증(OOM)이 나면 무엇부터 확인하고, 어떤 순서로 고치면 되는지”를 체크리스트로 제공합니다. 단순히 k를 줄이거나 인스턴스를 키우는 이야기가 아니라, 재현/측정 → 원인 분류 → 단기 완화 → 구조적 해결까지 현업 기준으로 정리합니다.


0) 먼저 결론부터: OOM은 보통 4가지 축에서 난다

  1. 인덱스 타입/정밀도 문제: IndexFlat*를 대용량에 쓰거나, float32를 고집해서 메모리가 선형으로 폭증
  2. 로드/복제 방식 문제: 워커마다 인덱스를 개별 로드(프로세스 수만큼 N배), GPU로 올리며 중복
  3. 검색 파이프라인 문제: 배치 임베딩/검색에서 임시 배열이 큰 피크를 만들거나, 결과 후처리에서 객체가 누적
  4. 운영/코드 누수 문제: 요청 캐시/세션/큐/리트라이가 누적되거나, 파이썬 레퍼런스가 남아 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) kfetch_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) 쿠버네티스에서 메모리 제한이 너무 타이트한가?

  • requestslimits가 같고 여유가 없으면 스파이크에 바로 죽습니다.
  • 피크 메모리가 큰 워크로드(배치 임베딩/리랭킹)는 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은 대개 인덱스 하나의 문제가 아니라, 인덱스 타입 선택 + 프로세스 모델 + 배치/후처리 방식이 합쳐져 폭발합니다. 오늘 바로 할 일은 간단합니다.

  1. 요청 단위 RSS delta를 찍어 피크인지 누수인지를 분리하고, 2) 워커 중복 로드를 제거하며, 3) Flat이라면 IVF/PQ 같은 압축 인덱스로 전환 계획을 세우세요.

이 체크리스트대로 한 단계씩 좁혀가면 “가끔 죽는 RAG”를 “예측 가능한 RAG”로 바꿀 수 있습니다.