지난 글에서 색인 안의 어느 범위를 볼 것인가를 다뤘다. 그런데 그 색인 자체가 언제 것인지는 아직 이야기하지 않았다.
색인은 어느 시점의 사진이다. 문서를 읽어 자르고 벡터로 만들어 넣은 순간의 상태가 그대로 굳는다. 원본은 그 뒤에도 계속 바뀌지만 색인은 저절로 따라오지 않는다. 사내 규정이 지난주에 개정됐는데 챗봇이 옛 조항을 자신 있게 인용하는 일이 이래서 생긴다. 검색은 정확히 동작했다 — 다만 어제의 세계에서.
「실시간」이라는 말이 가리는 것
갱신이 느리다는 이야기를 할 때, 어디가 느린지는 대개 안 나눠 본다. 그런데 지연은 세 토막이고 성격이 다 다르다.
감지 지연은 원본이 바뀐 시각과 우리가 그것을 알아챈 시각의 차이다. 한 시간마다 훑는다면 이 값은 평균 30분, 최악 1시간이다. 코드를 아무리 빠르게 만들어도 줄지 않는다 — 주기가 그대로 값이기 때문이다.
처리 지연은 다시 자르고 임베딩을 만드는 데 걸리는 시간이다. 문서 한 개면 몇 초지만, 밀린 것이 쌓이면 대기열에서 기다린 시간이 여기 다 들어간다. 평소에는 짧다가 대량 갱신이 한 번 오면 갑자기 몇 시간이 되는, 값이 가장 크게 흔들리는 구간이다.
반영 지연은 색인에 쓴 뒤 검색 결과에 나타나기까지다. 저장소마다 다르다. 곧바로 보이는 곳도 있고, 세그먼트를 주기적으로 정리하는 구조라 몇십 초 걸리는 곳도 있다.
셋을 따로 재야 고칠 곳이 보인다. 세 값을 한 번도 나눠 재지 않은 채 「갱신이 느리다」고 하면 대개 엉뚱한 데를 손보게 된다. 임베딩 배치를 최적화했는데 실제 병목은 한 시간짜리 훑기 주기였다는 식이다.
변경을 어떻게 알아채는가
감지 방식은 셋이고, 고르는 기준은 원본이 무엇을 내주느냐다.
| 방식 | 어떻게 | 감지 지연 | 걸리는 조건 |
|---|---|---|---|
| 주기적 훑기 | 목록을 받아 수정 시각을 비교 | 주기의 절반~한 배 | 아무 데나 된다 |
| 변경 알림 받기 | 원본 쪽이 바뀔 때 호출해 준다 | 몇 초 | 원본이 알림을 지원해야 |
| 변경 기록 읽기 | 데이터베이스 변경 로그를 따라간다 | 몇 초 | 원본이 데이터베이스일 때 |
수정 시각만 믿으면 안 된다. 파일 시스템이나 문서 도구의 수정 시각은 내용이 안 바뀌어도 갱신되는 경우가 흔하다. 권한을 바꾸거나 열어만 봐도 시각이 도는 곳이 있다. 그대로 믿으면 매 주기마다 전체를 다시 임베딩하게 된다.
그래서 수정 시각은 후보를 좁히는 데만 쓰고, 실제로 바뀌었는지는 내용 해시로 확인한다. 문서 본문의 해시를 저장해 두고 다시 계산해 비교하면, 시각이 돌았어도 내용이 같으면 그냥 넘어간다.
def changed_docs(remote, index_state):
for doc in remote.list(modified_after=index_state.last_run):
h = sha256(doc.body) # 시각은 후보만 좁히고
if h != index_state.hash(doc.id): # 판단은 해시로
yield doc, h
한 곳을 고쳤는데 다섯이 딸려 오는 문제
바뀐 문서를 찾았다고 해서 일이 작아지는 것은 아니다. 문서 안에서 무엇을 다시 계산할지가 남는다.
글자 수로 자르면 경계가 밀린다. 두 번째 문단에 두 문장이 늘었을 뿐인데, 800자마다 자르는 규칙 아래에서는 그 뒤 모든 조각의 시작점이 함께 밀린다. 세 번째 조각은 내용이 하나도 안 바뀐 부분인데도 어제와 다른 글자로 채워지고, 그러니 다시 임베딩해야 한다. 문서 끝에서는 조각이 하나 더 생기기도 한다.
경계를 문서 자체의 구획 — 문단, 절 제목, 표 — 에 맞춰 두면 이 번짐이 사라진다. 청킹 전략에서 문단 경계를 지키라고 한 이유가 검색 품질 때문만은 아닌 셈이다. 갱신 비용이 여기서 자릿수로 갈린다.
청크에 안정된 식별자를 준다. 문서id:5번째 처럼 순번으로 키를 만들면 앞에 조각이 하나 끼는 순간 뒤 키가 전부 밀려, 내용은 그대로인데 다른 자리에 다시 쓰이게 된다. 문서 안 구획의 이름이나 청크 내용 해시로 키를 만들면 순번이 바뀌어도 같은 청크는 같은 키를 유지한다.
가장 자주 새는 곳은 삭제다
추가와 수정은 눈에 보인다. 문서가 안 나오면 누군가 알아채고 고친다. 삭제는 반대다 — 원본에서 지운 문서가 색인에 남아 있어도 아무 오류가 안 나고, 그저 검색 결과에 계속 나온다. 그리고 그 근거로 답이 만들어진다.
이유는 감지 방식에 있다. 위의 세 방식 중 주기적 훑기는 「있는 것의 목록」을 받는다. 지워진 것은 목록에 없으므로, 없다는 사실을 알아채려면 색인에 있는 것과 대조해야 한다. 이 대조를 안 하면 삭제는 영영 반영되지 않는다.
대응은 둘이다.
지웠다는 표시를 먼저 남긴다. 색인에서 곧바로 빼는 대신 deleted: true 같은 값을 세우고 검색에서 그 조건을 항상 배제한다. 반영이 즉시라서 노출이 바로 멎고, 실수였을 때 되돌리기도 쉽다. 실제 삭제는 나중에 몰아서 한다.
주기적으로 전체를 대조한다. 하루에 한 번쯤 원본의 전체 식별자 목록과 색인의 목록을 맞춰 보고, 색인에만 있는 것을 지운다. 알림을 놓쳤거나 오류로 건너뛴 것이 여기서 걸린다. 이 대조를 돌려 본 적 없는 색인에는 대개 유령 문서가 꽤 있다.
권한 회수도 같은 성격이다. 어떤 문서를 볼 수 있는 사람이 줄었는데 색인의 값이 예전 그대로면, 지난 글에서 다룬 권한 필터가 옛 기준으로 동작한다. 필터가 정확해도 대조하는 값이 낡았으면 소용이 없다.
임베딩 모델을 바꾸면 전부 다시다
증분 갱신으로는 못 넘는 선이 하나 있다. 임베딩 모델을 바꾸면 색인 전체가 무효가 된다. 벡터끼리의 거리는 같은 모델이 만든 것들 사이에서만 뜻이 있어서, 새 모델과 옛 모델의 벡터를 한 공간에 섞으면 검색이 그냥 망가진다. 차원 수가 우연히 같아도 마찬가지다.
그래서 임베딩 모델 교체는 갱신이 아니라 이사다. 새 색인을 따로 만들어 전체를 채운 뒤, 검색이 바라보는 곳을 한 번에 옮기고, 문제가 없으면 옛 색인을 지운다. 채우는 동안 원본이 계속 바뀌므로, 옮기기 직전에 그동안 쌓인 변경을 한 번 더 흘려 넣는 단계가 필요하다.
이 작업의 크기가 곧 평소에 재색인을 얼마나 잘 돌릴 수 있느냐의 시험이다. 전체 재색인을 한 번도 안 해 본 시스템은 모델 교체 때 처음 시도하게 되는데, 그때는 대개 잘 안 된다. 분기에 한 번쯤은 빈 색인에서 전체를 다시 채워 보고 걸리는 시간을 재 두는 편이 낫다.
색인이 최신이어도 남는 문제
여기까지 다 해도 하나가 남는다. 옛 버전과 새 버전이 둘 다 색인에 있는 경우다. 지난해 규정과 올해 규정이 각각 별개 문서로 존재하면, 검색은 둘 다 비슷하게 가깝다고 판단한다. 어느 쪽이 지금 유효한지는 문서 내용만 봐서는 모른다.
이건 갱신 문제가 아니라 답변 설계 문제다. 셋으로 나눠 다룬다.
- 효력 기간을 값으로 넣는다. 「2026-01-01부터 유효」 같은 값을 청크에 붙여 두고, 지난 것은 앞 글의 필터로 걸러 낸다. 사람이 판단할 것을 검색에 맡기지 않는다
- 최신 쪽에 가산점을 준다. 필터로 자르기 애매하면 점수 단계에서 최근 문서를 조금 올린다. 재순위에서 다룬 자리에 시각을 하나 더 넣는 식이다
- 답에 기준 시각을 적는다. 「2026년 3월 개정본 기준입니다」 한 줄이면 읽는 사람이 낡았는지 판단할 수 있다. 근거의 날짜를 아예 안 보여 주면 확인할 방법이 없다
정리
- 지연은 감지·처리·반영 셋이고 성격이 다르다. 따로 재지 않으면 엉뚱한 데를 고친다
- 수정 시각은 후보를 좁히는 데만 쓰고, 바뀌었는지는 내용 해시로 판단한다
- 글자 수로 자르면 한 곳을 고쳐도 뒤 청크가 전부 밀린다. 경계를 문서 구획에 맞춘다
- 청크 키를 순번으로 만들지 않는다. 구획 이름이나 내용 해시를 쓴다
- 삭제는 오류를 안 내고 조용히 남는다. 지움 표시를 세우고, 주기적으로 전체를 대조한다
- 임베딩 모델 교체는 증분이 아니라 색인 통째로 갈아 끼우는 일이다. 미리 연습해 둔다
- 옛 버전이 함께 있으면 최신 색인도 소용없다. 효력 기간을 값으로 넣고 답에 기준 시각을 적는다
읽어주셔서 감사합니다. 😊

