RAG 운영: 전체 재색인을 피하고 캐시는 본 요청을 막지 않는 법
처음 RAG를 짤 때는 문서를 고칠 때마다 전체를 다시 색인했습니다. 문서가 백 개쯤 될 때는 그래도 됐습니다. 잠깐 기다리면 끝났거든요. 그런데 문서가 천 개를 넘고, 거기에 재고 아이템까지 색인에 들어가니 얘기가 달라졌습니다. 전체를 다시 임베딩하면 수천 번 모델을 부르는 셈이고, 그 사이에 질문이 들어오면 답이 늦거나 틀립니다.
이 글은 전체 재색인을 피하고, 데이터가 바뀌어도 RAG가 멈추지 않게 만든 운영 결정들을 적은 겁니다.
content_hash: 바뀐 것만 다시 임베딩
가장 먼저 한 건, 각 소스의 내용을 해시로 저장하는 것이었습니다. 색인할 때 제목과 본문을 합쳐 SHA-256 해시를 만들고, 그 값을 doc_chunk 테이블에 content_hash로 남깁니다. 다음 색인 때는 이 해시를 비교해서, 해시가 같으면(즉 내용이 안 바뀌었으면) 건너뜁니다.
이게 핵심입니다. 임베딩은 돈과 시간이 드는 작업입니다. 문서 1,300개와 아이템 980개를 매번 전부 다시 임베딩하면, 실제로 바뀐 건 열 개 남짓인데 천 번을 돌리는 셈이 됩니다. 해시로 걸러내면 진짜 바뀐 것만 모델을 부릅니다. 어제 하루에 바뀐 문서가 다섯 개면 다섯 번만 임베딩합니다.
색인 코드는 "바뀐 것만 고른 뒤, 정해진 배치 수만큼만 돌린다"로 되어 있습니다. 기본 배치는 8개, 최대 20개입니다. 한 번에 전부를 돌리지 않는 이유는, 색인하는 동안 질문이 들어와도 답이 늦지 않게 하려는 거죠. 바뀐 게 많으면 여러 번에 나눠 돌립니다.
CHUNKING_VERSION: 규칙이 바뀌면 어떻게
여기서 문제가 하나 생깁니다. 내용은 안 바뀌었는데, 청크를 자르는 규칙을 바꾸면 어떡할까요. 예를 들어 청크 크기를 800토큰에서 1,200토큰으로 바꾸면, 저장된 청크는 옛 규칙으로 만든 것이라 새 규칙과 섞입니다. 내용 해시만 보면 바뀐 게 없으니 재임베딩을 건너뛰고, 결국 옛 청크가 그대로 남습니다.
이걸 막으려고 청킹 버전(CHUNKING_VERSION)을 해시에 섞었습니다. 규칙을 바꾸면 이 버전을 올립니다. 그러면 같은 내용이라도 해시가 달라져서, 다음 색인에서 전체를 다시 만듭니다. 주석에 적은 대로, "규칙을 바꾸면 이 값을 올린다. 내용이 그대로여도 자르는 방식이 달라지면 저장된 청크는 옛 규칙으로 만든 것이라 그대로 두면 새 규칙과 섞인다."
임베딩 모델도 같은 방식으로 다룹니다. 각 청크에 어떤 모델로 임베딩했는지(embedding_model)를 저장하고, 현재 쓰는 모델과 다르면 재임베딩합니다. 모델을 바꾸면 옛 벡터가 섞이는 걸 막는 거죠. 단, 임베딩 모델은 벡터 차원(1024)과 결박되어 있어서, 함부로 바꾸면 안 됩니다. 차원이 다르면 같은 테이블에 넣을 수가 없으니까요. 그래서 설정 화면에서 못 바꾸게 하고 환경변수로만 관리합니다.
고아 청크: 삭제된 문서의 청크 처리
문서를 지우면 어떻게 될까요. doc_chunk 테이블에 외래키를 두고, Doc이나 Item이 지워지면 청크도 cascade로 따라 지워지게 했습니다. 즉 DB 레벨에서 자동 정리됩니다.
하지만 이것만으로는 부족한 경우가 있습니다. 문서를 완전히 지우지 않고 deletedAt로 소프트 삭제했을 때. 이때 문서 행은 남아있어서 cascade가 안 일어나고, 청크도 남습니다. 색인 대상에서는 빠졌는데 벡터 공간에는 남아있는 셈이죠. 검색하면 삭제된 문서가 걸립니다.
그래서 색인 마지막에 한 번 더 정리합니다. 현재 색인 대상에 없는 소스의 청크를 전부 지웁니다. 코드는 DELETE FROM doc_chunk WHERE (source_type='doc' AND source_doc_id NOT IN (...)) OR (source_type='item' AND source_item_id NOT IN (...)) 꼴입니다. 소프트 삭제된 문서는 색인 대상 목록에서 빠지니, 이 정리 단계에서 청크가 사라집니다.
캐시는 가속 계층, 본 요청을 막지 않는다
검색은 같은 질문이 반복될 때가 많습니다. 매번 임베딩하고 DB를 찌르면 비싸니까, Redis에 검색 결과를 캐시합니다. 질문을 해시 키로 쓰고, 결과를 10분간 저장합니다.
여기서 중요한 결정이 하나 있습니다. Redis가 죽었을 때 RAG 전체가 멈추면 안 된다는 것. 코드에 주석으로 적어뒀습니다. "Redis는 재생성 가능한 가속 계층이다. 장애가 RAG 본 요청을 막아서는 안 된다."
그래서 캐시 읽기가 실패하면 null을 반환하고, 그러면 검색은 DB에서 다시 가져옵니다. 쓰기가 실패해도 그냥 넘어갑니다. 캐시가 없으면 느려질 뿐, 답이 안 나오지는 않습니다. Redis가 살아있으면 빠르고, 죽었으면 느린데 동작은 하는 구조입니다.
캐시 무효화도 같은 태도로 짰습니다. 문서가 바뀌면 관련 캐시를 지워야 하는데, 무효화가 실패해도 10분 TTL로 자연 만료됩니다. 즉 실패하면 최대 10분간 옛 답이 나올 수 있지만, 그 후에는 새 답이 나옵니다. 완벽한 무효화보다, 대략 맞는 답이 계속 나오는 쪽이 혼자 쓰는 시스템에는 더 맞았습니다.
데이터가 늘면: 배치와 잠금
색인을 돌릴 때 동시에 두 군데서 돌면 어떡할까요. 관리자 화면에서 수동으로 색인을 돌리는 동안, 정기 작업도 같이 돌 수 있습니다. 같은 문서를 두 번 임베딩하거나, 중간 상태가 섞일 수 있습니다.
이걸 막으려고 PostgreSQL의 자문 잠금(advisory lock)을 썼습니다. 각 소스를 색인할 때 pg_advisory_xact_lock으로 그 소스의 잠금을 잡습니다. 같은 소스를 동시에 색인하려면 한쪽이 기다립니다. 즉 같은 문서가 두 번 임베딩되지 않습니다. 서로 다른 문서는 잠금이 안 겹치니 병렬로 진행됩니다.
배치 크기도 정해뒀습니다. 한 번의 색인 호출에 최대 20개 소스까지만 돕니다. 바뀐 게 그보다 많으면 여러 번에 나눠 돌립니다. 한 번에 전부를 돌리면 임베딩 모델이 한참 점유돼서, 그 사이에 들어온 질문이 늦습니다. 쪼개서 돌리면 질문이 들어와도 임베딩이 끼어들 틈이 있습니다.
남은 문제
지금은 색인을 관리자 화면에서 버튼을 누르거나, API를 호출할 때 돌립니다. 즉 사람이 촉발합니다. 문서를 고치고 나서 색인 버튼을 안 누르면, 새 내용이 검색에 안 나옵니다. 이걸 문서 저장 시 자동 촉발로 바꾸면 편하지만, 그러면 저장이 임베딩 때문에 느려집니다. 지금은 "고쳤으면 색인도 돌려라"라는 수동 루틴으로 감당하고 있습니다.
데이터가 더 늘면, 결국 백그라운드 큐를 달아야 할 겁니다. 문서 저장은 큐에 작업만 밀어 넣고 끝내고, 워커가 임베딩을 돌리는 구조. 지금 4,500개 청크 수준에서는 그런 인프라가 과하지만, 만 개를 넘으면 고민해야 할 시점이 옵니다. 그때도 핵심은 같을 겁니다. 바뀐 것만 돌리고, 캐시는 망가져도 본 요청은 살려두고, 잠금으로 충돌을 막는 것. 전체 재색인은 최후의 수단으로 남겨두는 것.
함께 읽기
- 문서 RAG에 재고 981건을 같이 색인한 방법: 구조화 데이터의 텍스트화RAG를 "문서 검색"이라고만 생각하면 놓치는 게 많습니다. 제가 실제로 자연어로 묻고 싶은 질문의 절반은 문서가 아니라 데이터베이스에 들어있는 구조화된 데이터에 대한 것이었습니다. "이 철제장에 뭐 있지", "이 브랜드 물건 중 정리 대기 걸린 건 뭐지", "유효기간 지난 거 있어?". 이런 건 문서로 안 적어둡니다.…
- 한국어 RAG에서 벡터만 쓰면 안 되는 이유: 하이브리드 검색 설계처음 RAG를 짤 때는 벡터 검색만 썼습니다. 코사인 유사도 하나로 충분할 줄 알았죠. 그런데 한국어 질문에서 이상한 일이 잦았습니다. 분명 문서에 있는 단어를 물었는데 검색이 안 됩니다. 비슷한 의미의 다른 문서만 계속 위에 뜹니다. 원인을 찾다 보니, 벡터 검색이 "의미가 비슷한가"는 잘 재는데 "그 단어가 실제로 …
- RAG 답변 품질을 메트릭 없이 지키는 법: 출처·임계값·방어 코드RAG 답변 품질을 재는 정량 프레임워크가 있습니다. RAGAS, TruLens 같은 도구가 답변의 충실도(faithfulness), 관련성, 맥락 적합성을 점수로 냅니다. 처음엔 이런 걸 달아야 하나 고민했습니다. 그런데 막상 혼자 쓰는 RAG를 짜고 보니, 정량 점수보다 답이 거짓말을 안 하게 만드는 "코드"가 먼저…
- 멀티모달 RAG의 현실: 모달을 섞지 않고 텍스트로 환원하는 이유멀티모달 RAG라는 말을 처음 들었을 때, 이미지를 임베딩해서 문서 텍스트와 같은 벡터 공간에 올리는 걸 상상했습니다. CLIP 같은 모델로 그림과 글을 하나의 공간에 두면, "이런 느낌의 다이어그램 찾아줘" 같은 질문도 답이 나올 것 같았죠.
- 혼자 쓰는 RAG: 인증·권한을 빼고 남은 설계 결정들ChatGPT에 문서를 전부 올리면 끝나는 일 아닌가,라고 한동안 생각했습니다. 그런데 제가 쓰는 데이터를 전부 올리는 건 사정이 달랐습니다. 재고 목록, 영업 리드, 기도 기록, 가계부까지 한 사람의 전 삶이 들어있는 DB였거든요. 그래서 로컬 LLM 기반 RAG를 직접 짰습니다. 이 글은 그 과정에서 내린, 그리고 …