한글 문서 RAG를 만들었더니, 고생은 검색이 아니라 수집에 있었습니다
RAG 관련 글을 찾아보면 대부분 검색을 말합니다. 리트리버를 어떻게 짜는지, 리랭커를 어디에 붙이는지, 하이브리드 검색이 정확도를 얼마나 올리는지. 정작 제가 하루를 통째로 쓴 자리는 그 앞이었습니다.
제안·컨설팅 도메인의 사내 제안서 지식검색 도구를 만들었습니다. 견적서와 제안서, 산출물을 프로젝트 단위로 모아 자연어로 검색하고 질의하는 구조입니다. 입력이 한국 비즈니스 문서라 HWP와 HWPX, PDF, 스캔본이 한 폴더에 섞여 들어왔습니다.
그리고 이 파이프라인은 한 번 돌리고 끝나는 배치가 아니었습니다. 문서가 추가될 때마다 같은 폴더를 다시 스캔하게 됩니다. 멱등성이 나중에 붙일 기능이 아니라 처음부터의 전제 조건이었습니다.
같은 폴더를 두 번 돌리면 무엇이 쌓이나
재색인에서 문제가 되는 것은 실패가 아니라 성공입니다. 파이프라인은 에러 없이 끝나고, 벡터 수만 두 배가 됩니다. 파일이 다른 폴더로 옮겨졌거나 이름만 바뀌어도 시스템은 그것을 새 문서로 봅니다.
결과는 검색 단계에서 드러납니다. 상위 결과가 같은 문장 두 벌로 채워지고, 답변에 붙는 출처도 같은 곳을 두 번 가리킵니다. 사용자에게는 검색 품질이 나빠진 것으로 보이는데, 원인은 리트리버가 아니라 색인 앞단에 있습니다.
문서 id를 경로가 아니라 내용에서 뽑았습니다
수집 단계의 순서 자체는 단순합니다. 폴더를 스캔하고, 파일명과 폴더명에서 프로젝트·고객 메타를 뽑고, 파일 내용을 해시해 document_id를 만듭니다. 원본 파일은 객체 스토리지에 올리고 그 키만 문서 노드에 기록합니다.
id를 경로에서 만들지 않은 것이 이 단계의 핵심 결정이었습니다. 경로 기반 id는 사람이 폴더를 정리하는 순간 무너집니다. 그리고 실무에서 문서 폴더는 반드시 정리됩니다.
상황 | 경로 기반 id | 콘텐츠 해시 id |
|---|---|---|
파일을 다른 폴더로 이동 | 새 문서로 색인 | 같은 문서로 인식 |
파일명만 변경 | 새 문서로 색인 | 같은 문서로 인식 |
같은 파일 재투입 | 중복 누적 | 중복 없음 |
내용만 한 글자 수정 | 같은 문서로 덮어씀 | 별개 문서로 분리 |
마지막 행이 이 선택의 비용입니다. 콘텐츠 해시는 거의 같은데 한 글자 다른 문서를 완전히 별개로 봅니다. 제안서 초안과 최종본이 둘 다 색인되는 상황은 근접 중복 제거를 따로 붙여야 정리됩니다.
문서 노드에는 PENDING과 INDEXED 두 상태만 두었습니다. 실데이터 13개 문서로 수집을 반복 실행해 중복이 생기지 않는 것까지 확인하고 다음 단계로 넘어갔습니다.

한글 문서를 전부 먹는 파서는 없었습니다
HWP 처리는 전용 라이브러리를 붙이는 길과 오피스 변환기를 경유하는 길이 있었습니다. 변환 경유를 골랐습니다. 포맷이 하나 늘 때마다 파서를 하나씩 붙이는 대신, 포맷 다양성을 변환기에 떠넘기는 쪽이 유지 비용이 낮았습니다.
1티어는 오피스 변환기로 HWP와 HWPX를 PDF로 바꾼 뒤 PDF에서 텍스트를 추출합니다. 대부분의 문서는 여기서 끝납니다. 문제는 여기서 아무것도 나오지 않는 문서였습니다.
추출 결과가 0자인 문서는 파싱 실패가 아니라 스캔본입니다. 종이를 찍은 이미지가 PDF 안에 들어 있어 추출할 텍스트 자체가 없습니다. 이 0자를 예외로 던지지 않고 폴백 진입 신호로 썼습니다.
2티어는 비전 LLM입니다. 페이지 이미지를 그대로 넣어 텍스트를 받아옵니다. 정상 경로와 폴백 경로를 처음부터 나눠두면, 예상 밖의 입력이 들어와도 파이프라인 전체를 고칠 일이 줄어듭니다.

