
문서가 바뀔 때마다 컬렉션 전체를 다시 임베딩하는 파이프라인은 문서 수가 늘어날수록 비용과 처리 시간이 그대로 늘어납니다. 바뀐 문서만 골라 다시 임베딩하도록 해시 기반 변경 감지와 증분 색인을 붙이면, 같은 임베딩 API 호출 횟수를 실제 변경분 수준으로 줄일 수 있습니다. 지금 파일 하나만 고쳐도 컬렉션 전체를 재색인하는 구조로 운영하고 있다면, 이 글에서 다루는 레코드 매니저와 해시 비교 방식부터 붙여 보시길 권해드립니다.
문서가 바뀔 때마다 전체 재임베딩할 때 드는 비용
전형적인 RAG 색인 파이프라인은 문서를 불러오고, 청크로 나누고, 임베딩 API를 호출해 벡터 스토어에 업서트하는 순서로 짜여 있습니다. 이 흐름에 변경 추적이 없으면, 크론잡이 돌 때마다 스크립트는 처음부터 끝까지 다시 실행됩니다.
문제는 청크 1만 개짜리 컬렉션에서 문서 하나만 수정해도 나머지 9,999개까지 다시 임베딩 API를 호출한다는 점입니다. 호출 횟수가 늘어나는 만큼 색인이 끝날 때까지 걸리는 시간도 길어지고, 벡터 스토어에 같은 내용을 반복해서 덮어쓰는 부하도 그대로 쌓입니다. 문서가 100건 수준일 때는 티가 안 나던 구조가, 수천 건을 넘어가는 순간부터 배치 하나 돌리는 데 몇 시간씩 걸리는 병목으로 바뀝니다.
바뀐 부분만 골라내려면 어떻게 접근해야 할까요?
가장 단순하면서 확실한 방법은 문서 내용의 해시값을 저장해두고, 다음 색인 시점에 새로 계산한 해시값과 비교하는 방식입니다. 두 값이 같으면 내용이 그대로라는 뜻이므로 임베딩을 건너뛰고, 다르면 그 문서만 재임베딩 대상으로 표시합니다. 해시 함수의 원리 자체는 위키백과의 해시 함수 문서에 정리되어 있으니 참고하시면 됩니다.
이 방식이 성립하려면 전제가 하나 있습니다. 문서마다 안정적인 식별자(파일 경로, DB 기본키, URL 등)가 있어야 한다는 점입니다. 파이프라인을 돌릴 때마다 임의로 새 ID를 생성하는 구조라면 해시를 아무리 비교해도 “같은 문서”라는 걸 시스템이 알아채지 못해서 매번 신규 문서로 처리됩니다.
라이브러리 없이 표준 라이브러리만으로 구현하면 아래처럼 짤 수 있습니다. 해시 알고리즘은 SHA-256을 사용했는데, 구체적인 스펙은 NIST의 SHA-256 표준(FIPS 180-4)에서 확인할 수 있습니다.

