티스토리 뷰

 

문서 검색 도구를 만드는 중에 메모리 이슈가 올라왔다. 인덱스가 크지 않은 검색 서버의 프로세스가 메모리를 3GB나 쓰고 있었다. 디스크에 있는 인덱스 파일과 맞먹는 크기니, 거의 통째로 메모리에 올라온 것이다.

 

개발환경은 비교적 작은 크기지만, 실제 환경은 문서가 지금보다 열 배쯤 많다. 그렇다면 메모리에 올라가는 인덱스는 단순 비례로 잡으면 수십 GB가 넘게 된다.

 

이 포스팅은 검색 정확도는 그대로 두고 전체 메모리를 40% 넘게 줄인 트러블 슈팅에 대한 글이다.

트러블 슈팅 내내 생각한 문제가 아닌 것이 반복됐다. 반복되는 검증 과정에서 문제를 정의해가는 과정을 정리했다.

3GB는 어디에 있었나

일단 프로세스가 시작하면서 단계별로 얼마나 늘어나는지부터 쟀다.

파이썬만                      20 MB
+ 조각 텍스트 전부           360 MB   (+340)
+ 키워드 인덱스 만들기     1,760 MB   (+1,400)
+ 벡터DB 연결              1,800 MB   (+40)
+ 조각 번호 목록           1,980 MB   (+180)
+ 첫 벡터 검색             3,030 MB   (+1,050)
+ 두 번째 벡터 검색        3,030 MB   (+0)

두 가지가 눈에 띄었다. 하나는 벡터가 연결할 때가 아니라 첫 검색 때 올라온다는 것이다.

 

연결만 하면 수십 MB인데 첫 질의에서 1GB가 붙는다. 첫 검색이 유독 느렸던 이유가 여기 있었다.

 

다른 하나는 좀 의외였는데, 제일 큰 게 벡터가 아니라 키워드 인덱스였다.

 

데이터베이스는 원래 디스크에 두고 필요한 만큼만 읽는 거 아닌가? 같은 sqlite 파일로 확인해봤다.

DB 열기                          20 MB
전체 행 세기                     23 MB
한 문서의 조각만 조회            24 MB
전체를 파이썬 목록으로 퍼올리기  240 MB

sqlite는 정상이었다. 전체 행을 다 훑어도 메모리가 3MB밖에 안 늘었다.

 

3GB는 DB 탓이 아니라, 애플리케이션이 시작할 때 전부 메모리로 옮기고 인덱스를 새로 만들기 때문이었다.

 

그렇다면 그 인덱스가 정말 그만큼의 정보를 담고 있는 걸까?

 

담아야 할 정보량을 직접 계산해봤다. (단어, 조각) 쌍마다 조각 번호에 4바이트, 등장 횟수에 2바이트씩만 잡으면 된다.

실제로 담아야 할 정보     60 MB 남짓
단어 사전까지 더해도     100 MB 미만
실제로 쓰고 있던 자리      1.4 GB

필요한 데이터의 20배가 넘었다. 코드를 보니 이유는 명확했다.

 

조각마다 "단어별 등장 횟수"를 담은 사전 객체를 따로 만들어 조각 수만큼 들고 있고, 거기에 단어별 조각 번호 목록을 또 만든다.

 

정보가 많아서가 아니라, 파이썬 객체 하나하나가 비싸서 부푼 거였다.

"디스크에 두면 느리다"는 통념

인덱스를 디스크로 옮기면 검색할 때마다 파일을 읽어야 하는데 너무 느리지 않을까?

 

그러나 이 문제는 인덱스가 없을 때의 얘기다. 인덱스가 있으면 전체를 읽는 게 아니라, 질의에 나온 단어가 적힌 자리로 바로 가서 그 부분만 읽는다. 이건 몇 KB 수준이다.

 

그리고 운영체제가 자주 읽는 부분은 알아서 메모리에 얹어준다. 이건 프로세스가 붙잡고 있는 메모리가 아니라, 부족하면 알아서 반납되는 종류다.

 

테스트를 위해 특별한 환경 설정이 필요가 없는 게, sqlite에는 전문검색 기능(FTS5)이 내장되어 있다.