청킹은 글자 수보다 구조를 먼저 봅니다
청크는 섹션 경계를 기준으로 1000자 안팎, 앞뒤 150자를 겹쳐 잘랐습니다. 표는 자르지 않고 원자 단위로 보존했습니다. 표 중간에서 끊긴 청크는 검색에 걸려도 근거로 쓰기 어렵습니다.
여기서 멱등성의 두 번째 축이 나옵니다. 청크 id를 랜덤이나 실행 순번으로 만들면 같은 문서를 다시 색인할 때마다 새 id가 생깁니다. 문서 id가 같아도 청크가 중복되면 결과는 똑같습니다.
chunk_id = hash(document_id + 섹션 경로 + 청크 순번 + 청킹 규칙 버전)
같은 문서 · 같은 위치 -> 항상 같은 chunk_id
벡터 저장은 insert 가 아니라 upsert -> 두 번 돌려도 한 벌id를 만들기 전에 공백을 정규화하는 것도 같이 넣었습니다. 변환기가 공백을 하나 더 넣고 빼는 것만으로 해시가 달라지면 결정적 id를 만든 의미가 없습니다.
임베딩은 1536차원 모델로 100개씩 배치 처리하고 L2 정규화까지 마친 뒤 벡터DB에 upsert합니다. 라이브 데이터 기준으로 496개 벡터가 올라갔고, 같은 폴더를 다시 돌려도 이 숫자는 변하지 않습니다.
원본과 구조와 의미를 세 곳에 나눠 담았습니다
저장소는 하나로 합치지 않았습니다. 원본 파일은 객체 스토리지, 문서와 프로젝트의 관계는 그래프DB, 청크의 의미 유사도는 벡터DB로 나눴습니다. 셋을 잇는 것은 문서 id와 청크 id 두 개의 브리지 키뿐입니다.
검색은 이 분리를 그대로 따라갑니다. 질의를 임베딩해 벡터DB에서 최근접 청크를 찾고, 거기서 받은 청크 id로 그래프에서 본문과 출처 문서를 가져옵니다. 답변 단계는 이 근거만 넣고 LLM이 출처를 인용해 답하게 합니다.
스캔본에서 뽑은 텍스트가 답변 근거로 인용되는 것까지 브라우저에서 확인했습니다. 파싱 폴백이 실제로 검색 대상까지 도달했는지는 이 방식이 아니면 확인이 어렵습니다.
저장을 나누면 재색인할 때 무엇을 지우고 무엇을 남길지가 곧바로 문제가 됩니다. 그래프 쪽에서 같은 판단을 했던 기록을 따로 정리해 두었습니다.

이 구조가 깨지는 자리
멱등해졌다고 파이프라인이 안전해진 것은 아닙니다. 이 설계는 최소한 네 지점에서 깨집니다.
깨지는 지점 | 무슨 일이 생기나 | 지금 대응 |
|---|---|---|
근접 중복 문서 | 한 글자만 달라도 별개 문서로 색인 | 근접 중복 제거를 별도 단계로 검토 |
오피스 변환기 실패 | 레이아웃 손실 또는 변환 자체 실패 | 실패 문서를 PENDING으로 남겨 재시도 |
청킹 규칙 변경 | 결정적 청크 id가 전부 달라짐 | 규칙 버전을 id 재료에 포함, 변경 시 전량 재색인 |
스캔본 비율 상승 | 비전 LLM 폴백의 비용과 지연 증가 | 폴백 진입 문서 수를 색인 비용으로 계측 |
세 번째가 가장 조용합니다. 청킹 규칙을 조금 손보고 재색인을 돌리면 에러 없이 끝나는데, 옛 청크가 지워지지 않은 채 새 청크가 그 위에 얹힙니다. 그래서 규칙 버전을 id 재료에 처음부터 넣어두었습니다.
마무리
이 하루의 실질은 검색을 잘하게 만든 것이 아니라 수집을 멱등하게 만든 것이었습니다. 문서 id를 내용에서 뽑고, 파서에 폴백을 두고, 청크 id를 결정적으로 만들고, 저장소를 셋으로 나눈 것이 전부입니다.
리트리버와 리랭커를 손대기 전에 볼 것이 두 가지 있습니다. 같은 입력을 두 번 넣어도 결과가 같은지, 그리고 파서가 들어오는 입력을 다 먹는지. 이 둘이 흔들리는 상태에서 검색 정확도를 올리면 올린 만큼 다시 흐려집니다.