20,000개 문서 앞에서 설계가 무너진 날
앞 편에서, 개인 연구용 세컨드브레인을 벡터 RAG 없이 시작한 이야기를 썼다. 소스가 들어올 때 LLM 이 한 번 읽고 링크 걸린 마크다운으로 다시 쓰는 방식(Karpathy 의 LLM-Wiki 패턴)이고, 근거 중 하나가 "개인 규모에서는 사전합성이 RAG 를 이긴다" 였다. 2026년 6월 2일에 그게 엔드투엔드로 돌아가는 걸 확인했다.
같은 날 설계가 깨졌다. 이 편은 그 이야기고, 시리즈에서 제일 중요한 편이라고 생각한다.
산수 한 줄로 끝났다
구글드라이브에는 대학 두 곳에서 일하며 만든 자료가 예전부터 쌓여 있었다. 브레인에 넣을 대상으로 그 폴더를 지정하고, 규모를 세어봤다.

폴더 두 개를 합쳐 문서 20,500개에, 약 53 GiB 였다. 이하 이 글에서는 약 2만 개라고 부르자. 앞 편에서 내가 적어둔 전제는 "수백~수천 개" 였는데, 그림의 빨간 선이 그 상한이고 실제 규모는 그 선을 한참 넘어간 곳에 있었다.
LLM-Wiki 패턴은 문서 하나당 LLM 합성 한 번이다. 소스를 읽고, 요약하고, 링크를 뽑아, 마크다운을 생성하는 일이라 짧게 끝나지 않는데, 당시 이 박스에서 잰 문서당 합성 시간이 약 70초였다. 20,500 곱하기 70초는 1,435,000초, 그러니까 16.6일이다.

이 그림을 그리다가, 내 초고가 틀렸다는 걸 알았다. "수천 개면 하룻밤"이라고 적어뒀는데 5,000개는 이미 4.1일이고, 하룻밤 안에 끝나는 건 1,000개 수준까지다. 문서당 70초에서 하루를 넘기지 않는 한계는, 정확히 1,234개다. 비용이 개수에 정비례하니, 이런 감각은 금방 어긋난다.
16.6일은 그것도 연속으로 16일이고, 그 동안 같은 vLLM 이 다른 일을 못 한다. 브레인의 다른 기능도 그 뒤에 줄을 서고, 회의록 정리도 서고, 논문 요약도 서고. 증분으로 나눠 돌린다 해도 초기 적재를 끝내지 못하면, 시스템 자체가 시작되지 않는다.
여기서 중요한 건, 16일이 "느리다"는 문제가 아니라는 점이다. GPU 를 하나 더 붙이면 8.3일이 되지만, 그래도 답이 아니다. 절반으로 줄여도 여전히 일주일을 넘고 네 배를 붙여도 나흘이니, 깨진 것은 속도가 아니라 전제였다.
문서당 LLM 1회라는 비용 구조는 소스 개수에 정비례한다. 천 개에서는 하룻밤이고, 2만 개에서는 시스템이 성립하지 않는다. 같은 설계가 어느 구간에서는 최적이고 어느 구간에서는 무의미한데, 나는 그 경계를 넘었다는 걸 폴더 크기를 세고 나서야 알았다.
위키를 버리지 않고 층을 하나 얹었다
선택은 두 가지였다. LLM-Wiki 를 버리고 표준 RAG 로 전면 전환하거나, 위키는 남기고 대규모 소스를 감당할 검색 계층을 따로 얹거나.
두 번째를 골랐다. 위키가 사람이 읽는 층으로는 여전히 잘 작동했고, 링크를 따라가며 브라우징하는 건 벡터 검색이 대체할 수 없기 때문이다. 반대로 2만 개 원본 문서에서 "그 숫자가 어디 있었지"를 찾는 일은, 위키가 못 한다. 용도가 다르니 층을 나누는 게 맞다고 판단했다.

임베딩은 fastembed 로 intfloat/multilingual-e5-large 를 썼다. 1024차원에 ONNX 런타임에서 CPU 로 돌린다. 벡터DB 는 도커로 띄운 Qdrant 를 쓰고, 처리 완료분은 매니페스트 파일에 기록해서 증분으로만 색인하게 했다.
CPU 로 임베딩한 이유는, 앞 편의 그 119GB 다. GPU 메모리에 임베딩 모델을 얹을 자리가 없었고, ONNX/CPU 로 돌리면 느리지만 20 코어가 놀고 있었으니 그쪽이 현실적이었다.
e5 를 고른 이유는 한국어였다. 자료가 대부분 한국어인데, 당시 실측에서 한국어 코사인 유사도가 0.8 수준으로 나왔고, 나는 그걸 메모에 장점으로 적어뒀다.
이 문장이 나중에 이 시리즈에서 제일 아픈 대목이 된다. 미리 한 줄만 적어두면, 무관한 문서끼리도 0.8 이 나온다는 건 변별력이 없다는 뜻이었다. 당시엔 한국어를 잘 이해한다는 신호로 읽었다. 5부에서 수치와 함께 다시 나온다.
e5 는 접두사도 요구한다. 질의에는 query:, 문서에는 passage: 를 붙여야 하는데, 안 붙이면 조용히 품질이 떨어진다. 에러가 나지 않으니, 더 위험하다.
같이 미룬 것도 적어두자. 진짜 그래프 추출(LightRAG 류)은 이때 접었다. 엔티티와 관계를 뽑으려면 청크마다 LLM 을 호출해야 하는데, 그건 문서당 70초와 정확히 같은 벽이고, 청크는 문서보다 많으니 더 심하다. 대신 투입 대비 회수가 가장 큰 벡터 의미검색만 먼저 넣었다. 색인된 게 하나도 없는 동안은 예전 위키 어휘검색으로 폴백하게 했는데, 새 계층이 비어 있는 기간에도 시스템은 동작해야 하니까.
곁가지 — 임베딩 데몬을 따로 띄운 이유
이건 같은 걸 만드는 사람에게 바로 쓸모있을 것 같아서, 따로 적어둔다.
brain query 를 처음 구현할 때는, 질의가 들어오면 임베딩 모델을 로드해 질의를 벡터로 바꾸고 Qdrant 에 던졌다. 문제는 로드 시간이었다. 모델이 약 2GB 인데 올리는 데 170초가 걸리고, 정작 질의 자체는 1초도 안 걸린다.