FTS5(Full-Text Search)는 sqlite가 텍스트를 단어 단위로 검색하게 해주는 내장 기능이다. 원리는 앞에서 말한 그 인덱스다. '어떤 단어가 어느 행에 나오는지'를 미리 정리한 역색인을 만들어 sqlite 파일 안에 같이 저장해둔다. 그래서 "회의"로 찾으면 전체를 훑는 게 아니라 '회의'가 적힌 행으로 바로 간다. 핵심은 그 역색인이 메모리가 아니라 디스크에 산다는 것이다. 내가 파이썬으로 메모리에 통째로 들고 있던 인덱스를, sqlite가 디스크에 두고 알아서 관리해준다.

 

마침 조건도 좋았다. 색인 과정에서 형태소 분석을 끝내고 그 결과를 공백으로 이어붙인 칸을 이미 저장하고 있었다.

 

그 칸을 그대로 인덱싱하면 한국어 처리는 이미 끝난 상태로 들어간다.

CREATE VIRTUAL TABLE chunk_fts USING fts5(tokens, tokenize='unicode61');
INSERT INTO chunk_fts(rowid, tokens) SELECT id, tokens FROM keyword_chunks;

인덱스 만드는 데 6초, sqlite 파일이 5할쯤 커졌다. 늘어난 디스크만큼으로 메모리 1.4GB를 대체한다.

 

기존 검색기랑 다른 점이 하나 있긴 했다. 이 시스템은 조각마다 가중치가 다르다(제목이 포함된 조각은 점수를 두 배로 친다). 그런데 sqlite의 점수 함수는 칸별 가중치는 지원해도 줄별 가중치는 직접 지원하지 않는다. 그래서 sqlite로 후보를 넉넉히 뽑고, 최종 점수는 가중치를 곱해 다시 계산하는 방식으로 처리했다.

 

골든셋으로 성능을 확인해보면 다음과 같다.

             1위 정답률   10위 안   질의당     메모리
메모리 BM25       86%       92%    10ms대   1,300MB
디스크 FTS5       86%       92%     5ms대      20MB

동일한 성능에 키워드 인덱스만 놓고 보면 메모리가 98% 넘게 줄었다.

 

그리고 기존에는 문서가 하나 추가되면 인덱스를 통째로 다시 만드는 문제가 있었는데 이 문제도 같이 풀렸다. 수정 후에는 디스크 인덱스는 그 문서 몫만 넣거나 빼면 된다. 나머지는 파일에 그대로 남아 있고, 프로그램을 껐다 켜도 다시 만들 필요가 없다.

벡터 인덱스와 전체를 다시 재기

남은 1GB는 벡터 인덱스다. 이건 성격이 좀 다르다. 벡터 검색은 흩어진 점들 사이를 계속 오가며 비교하는 방식이라, 디스크에 두면 정말로 느려진다. 메모리에 두는 게 표준적인 선택이다.

 

숫자도 얼추 맞아떨어진다. 조각 수 × 1,024차원 × 4바이트로 잡으면 대략 930MB. 측정된 증가분과 얼추 비슷하다(나머지는 인덱스 오버헤드일 것이다). 그래서 키워드 인덱스랑은 다른 방식으로, 사용하지 않을 때 내려두는 방식으로 접근했다.

첫 검색 후          1,200 MB   (1.1초)
연결 버리고 수거 후     90 MB   (92% 반납)
다시 붙여 검색       1,200 MB   (1.2초)

검색이 유휴상태로 돌아서면 메모리에 있던 벡터 인덱스 1.1GB를 실제로 운영체제에 반납한다. 그리고 검색이 다시 시작되면 인덱스를 붙이는 데 1.2초가 걸린다.

 

놀 때 1.1GB를 아끼고, 쉬었다 처음 쓸 때 1.2초를 낸다. 사용자가 검색을 계속 하는 게 아니라 가끔 하는 도구라면, 괜찮은 방식이라 생각했다.

 

여기까지의 측정은 키워드 인덱스만 떼어낸 거였다. 이번엔 실제 서버 구성으로 두 방식을 각각 새 프로세스에 띄워, 처음부터 끝까지 다시 쟀다.

항목                  메모리 인덱스    디스크 인덱스    차이
─────────────────────────────────────────────────────────────────
시작 시간             약 17초          약 11초          35% 단축
시작 후 메모리        약 1,960 MB      약 230 MB        88% 감소
첫 키워드 검색        10 ms대          14 ms대          오차 범위
첫 벡터 검색          1.1초            1.0초            동일
이후 키워드 검색      중앙값 1.6 ms    중앙값 0.0 ms    더 빠름
검색 후 총 메모리     약 3,000 MB      약 1,300 MB      57% 감소
문서 1개 추가 반영    약 3.6초         약 0.02초        99% 단축

