자사 매뉴얼의 RAG(Retrieval-Augmented Generation)를 운영한 지 2주 만에, 답변의 근거를 확인해 보니 상위 5개가 같은 단락의 복사본으로 채워져 있었습니다. 이는 사양서를 업데이트할 때마다 등록 스크립트를 실행했던 저의 실수였습니다. 동일한 문서를 3번 넣는 순간, 같은 문장이 3중으로 쌓였고, 원래 히트했어야 할 다른 장(章)이 검색 결과에서 사라지고 말았습니다.
본문에서는 등록을 몇 번 하든 중복화되지 않는 ID 설계의 5가지 규칙을 판단 기준표로 제시하고, 복사 붙여넣기로 작동하는 등록/검색 스크립트 하나와, 3번 등록해도 건수가 늘어나지 않으며 폐기된 문서가 사라지는 것을 확인할 수 있는 절차를 그대로 담았습니다. ChromaDB를 사용하지만, ID 설계 자체는 Qdrant나 pgvector에서도 동일한 구조를 사용할 수 있습니다.
- ID는 '절대 경로 + 청크 본문'의 SHA-256으로 한다 — uuid4는 실행할 때마다 바뀌기 때문에 중복 판별 자료가 될 수 없습니다.
- 등록은 upsert로 진행한다 — add에 맡기면 '신규인지 교체인지'를 스스로 판단하게 되지 않습니다.
- source 메타데이터는 resolve()한 절대 경로로 한다 — 상대 경로는 같은 문서가 다른 문서로 계산될 수 있습니다.
- 사라진 문서는 delete(where)로 청소한다 — 청소를 하지 않으면 폐기된 사양이 영원히 답변의 근거에 남게 됩니다.
- 검색 결과의 복제 수는 직접 센다 — 벡터 DB는 중복화를 알려주지 않습니다.
| ID 결정 방법 | 같은 문서를 3번 등록할 경우 | 실제 피해 |
|---|
| uuid4 (실패한 설계) | 모든 청크가 신규 행으로 3번 추가됨 | 같은 단락이 3중화되고, top-k 슬롯을 복제본이 채움 |
| ... | | |
| uuid4의 문제는 'ID가 내용과 무관하다'는 점입니다. 같은 본문이라도 수십 번 입력해도 전부 다른 ID가 되기 때문에, 중복은 조용히 축적됩니다. 반대로 '파일 경로 + 본문'까지 넣은 해시라면, 같은 문서를 몇 번 넣어도 ID가 일치하므로, 이후에는 put(upsert) 계열의 등록 API만 사용하면 중복화가 구조적으로 일어나지 않습니다. | | |
제가 처음에 작성했던 등록 스크립트의 해당 부분입니다 (get_collection)
/ chunk_text
은 후술할 완벽 구현과 동일합니다.
# 실패한 설계 (실제로 작성했던 ingest_bad 재현)
import uuid
def ingest_bad(root: str) -> None:
...
망가지는 이유는 3가지가 있습니다.
- ID가 매번 바뀐다 — uuid4는 등록할 때마다 새로운 값을 반환합니다. ChromaDB 입장에서 '처음 보는 ID'이므로 신규 행으로 추가되어, 같은 본문이 조용히 복제됩니다.
- 업데이트 버전과 구 버전이 공존한다 — 사양을 수정하여 재등록하면, 기존 청크는 사라지지 않고 새 버전이 추가됩니다. 검색은 둘 다 반환하고, LLM은 폐기된 오래된 사양을 근거로 답변하기 시작합니다. 이것이 가장 무서운 사고였습니다.
- 이상 징후를 감지할 수단이 없다 — 중복화되어도 count()나 검색은 에러가 나지 않습니다. '상위 몇 개가 얼마나 중복되는지'는 직접 세는 메커니즘을 갖추지 않는 한 알기 어렵습니다.
실제로 2개의 문서를 3번 등록했을 때 컬렉션의 건수는 다음과 같았습니다.
1회차: count=2
2회차: count=4
3회차: count=6 ← 아무것도 하지 않았는데 2배로 부풀어 오름
Python 3.9 이상에서, pip install chromadb
만으로 충분합니다. 첫 실행 시에만 기본 임베딩 모델(약 80MB・all-MiniLM-L6-v2)이 ~/.cache/chroma
에 다운로드되며, 이후는 오프라인으로 작동합니다. API 키도 필요 없습니다.
# rag_store.py — 사양서를 몇 번 넣어도 중복화되지 않는 RAG 등록·검색 스크립트
# pip install chromadb
# python rag_store.py ingest ./manuals 등록 (재실행해도 늘어나지 않음)
...
설계의 핵심 포인트는, **'ID에 내용을 담고, 교체는 upsert에 맡기고, 청소와 감시를 별 커맨드로 분리한다'**라는 4분할입니다. ID가 digest()
에만 작성되어 있기 때문에, 임베딩 모델을 교체하거나 청크 폭을 바꿔도 중복화 방어는 그대로 유효합니다 (청크 폭을 바꾸면 모든 ID가 바뀌므로, 처음에 gc 상당의 전체 삭제가 필요한 점만 주의하십시오). 참고로, 기본 임베딩은 영어 전제 모델이므로, 일본어 매뉴얼의 순위 정밀도가 필요하다면 임베딩 함수를 다국어 모델로 교체해 주십시오. ID 설계에는 영향을 미치지 않습니다.
샘플 매뉴얼 2개를 준비하겠습니다.
mkdir -p ~/rag/manuals
cat > ~/rag/manuals/login.md <<'EOL'
# 로그인
...
동일한 명령어를 3번 실행합니다.
cd ~/rag && python rag_store.py ingest ./manuals # 이것을 3회 실행
실행 결과는 다음과 같습니다. 2회차 이후로는 1건도 늘어나지 않았습니다.
1회차: [ingest] 문서 2건 / 청크 0 → 2
2회차: [ingest] 문서 2건 / 청크 2 → 2
3회차: [ingest] 문서 2건 / 청크 2 → 2
검색도 정상입니다. 중복 마커가 붙지 않고, 별도의 문서들이 나열됩니다.
[ask] 비밀번호에 이름이나 생일 등을 사용해서는 안 되는 규칙
0.711 login.md#0
# 로그인 비밀번호는 8자 이상으로 하고, 영문 대/소문자・숫자를 각각 1개씩 포함해야 합니다.
...
매뉴얼이 폐지되었는데도 인덱스에서 지우는 것을 잊어버렸을 때 어떻게 되는지는 '구 버전 사양' 설명과 같습니다. 파일을 삭제하고 gc를 1회 실행하면, 대응하는 청크가 사라집니다.
rm ~/rag/manuals/backup.md && python rag_store.py gc ./manuals
[gc] 삭제: /home/user/rag/manuals/backup.md
[gc] 유지 1 / 삭제 1
삭제된 백업 사양에 대해 물어봐도, 사라진 청크는 반환되지 않습니다.
[ask] 데이터베이스의 백업은 매일 언제 가져가야 하나요?
1.111 login.md#0
# 로그인 비밀번호는 8자 이상으로 하고, 영문 대/소문자・숫자를 각각 1개씩 포함해야 합니다.
...
'어떤 타이밍에 재등록할지'의 판단 기준입니다.
| 방식 | 동작 | 약점 |
|---|
| 전체 삭제 후 재등록 | 항상 컬렉션이 최신 상태가 됨 | 매번 모든 청크를 다시 임베딩해야 하므로, 대용량 문서에서는 시간이 많이 소요됨 |
| ... | | |
| 본 기사의 방식은 '등록할 때마다 전부 돌린다'는 전제하에, 불필요한 재임베딩을 ID에 흡수시킵니다. cron으로 야간에 ingest를 실행해 두면, 주간에는 ask와 gc만 할 수 있습니다. | | |
-
자신의 ingest 코드를 열어서
uuid4()
/add()
을 찾아보세요. — 이 둘이 함께 있는 부분은 돌릴 때마다 조용히 늘어나고 있습니다 - ID를 '절대 경로 + 본문 해시'로 대체하고, 등록을 upsert로 변경하여, 같은 문서를 2번 넣어도 count가 증가하지 않음을 확인합니다.
-
검색 전처리 과정에 중복 수 체크를 넣어, 폐지된 문서는 gc로 지우는 운영으로 만듭니다.
-
ID를 uuid4 / 타임스탬프로 만들지 않고 (내용 해시에 함)
-
ID의 종류에 '파일의 절대 경로'와 '청크 본문'을 포함시킵니다.
-
등록을 add가 아닌 upsert로 수행합니다.
-
source 메타데이터가 resolve()된 절대 경로를 갖도록 합니다.
-
같은 디렉토리를 2회 ingest하여 count가 증가하지 않음을 확인했습니다.
-
폐지된 문서를 gc(delete(where))로 지울 수 있는 상태로 만듭니다.
-
검색 결과의 중복 수를 직접 세어 출력합니다.
-
임베딩 모델이 일본어에 적합한지 확인했습니다 (영어 전제 그대로 운영하지 않습니다).
본 기사와 같은 RAG 구현 패턴을 가로질러 정리한 치트시트와, Obsidian・Notion 템플릿 모음을 무료 배포합니다. 'RAG・MCP・GAS・n8n' 판단 차트도 포함됩니다.
관련 기사:
참고 서적: RAG 구축 입문 — LLM에 '지식'을 주는 검색 확장 생성의 실전 (Kindle・Kindle Unlimited 무제한 이용 가능) (하야마 유우키 저)