질문 하나에 3분을 기다리는 도구는, 도구가 아니다. 그래서 임베딩만 담당하는 상주 데몬을 따로 뒀다. 부팅 때 한 번 모델을 올린 뒤 죽지 않고 떠 있으면서 색인과 질의 양쪽에 HTTP 로 벡터를 내준다.
교훈은 단순하다. 모델 로드 시간이 요청 처리 시간보다 크면, 프로세스 경계를 다시 그려야 한다. CLI 한 방에 다 넣고 싶은 마음이 들지만 로드가 170초면 그 구조는 성립하지 않는다. 그리고 이 판단은 앞 편의 메모리 제약과 붙어 있다. 상주 데몬을 두려면 메모리에 계속 앉아 있을 자리가 필요하고, 그 자리를 어디서 뺏어올지가 결국 같은 문제였다.
조건부 명제를 조건 없이 저장했다
정리하면 이렇다.
"사전합성이 RAG 를 이긴다"는 규모 조건부 명제였다. 나는 조건을 근거 문단에만 적고, 설계 문서에는 적지 않았다.
기술 결정을 기록할 때 우리는 보통 결론을 적는다. "벡터DB 없이 간다." 그리고 그 결론이 왜 맞는지 근거를 붙인다. 그런데 근거 안에 조건이 숨어 있으면, 그 조건은 결론과 함께 저장되지 않아서, 나중에 문서를 다시 읽는 사람은 — 그게 3주 뒤의 나였다 — 결론만 본다.
지금은 설계 결정을 적을 때, 이런 줄을 같이 적는다.
유효 범위: 소스 1,200개 이하
무효화 조건: 소스 개수 × 문서당 합성 시간 > 24시간
두 줄이 같은 수치를 가리켜야 한다. 처음 이 규칙을 적을 때 나는 유효 범위에 5,000개라고 써놓고 무효화 조건은 24시간으로 뒀는데, 5,000개는 4일이니 두 줄이 서로를 부정한다. 이 편의 그림을 그리다가 그걸 발견했다. 수치를 도표로 옮기면, 앞뒤가 안 맞는 게 눈에 보인다.
조건이 아니라 무효화 조건으로 적는 게 핵심이다. "언제까지 맞다" 보다 "무엇이 관측되면 틀린 것이다" 가, 확인 가능한 문장이기 때문이다. 이건 나중에 이 시리즈의 관통 주제가 된다. 선언을 적을 때 그 선언이 깨졌는지 확인하는 방법을 같이 적어두지 않으면, 선언은 그냥 오래된 낙관으로 남는다.
후일담 — 곁가지가 본체가 됐다
한 달 뒤 시스템을 재설계하면서, 확인한 사실이 있다.
의미검색이 실제 백본이었다. 색인 작업은 원본 소스를 임베딩하고 있었고 LLM 이 재합성한 위키는 색인에서 명시적으로 제외돼 있었으니, 질문에 답할 때 근거로 쓰인 건 위키가 아니라 원본 소스 쪽이었다. 그래서 재설계에서 자동 위키는 동결하고, 읽기전용 유물로 남겼다. 급하게 얹은 곁가지가 본체가 되고, 원래 본체가 유물이 된 셈이다.
이게 앞 편에서 내가 자랑했던 것과 어긋난다는 점도 적어두자. 나는 lint 로 위키를 점검할 수 있다는 걸 이 설계의 장점으로 꼽았는데, 실제로 답의 근거가 된 층은 위키가 아니라 벡터 쪽이었고 그쪽에는 점검할 방법이 없었다. 점검 가능한 층은 점검했고, 점검할 수 없는 층이 정작 일을 하고 있었다.
정직하게 하나 더. 이때 얹은 벡터 계층 자체도, 결국 망가진 채로 몇 주를 돌고 있었다. 위에서 장점이라고 적어둔 그 코사인 0.8 이 원인이었고, 그 진단은 5부에서 수치와 함께 쓴다.
다음 편에서는 그 2만 개를 실제로 읽어들이는 이야기를 해 보자. 한국 대학 자료의 파일 포맷은, 상상보다 더 난장판이다.