RAG 배치 업데이트가 완료되었는데 검색 상위 10개가 전날과 동일했던 문제: 차이 반영 검증 및 upsert 순서 개선 완전 구현
요약
RAG 배치 업데이트 과정에서 문서가 갱신되어도 검색 결과에 반영되지 않는 치명적인 버그를 분석했습니다. 원인은 ID 설계와 upsert 순서의 문제였으며, 해결책으로 안정 키(stable key) 사용과 '삭제 → 추가' 순서를 고정하는 완전한 upsert 구현체를 제시합니다. 또한, 배치 완료 로그 외에 인덱스 신선도 검증 로직을 필수적으로 내장해야 함을 강조합니다.
핵심 포인트
- ID 설계 시 안정 키(stable key)를 사용하면 복제 위험 없이 덮어쓰기가 가능합니다.
- 업데이트 순서는 반드시 '구 버전 삭제 → 신 버전 추가'로 고정되어야 합니다.
- 배치 완료 로그만으로는 부족하며, 인덱스 내 최신 버전 비율을 측정하는 검증 로직이 필수입니다.
- ID가 변경되면 기존 벡터는 삭제되지 않고 살아남아 검색에 영향을 줄 수 있습니다.
오전 5시 배치가 '142 docs updated' 로그를 출력했음에도 불구하고, 검색 상위 10개 결과가 전날과 같은 구성으로 유지된 사고였습니다. 신규 버전의 문서가 단 하나도 반영되지 않은 상태가 반나절 동안 지속되었습니다. 원인은 content-hash를 ID로 사용한 업데이트 설계가 '신규 버전 추가'만 수행하고 '구 버전 삭제'를 누락했기 때문이었습니다. 본 글에서는 차이(diff)가 인덱스에 제대로 반영되었는지 검증하는 한 줄의 체크 로직과, 삭제 → 추가 순서를 고정시킨 완전한 upsert 구현체(chromadb 및 복사/붙여넣기로 작동 가능)까지 다룹니다.
| 일시 | 발생 내용 |
|---|---|
| 11/1 05:00 | 야간 배치가 사양서 142건을 재획득하고, embedding → upsert 완료 로그 출력 |
| ... | |
| 상위 10개가 '전날과 동일'했던 이유는 검색 속도가 느려서가 아니라, 구 버전의 벡터가 계속 살아남아 검색 거리가 가까운 구 버전만 반환되었기 때문입니다. 버그는 업데이트 처리 자체가 아닌, 업데이트의 ID 설계와 순서에 있었습니다. |
ID 설계: 문서 ID를 sha256(본문)으로 설정했기 때문에, 본문이 바뀌면 새로운 ID가 발행됩니다. upsert는 'ID가 같은 것을 덮어쓰기'하므로, ID가 바뀌면 기존 ID는 건드려지지 않아 둘 다 생존하게 됩니다.
순서: 구 버전을 삭제하기 전에 신 버전을 추가(추가 → 삭제)하면, 삭제 조건이 ID로 작성되는 순간 삭제 대상이 신 버전으로 대체됩니다. 삭제는 ID가 아닌 '안정 키(stable key)'를 사용해야 합니다.
검증 부재: 배치 완료 로그는 'API 호출 성공'만을 보장합니다. '인덱스 내 최신 버전의 비율'을 측정하는 검증 로직이 단 한 줄도 없었습니다.
원인을 특정하는 데 사용할 수 있는 것이 바로 업데이트 후 인덱스의 신선도(freshness) 검증입니다. 1문서 = 1벡터가 되었는지, 하나의 안정 키에 구 버전이 몇 건 남아있는지를 계산합니다. 사고 당시에는 ID는 content-hash였지만 메타데이터에 안정 키를 가지고 있었기 때문에, 이 집계를 통해 중복 생존을 확인할 수 있었습니다.
from collections import Counter
import chromadb
client = chromadb.PersistentClient(path="./db")
...
stale_copies=0
이 외의 모든 값은 '반영되지 않음'을 의미합니다. 이 한 줄을 배치 마지막에 추가하는 것만으로, 초기의 사고는 '오전 5시 시점에 감지'할 수 있었습니다.
"""RAG 차이 업데이트 완전형: 안정 키로 삭제 → 추가 순서 고정 + 반영 검증 포함"""
import hashlib
from datetime import datetime, timezone
...
핵심 포인트는 3가지입니다. ① ID = 안정 키: upsert의 ids에 stable_key를 직접 사용하면, 두 번째 업데이트가 '동일 ID 덮어쓰기'가 되어 복제가 구조적으로 발생하지 않습니다 (hash를 메타데이터에 저장하면 '내용이 바뀌었는지' 판별도 가능합니다). ② 삭제 → 추가 순서: 만약 중간에 실패하더라도 구 버전만 남아있을 뿐, 신 버전의 중복 투입은 일어나지 않습니다. ③ 반영 검증 배치 내장: 완료 로그와 실제 인덱스의 일치 여부를 동일한 트랜잭션 내에서 assert합니다.
실행 방법:
$ python3 rag_batch.py
{'upserted': 142, 'checked_at': '2026-11-01T20:40:11+00:00'}
$ python3 freshness_audit.py
...
| ID 방식 | 업데이트 시 동작 | 복제 위험성 | 사고 발생 시 감지 여부 | 판정 |
|---|---|---|---|---|
| content-hash (사고 버전) | 본문 업데이트로 신규 ID 발행 및 구 ID 생존 | 높음 (매번 업데이트마다 복제) | ||
| ... |
실패 1: content-hash를 ID로 사용한 설계. '변조 감지까지 겸하여 일석이조'라고 생각한 것이 오류였습니다. ID의 역할(안정적인 삭제/덮어쓰기 단위)과 지문(fingerprint)의 역할(내용 변화 감지)을 혼합했습니다. 지문은 메타데이터에, ID는 안정 키로 분리하는 것이 정답입니다.
실패 2: 삭제 조건을 (생략). 삭제 대상이 구 hash였다면, 배치 재실행 시 hash가 바뀐 순간 삭제할 수 없는 고아가 남게 됩니다. 삭제는 항상 content_hash로 작성한 초판의 수정된 stable_key(where 절)를 사용해야 했습니다.
실패 3: 검증을 '다음 날 검색에서 인지'하는 전제 운영. 검색 결과 구성은 변화가 적기 때문에, 반영 누락은 사람의 눈으로는 감지할 수 없습니다 (실측: 반나절 동안 손해). 검증은 배치 내장으로 기계적으로 수행해야 합니다.
- ID를 안정 키(Stable Key)로 전환하고, 삭제 → 추가 순서로 upsert 로직을 재작성합니다. content_hash는 메타데이터로 이동시킵니다.
- 신선도 검증(Freshness Validation) (
stale_copies==0)을 배치(Batch) 끝에 assert로 추가해야 합니다. 별도의 운영 스크립트는 필요 없습니다. 이때 주의할 점: 기존 인덱스에 content-hash의 구(舊) ID가 남아 있는 경우, 먼저 한 번 '전체 삭제 → 안정 키로 재투입'하는 초기 마이그레이션이 필수적입니다. 이 마이그레이션을 생략하면 구 ID 계열이 계속 남게 되어 검증이 영구적으로 실패하게 됩니다.
본 기사 같은 구현 패턴을 총망라한 치트시트와, Obsidian・Notion 템플릿 모음을 무료로 배포하고 있습니다. 8권 분량의 내용과 '무엇을 어떤 도구로 할지' 판단 차트까지 포함되어 있습니다.
📗 RAG 구축 입문 — LLM에 '지식'을 부여하는 검색 증강 생성(RAG) 실전 가이드 (Kindle・읽어放題 대상)
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기