이번 시간에는 문서를 고친 뒤 RAG 색인을 어떻게 갱신해야 옛 내용이 남지 않는지, 청크 id를 만드는 규칙을 바꿔 가며 Qdrant로 측정하며 배워가는 시간입니다.
예를 들어서 Kafka 글의 한 문단을 고치고 RAG 색인에 다시 넣었다고 해볼게요. session.timeout.ms 기본값으로 계산한 "45초"를 "30초"로 바꾼 수정이에요.
문서를 다시 넣었으니 덮어써졌으리라 생각하기 쉽지만, 벡터 DB는 같은 id로 들어온 점만 덮어써요. 청크 id를 매번 새로 만들었다면 고친 문서는 옛 청크 옆에 하나 더 쌓일 뿐이에요.
그래서 이 사이트의 글을 문단 단위로 나눠 Qdrant에 넣고, 몇 편을 실험용으로 고친 뒤 다시 색인해 볼게요.
실습 준비
이 글의 명령은 모두 실습 코드의 rag-index-update/ 디렉터리에서 실행합니다.
compose.yaml: Qdrant와 Python 클라이언트 컨테이너 정의requirements.txt: 클라이언트 패키지prep.py: 매니페스트에 등록된 글을 읽어out/docs.jsonl로 저장하는 스크립트chunking.py: 문단 하나를 청크 하나로 나누고 내용 해시를 붙이는 모듈upd.py: 색인, 다시 넣기, 해시 동기화 같은 실습 명령을 모은 스크립트edit_questions.jsonl: 수정 확인 질문 5개questions.jsonl: 검색 질문 30개(upd.py가 시작할 때 함께 읽어요)make_edits.py: 글 여섯 편을 고친 수정본out/docs-v2.jsonl을 만드는 스크립트
임베딩 모델은 다국어 소형 모델 intfloat/multilingual-e5-small(384차원)이에요. 모델 파일은 처음 불러올 때 Hugging Face에서 받아 hf 볼륨에 저장해요.
compose.yaml은 Qdrant와 Python 클라이언트 컨테이너를 띄워요. ARTICLES_DIR에는 검색 대상으로 쓸 문서 디렉터리를 지정해 주세요.
패키지 버전은 requirements.txt에 고정해 두었어요.
--extra-index-url https://download.pytorch.org/whl/cpu
torch==2.14.1+cpu
sentence-transformers==6.1.0
transformers==5.18.0
qdrant-client==1.19.1
numpy==2.5.3
아래 블록은 파일로 만들지 말고 실습 디렉터리에서 연 터미널에 그대로 붙여 넣어 주세요. 이후 명령은 모두 이 터미널에서 실행하고, 터미널을 새로 열면 다시 붙여 넣으시면 됩니다.
mkdir -p out
export ARTICLES_DIR=/path/to/articles
dc() { docker compose -p rupd "$@"; }
py() { dc exec -T client python -u "$@"; }
dc up -d
dc exec -T client pip install -q -r requirements.txt
검색 대상 문서는 이 사이트에 발행된 글 67편(글마다 MDX 본문 하나와 그 글을 등록한 매니페스트 JSON)이라 글에 싣지 않았어요. prep.py는 매니페스트에 등록된 발행 글을 읽어 SVG 도식을 지우고 out/docs.jsonl로 저장해요.
py prep.py
문서 67편
전체 1,722,490자, 코드 블록을 뺀 산문 719,892자
가장 짧은 글 11,301자, 가장 긴 글 48,315자
청크는 문서를 검색 단위로 자른 조각이에요. 여기서는 코드 블록과 출력을 지우고, 빈 줄로 나눈 문단 하나를 청크 하나로 써요. 내용 해시는 청크 글자 그대로의 SHA-256이에요. 이 분할과 해시 계산은 chunking.py가 맡습니다.
청크마다 payload에 글 이름(slug), 글 안 순번(pos), 내용 해시(hash), 본문(text), 태그(tags)를 넣어요. 점의 id는 무작위, 글 이름과 순번, 글 이름과 내용 해시의 세 가지 규칙으로 만듭니다.
Qdrant 문서에 따르면 점 id는 64비트 부호 없는 정수나 UUID만 쓸 수 있어요. 그래서 문자열을 바로 id로 쓰지 않고 uuid5로 UUID를 만들어요. uuid5는 같은 문자열에서 늘 같은 UUID를 만듭니다.
index는 원본 문서를 청크로 나눠 한 번만 임베딩하고, 같은 벡터를 id 규칙만 다르게 해서 컬렉션 세 개(rand, seq, hash)에 넣어요. 글 이름과 순번으로 만든 seq는 이 글에서는 쓰지 않아요.
py upd.py index
문서 67편, 청크 4,041개, 한 글 안에서 내용이 같은 청크 0개
임베딩 4,041개: 331초, 모양 (4041, 384)
rand points_count 4,041
seq points_count 4,041
hash points_count 4,041
원본은 그대로 두고 make_edits.py로 고친 복사본 out/docs-v2.jsonl을 만들게요. 고친 내용은 여섯 가지예요.
- 글 A(
kafka-consumer-lag): 문단 하나의 숫자를 바꿉니다. - 글 B(
mysql-lock-wait-timeout): 앞쪽 문단 하나를 지웁니다. - 글 C(
redis-eviction): 중간에 새 문단 하나를 끼웁니다. - 글 D(
nats-jetstream): 글 전체를 지웁니다. - 글 E(
docker-image-size): 제목만 바꿉니다. - 글 F(
postgresql-explain-index): 태그만 바꿉니다.
py make_edits.py
kafka-consumer-lag: 청크 64개 -> 64개
-`session.timeout.ms`가 기본값인 45초였다면 파티션 2는 45초 동안 아무도 읽지 않았을 것입니다. 리밸런스 전후의 lag는 이렇게 잠깐 솟았다가 내려옵니다. 배…
+`session.timeout.ms`가 기본값인 30초였다면 파티션 2는 30초 동안 아무도 읽지 않았을 것입니다. 리밸런스 전후의 lag는 이렇게 잠깐 솟았다가 내려옵니다. 배…
mysql-lock-wait-timeout: 청크 46개 -> 45개
-설정한 5초를 채운 뒤에 1205가 났습니다. 잠금을 쥔 세션은 아무 일도 하지 않고 트랜잭션만 열어 두고 있었습니다. 이 세션이 끝나지 않는 한 몇 번을 다시 시도해도 같은 오…
redis-eviction: 청크 64개 -> 65개
+클라이언트 연결이 쓰는 메모리는 `maxmemory-clients`로 따로 제한합니다. 기본값 0은 제한이 없다는 뜻이고, 값을 정해 두면 합계가 그 값을 넘을 때 메모리를 가장…
원본 67편 -> 수정본 66편
글 A의 "30초"는 실험을 위해 일부러 바꾼 값이에요. Kafka 4.x의 session.timeout.ms 기본값은 원문대로 45초입니다. 글 C에 끼운 문단도 원문에 없는 내용이에요.
수정을 확인할 질문은 실험 전에 정해 edit_questions.jsonl에 넣었어요. 질문마다 수정 전 문장과 수정 후 문장에만 들어 있는 표시 문자열을 적어 두고, 검색 결과에 그 문자열이 있으면 <- 수정 전 문장, <- 수정 후 문장으로 표시해요.
- A: 죽은 컨슈머의 파티션을 기본값으로 몇 초 동안 아무도 읽지 않는지
- B: 글 B에서 지운 문단의 내용
- C: 글 C에 새로 끼운 문단의 내용
실습 1: 무작위 id로 다시 넣으면
reinsert rand는 고친 글 A, B, C를 다시 청크로 나눠 전부 임베딩하고, 청크마다 새 uuid4를 붙여 upsert해요. 글을 고칠 때마다 그 글을 통째로 다시 넣는, 가장 단순한 갱신이에요.
py upd.py reinsert rand
py upd.py ask rand A B C
kafka-consumer-lag 점 64 -> 128
mysql-lock-wait-timeout 점 46 -> 91
redis-eviction 점 64 -> 129
다시 임베딩한 청크 174개
[A] 컨슈머가 close() 없이 죽었을 때 session.timeout.ms가 기본값이면 그 파티션은 몇 초 동안 아무도 읽지 않나요?
1. 0.928 kafka-consumer-lag pos=50 `session.timeout.ms`가 기본값인 3… <- 수정 후 문장
2. 0.926 kafka-consumer-lag pos=50 `session.timeout.ms`가 기본값인 4… <- 수정 전 문장
3. 0.923 kafka-offset pos=25 실습 코드에서 `session.timeout.ms`…
4. 0.921 kafka-rebalance pos=66 타임아웃 값을 키우는 것은 다른 방법을 써 본 뒤에…
5. 0.919 kafka-at-least-once pos=12 `session.timeout.ms`를 6초로 줄인…
[B] 잠금을 쥔 세션이 트랜잭션만 열어 둔 채 아무것도 하지 않으면 1205를 다시 시도해도 같은 오류가 나나요?
1. 0.955 mysql-lock-wait-timeout pos=11 설정한 5초를 채운 뒤에 1205가 났습니다. 잠금… <- 수정 전 문장
2. 0.934 mysql-lock-wait-timeout pos=1 두 메시지 모두 `try restarting tra…
3. 0.934 mysql-lock-wait-timeout pos=1 두 메시지 모두 `try restarting tra…
4. 0.927 mysql-lock-wait-timeout pos=38 1205가 반복된다면 재시도 횟수를 늘리기보다 잠금…
5. 0.927 mysql-lock-wait-timeout pos=37 1205가 반복된다면 재시도 횟수를 늘리기보다 잠금…
[C] Redis에서 클라이언트 연결들이 쓰는 메모리 합계를 제한하는 설정은 무엇인가요?
1. 0.919 redis-eviction pos=14 클라이언트 연결이 쓰는 메모리는 `maxmemory… <- 수정 후 문장
2. 0.888 redis-vs-valkey pos=5 Redis, Valkey, Python 클라이언트 …
3. 0.882 chat-scale-out pos=53 1. WebSocket 연결은 핸드셰이크 뒤 한 T…
4. 0.881 redis-eviction pos=9 - `used_memory`: Redis가 할당자(…
5. 0.881 redis-eviction pos=9 - `used_memory`: Redis가 할당자(…
출력을 보면 세 글 모두 점 수가 원래의 두 배 가까이 됐어요. 옛 청크는 하나도 지워지지 않았고 새 청크가 그 옆에 더 들어간 거죠.
- A: 수정 후 문장(30초)과 수정 전 문장(45초)이 1위와 2위로 함께 나왔어요. 유사도도 0.002밖에 차이 나지 않아서, LLM은 30초라고 답할 수도 있고 45초라고 답할 수도 있습니다.
- B: 지운 문단이 여전히 1위예요. 글에서는 사라졌지만 색인에는 남아 있습니다.
- C: 새 문단은 1위로 들어왔지만, 4위와 5위가 같은 문단 두 벌이에요. 바뀌지 않은 문단도 옛 점과 새 점이 같은 벡터로 하나씩 있어서, 상위 결과 자리를 중복이 차지해요.
C처럼 새 내용을 찾는 질문은 겉으로 잘 동작하는 것처럼 보여요. 문제는 A와 B처럼 사라져야 할 내용을 묻는 질문에서만 드러나서, 검색 결과만 봐서는 알아채기 어렵습니다.
실습 2: 내용 해시로 바뀐 청크만
이번에는 id를 글 이름:내용 해시로 정해 볼게요. 그러면 문단 내용이 같은 한 id도 같아요. 순번이 밀려도 id는 바뀌지 않죠. 해시 id에 글 이름을 함께 넣은 건, 다른 글에 똑같은 문단이 있어도 서로 덮어쓰지 않게 하기 위해서예요.
그래서 글 단위로 두 해시 집합을 비교하는 것만으로 갱신이 끝나요.
- 새 해시 집합에만 있는 해시: 새로 생기거나 고친 문단이에요. 이것만 임베딩해서 upsert합니다.
- 저장된 해시 집합에만 있는 해시: 지우거나 고치기 전 문단이에요. 점 id로 지웁니다.
- 양쪽에 다 있는 해시: 아무것도 하지 않아요.
upd.py의 sync_doc이 글 하나에 대해 이 비교를 하고, sync는 수정본의 모든 글에 sync_doc을 돌려요. 바뀌지 않은 글도 빠짐없이 비교하고, 아무 일도 하지 않은 글은 개수만 셉니다.
py upd.py sync
py upd.py ask hash A B C
kafka-consumer-lag 추가 1 삭제 1 유지 63
mysql-lock-wait-timeout 추가 0 삭제 1 유지 45
redis-eviction 추가 1 삭제 0 유지 64
바뀌지 않은 글 63편, 다시 임베딩한 청크 2개, 걸린 시간 0.6초
[A] 컨슈머가 close() 없이 죽었을 때 session.timeout.ms가 기본값이면 그 파티션은 몇 초 동안 아무도 읽지 않나요?
1. 0.928 kafka-consumer-lag pos=50 `session.timeout.ms`가 기본값인 3… <- 수정 후 문장
2. 0.923 kafka-offset pos=25 실습 코드에서 `session.timeout.ms`…
3. 0.921 kafka-rebalance pos=66 타임아웃 값을 키우는 것은 다른 방법을 써 본 뒤에…
4. 0.919 kafka-at-least-once pos=12 `session.timeout.ms`를 6초로 줄인…
5. 0.916 kafka-retry-dlq pos=10 컨슈머는 파티션의 메시지를 offset 순서대로 하…
[B] 잠금을 쥔 세션이 트랜잭션만 열어 둔 채 아무것도 하지 않으면 1205를 다시 시도해도 같은 오류가 나나요?
1. 0.934 mysql-lock-wait-timeout pos=1 두 메시지 모두 `try restarting tra…
2. 0.927 mysql-lock-wait-timeout pos=38 1205가 반복된다면 재시도 횟수를 늘리기보다 잠금…
3. 0.925 mysql-lock-wait-timeout pos=27 1213을 받으면 트랜잭션이 이미 롤백됐으므로 처음…
4. 0.917 mysql-online-ddl-mdl pos=72 1. ALTER를 실행할 세션에서 `SET SESS…
5. 0.915 mysql-lock-wait-timeout pos=26 마지막 ROLLBACK이 있는데도 mouse 재고가…
[C] Redis에서 클라이언트 연결들이 쓰는 메모리 합계를 제한하는 설정은 무엇인가요?
1. 0.919 redis-eviction pos=14 클라이언트 연결이 쓰는 메모리는 `maxmemory… <- 수정 후 문장
2. 0.888 redis-vs-valkey pos=5 Redis, Valkey, Python 클라이언트 …
3. 0.882 chat-scale-out pos=53 1. WebSocket 연결은 핸드셰이크 뒤 한 T…
4. 0.881 redis-eviction pos=9 - `used_memory`: Redis가 할당자(…
5. 0.881 chat-scale-out pos=25 운영 중에는 서버와 Redis 사이 연결이 끊길 수…
출력을 보면 글 A는 추가 1, 삭제 1, 글 B는 삭제만 1, 글 C는 추가만 1이 나왔고, 나머지 63편은 아무것도 하지 않았어요. 다시 임베딩한 청크는 2개예요.
검색 결과에서도 옛 문단이 사라졌어요. A 질문에는 수정 후 문장만 나오고, B 질문의 상위 5개에는 지운 문단이 없고, C 질문의 결과에는 중복이 없습니다.
실습 3: 전체 다시 임베딩과 비교
같은 수정본으로 전체를 처음부터 다시 임베딩해 새 컬렉션 fresh를 만드는 것과 시간을 비교해 볼게요.
sync는 reset으로 hash 컬렉션을 실습 준비에서 처음 색인한 벡터로 되돌린 뒤 다시 돌렸고, 둘 다 세 번씩 쟀어요. reset 출력 hash 되돌림: points_count 4,041은 뺐습니다(발췌).
for i in 1 2; do py upd.py reset; py upd.py sync; done
for i in 1 2 3; do py upd.py full; done
바뀌지 않은 글 63편, 다시 임베딩한 청크 2개, 걸린 시간 0.5초
바뀌지 않은 글 63편, 다시 임베딩한 청크 2개, 걸린 시간 0.3초
수정본 전체 다시 임베딩: 청크 3,978개, 임베딩 281초, 넣기까지 282초
수정본 전체 다시 임베딩: 청크 3,978개, 임베딩 304초, 넣기까지 306초
수정본 전체 다시 임베딩: 청크 3,978개, 임베딩 282초, 넣기까지 284초
- 해시 비교: 청크 2개를 임베딩했고, 66편의 해시를 읽어 비교하는 시간까지 0.3~0.6초예요.
- 전체 다시 임베딩: 청크 3,978개를 임베딩하는 데 281~304초, Qdrant에 넣기까지 282~306초예요.
fresh의 청크 수가 3,978개인 건 수정본에서 글 D가 빠졌기 때문이에요. hash 컬렉션에는 아직 글 D가 남아 있어요. sync는 수정본에 있는 글만 돌기 때문에, 수정본에서 사라진 글은 건드리지 않습니다.
정리
범위: Qdrant v1.19.1, intfloat/multilingual-e5-small(384차원), 이 사이트 글 67편을 나눈 문단 청크 4,041개, 노트북 Docker의 CPU 2개, 시간 비교는 각 3회.
- 청크마다 새
uuid4를 붙여 고친 글 세 편을 다시 넣자 점 수가 두 배 가까이 됐고, A 질문에 수정 후 문장(30초)과 수정 전 문장(45초)이 1위와 2위로 함께 나왔으며 B 질문에는 지운 문단이 여전히 1위였어요. 청크 id를 넣을 때마다 새로 만들지 마세요. 글 이름:내용 해시id로 글마다 해시 집합을 비교하자 다시 임베딩한 청크가 2개, 걸린 시간이 0.3~0.6초였고, 전체를 다시 임베딩하면 3,978개에 281~304초였어요. 청크 id는 글 이름과 내용 해시로 만들고 바뀐 청크만 임베딩하세요.sync는 수정본에서 사라진 글 D를 건드리지 않았어요. 이 글에 싣지 않은 추가 측정에서는 매니페스트와 비교해slug필터로 지우자 점 63개가 지워졌어요. 사라진 글은 매니페스트와 색인의 글 목록을 따로 비교해 지우세요.