문제였던 전체 메모리가 절반 이하로 떨어졌고, 정확도는 앞서 골든셋으로 확인한 대로 소수점 아래까지 같다.

 

눈여겨볼 건 첫 검색 항목이다. 키워드는 10ms대와 14ms대로 사실상 같고, 진짜 지연은 벡터 쪽 1초다. 그리고 그 1초는 두 방식이 똑같다.

 

"첫 검색이 느리다"의 원인은 키워드 인덱스가 아니라 벡터를 그때 메모리에 올리기 때문이었던 것이다.

실제로 갈아끼우고 다시 재기

여기까지는 두 방식을 나란히 재보기만 한 것이었다.

 

키워드 검색을 sqlite 전문검색으로 바꾸고, 조각 본문을 통째로 들고 있던 것도 걷어내고, 조각 번호 목록도 표에서 읽게 했다.

항목              이전           이후           차이
────────────────────────────────────────────────────────
검색 후 메모리    약 3,100 MB    약 1,850 MB    41% 감소
DB 로딩           약 16초        약 1초         94% 단축
기동 전체         약 24초        약 10초        58% 단축
sqlite 파일       약 300 MB      약 460 MB      5할 증가

메모리 41% 감소를 디스크 5할 증가와 맞바꿨다.

 

남은 메모리의 대부분은 벡터 인덱스고, 키워드 쪽은 이제 문서가 늘어도 실행 중 메모리가 거의 안 는다.

 

정확도는 검색기만 떼어내 잰 것으로는 부족하다 싶어, 챗봇 전체 경로로도 A/B를 했다. 같은 프로세스에서 벡터·재정렬·필터를 전부 그대로 두고 키워드 검색기만 갈아끼우며 같은 질의를 돌렸다.

                 1위    카드3 안    검색 중앙값    메모리
──────────────────────────────────────────────────────────────
디스크 인덱스    65%    72%         약 1.2초       약 1,840 MB
메모리 인덱스    65%    72%         약 1.2초       약 3,150 MB

정답 건수까지 같았다. 속도 차이는 임베딩과 재정렬에 드는 통신 시간에 묻힌다. 키워드 검색만 놓고 보면 2배 빨라졌지만, 전체 검색 시간에서는 티가 안 나는 크기다.

 

그리고 여기서 하마터면 추가만 확인하고 끝낼 뻔했다. 파일이 수정되거나 삭제될 때도 인덱스가 따라오는지 확인해봤더니, 삭제에 구멍이 있어서 수정 작업을 추가로 진행했다. 맞춤 방식도 고쳤다. 처음엔 "새로 색인된 문서 몫만 인덱스에 더하기"로 짰는데, 그러면 삭제된 문서의 흔적이 인덱스에 남는다. DB와 인덱스의 차이를 맞추는 방식으로 바꾸니 추가·수정·삭제가 한 번에 처리됐다.

 

확인해보니 삭제 시 인덱스에서 조각이 빠지고 조각표에서도 제거되고 검색 결과에서도 사라졌다. 조각이 교체되는 경우에도 옛 항목이 빠지고 새 항목이 들어갔다.

마치며

이 작업으로 얻은 건 두 가지다. 키워드 인덱스가 더 이상 메모리에 상주하지 않는다는 것과, 문서가 바뀔 때 인덱스를 통째로 다시 만들지 않아도 된다는 것.

앞은 문서가 늘수록 같이 커지던 자리라 규모가 커지면 감당이 안 됐고, 뒤는 파일 하나 넣을 때마다 다음 검색이 멈추던 문제였다. 

대가로 sqlite 파일이 5할쯤 커졌지만, 프로세스 메모리를 40% 넘게 덜어낸 값으로는 싸다고 봤다.

이번 이슈는 데이터만 봤을 땐 "벡터DB가 무겁구나"로 끝날 뻔했다. 그리고 "디스크는 느리다"는 통념도 인덱스가 있는 경우엔 틀렸고, 오히려 빨랐다. 이 작업에서 챙긴 건 단순히 sqlite FTS5라는 도구 자체는 아니었다. 

총량 대신 단계별로, 통념 대신 실측으로. 최적화의 어려움은 어떻게 고치느냐보다 무엇이 진짜 문제인지를 알아가는 과정이 중요하다는 걸 다시 한번 배웠다.

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/08   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31
글 보관함