import hashlib
import sqlite3
def content_hash(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
conn = sqlite3.connect("doc_hashes.db")
conn.execute(
"CREATE TABLE IF NOT EXISTS doc_hash (doc_id TEXT PRIMARY KEY, hash TEXT)"
)
def needs_reembedding(doc_id: str, text: str) -> bool:
new_hash = content_hash(text)
row = conn.execute(
"SELECT hash FROM doc_hash WHERE doc_id = ?", (doc_id,)
).fetchone()
if row is None or row[0] != new_hash:
conn.execute(
"INSERT OR REPLACE INTO doc_hash (doc_id, hash) VALUES (?, ?)",
(doc_id, new_hash),
)
conn.commit()
return True
return False
# 첫 실행
print(needs_reembedding("faq_01", "환불은 7일 이내 가능합니다.")) # True
# 내용 변경 없이 재실행
print(needs_reembedding("faq_01", "환불은 7일 이내 가능합니다.")) # False
# 내용이 바뀐 뒤 재실행
print(needs_reembedding("faq_01", "환불은 14일 이내 가능합니다.")) # True
이 코드는 문서 단위로 해시를 비교하기 때문에, 재임베딩이 필요한 문서만 골라 뒤 단계(청크 분할 → 임베딩 → 업서트)로 넘기는 필터 역할을 합니다.
LangChain SQLRecordManager로 증분 색인 구현하기
직접 해시 테이블을 관리하는 대신, LangChain이 제공하는 SQLRecordManager와 index() 함수를 쓰면 변경 감지·업데이트·삭제 처리를 한 번에 맡길 수 있습니다. 자세한 옵션은 LangChain 공식 인덱싱 가이드에서 확인할 수 있습니다.
from langchain.indexes import SQLRecordManager, index
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
collection_name = "docs_v1"
embedding = OpenAIEmbeddings()
vectorstore = Chroma(collection_name=collection_name, embedding_function=embedding)
namespace = f"chroma/{collection_name}"
record_manager = SQLRecordManager(
namespace, db_url="sqlite:///record_manager_cache.sql"
)
record_manager.create_schema()
# docs는 Document 객체 리스트, metadata에 source가 안정적인 식별자여야 함
result = index(
docs,
record_manager,
vectorstore,
cleanup="incremental",
source_id_key="source",
)
print(result)
# {'num_added': 2, 'num_updated': 3, 'num_skipped': 7, 'num_deleted': 0}
num_skipped가 7로 나온 게 핵심입니다. 문서 12건 중 내용이 그대로인 7건은 임베딩 API를 아예 호출하지 않았다는 뜻입니다. 여기서 source_id_key에 지정한 컬럼이 앞서 언급한 “안정적인 식별자” 역할을 하고, 내부적으로는 문서 내용 해시를 자동으로 계산해 이전 실행 기록과 비교합니다.
청크 단위 해시로 쪼개면 재임베딩 범위가 더 줄어듭니다
문서 단위 해시 비교의 한계는, 10페이지짜리 문서에서 한 문단만 고쳐도 그 문서에 속한 청크 전부가 재임베딩 대상이 된다는 점입니다. 청크로 쪼갠 뒤 청크마다 해시를 매기면 실제로 바뀐 청크만 골라낼 수 있어서 범위가 더 좁아집니다. 다만 청크 경계가 바뀌는 방식으로 문서를 수정하면(예: 문단 순서 변경) 뒤쪽 청크의 해시까지 연쇄적으로 달라질 수 있다는 점은 감안해야 합니다.

| 구분 | 변경 감지 단위 | 재임베딩 범위 | 삭제 처리 | 구현 난이도 |
|---|---|---|---|---|
| 감지 없음(전체 재실행) | 없음 | 컬렉션 전체 | 자동(매번 새로 씀) | 가장 낮음 |
| 문서 단위 해시 | 문서 전체 내용 | 변경된 문서에 속한 청크 전체 | 별도 로직 필요 | 중간 |
| 청크 단위 해시 | 분할 후 청크 내용 | 변경된 청크만 | 별도 로직 필요 | 중간~높음 |
SQLRecordManager(index()) |
문서/청크 내용 해시 자동 관리 | 변경분만 | cleanup 옵션으로 처리 |
낮음(라이브러리 의존) |
전체를 직접 구현하려면 청크 단위 해시 테이블에 문서 ID + 청크 인덱스를 복합키로 저장하고, 문서가 재분할될 때마다 이전 청크 ID들과 비교하는 로직이 추가로 필요합니다. 이 부분을 자체 구현하기 부담스럽다면 앞서 소개한 SQLRecordManager가 이 과정을 대신 처리해 줍니다.
cleanup=’incremental’인데 삭제된 문서가 안 지워지는 이유
index()를 cleanup="incremental"로 돌리면 내용이 바뀐 문서는 이전 버전을 지우고 새 버전으로 교체하지만, 원본 소스에서 아예 사라진 문서는 벡터 스토어에 그대로 남습니다. incremental 모드는 “이번에 넘어온 배치” 안에서만 변경분을 판단하기 때문에,애초에 넘어오지 않은 문서는 삭제 대상인지 판단할 근거가 없기 때문입니다.
이걸 해결하려면 cleanup="full"을 써야 하는데, full 모드는 반대로 트레이드오프가 있습니다. 매 실행마다 살아있는 전체 문서 집합을 빠짐없이 넘겨야 합니다. 최근 수정분만 로드해서 index()에 넘기면, full 모드는 그 배치에 없는 나머지 문서를 전부 “삭제된 것”으로 오인해 지워버립니다. 그래서 실무에서는 평소엔 incremental로 변경분만 빠르게 반영하고, 삭제 동기화가 필요한 시점(예: 하루 1회 배치)에만 전체 문서를 로드해 full 모드를 별도로 돌리는 식으로 나눠 운영하는 편이 안전합니다.
레코드 매니저부터 붙이면 오늘부터 재임베딩 범위가 줄어듭니다
문서가 바뀔 때마다 전체를 다시 임베딩하는 구조는 초기에는 편하지만, 문서 수가 늘어나는 순간부터 비용과 처리 시간이 그대로 발목을 잡습니다. 바로 적용해볼 수 있는 순서는 다음과 같습니다.
- 기존 파이프라인에서 문서 ID로 쓸 수 있는 안정적인 값(파일 경로, DB 키 등)을 먼저 정합니다.
SQLRecordManager를 붙이고cleanup="incremental"로 먼저 돌려 재임베딩 호출 수가 줄어드는지 확인합니다.- 문서당 청크 수가 많다면 청크 단위 해시 비교로 범위를 한 번 더 좁히는 것을 검토합니다.
- 삭제 동기화가 필요한 주기를 정해서, 그 시점에만 전체 문서를 로드해
cleanup="full"을 별도로 실행합니다.
이 네 단계만 적용해도 매번 전체를 훑던 색인 작업이 실제 변경분 위주로 줄어드는 걸 로그의 num_skipped 값으로 바로 확인하실 수 있습니다.
