브레인이 커지자, 문제는 검색이 아니라 토큰이었다
질문 하나에 문서 다섯 편이 통째로 컨텍스트에 부어지는 것을 보고 있었다. 에이전트가 팀의 공유 브레인에서 답을 찾는 경로는 이랬다. 질문에서 검색어를 뽑아 마크다운 파일을 grep 하고, 많이 맞은 순서로 상위 다섯 편을 열어 정답을 만날 때까지 읽는다. 그 경로를 기계적으로 모사해 재 보니 질문 하나에 평균 9,391 토큰이 들었다. 답은 대개 그 안에 있었다. 문제는 답이 있느냐가 아니라, 답 한 줄을 꺼내는 데 문서 다섯 편어치를 태운다는 것이었다.
지난 글에서 나는 문서 더미가 뇌를 닮는 것은 모든 생각이 여러 방향에서 닿고 그 상태가 엔트로피 아래에서 유지될 때라고 썼다. 그래프는 공짜로 따라오고 그것을 연결된 채로 지키는 일이 진짜 작업이라는 이야기였다. 그런데 그 글을 쓰고 두 달 사이에, 유지보다 먼저 도착한 문제가 있었다. 규모다.
브레인은 두 달 만에 문서 63편에서 242편이 됐다. 편수가 네 배가 되는 동안 브레인을 다루는 비용도 같이 네 배가 됐다. 지식을 쌓는 것이 목적인 시스템이 쌓을수록 다루기 어려워진다면, 그건 성장통이 아니라 설계 결함이다.
비용이 문서 수에 비례하고 있었다
파일 브레인에서 핵심 동작 셋이 전부 O(전체 문서) 였다.
찾기 는 방금 말한 그대로다. 파일명과 본문을 grep 하고 후보를 통째로 읽는다. 잇기 는 더 나빴다. 링크 그래프를 갖고 있지 않으니 린터가 매번 전 문서를 파싱해 그래프를 처음부터 재구축했고, 엮을 후보를 고르는 것도 문자열 훑기였다. 겹침 막기 — ‘이미 같은 주제를 다룬 문서가 있으면 새로 만들지 말고 갱신하라’ 는 규칙은 확인 수단이 grep 뿐이라, 표현이 조금만 달라도 못 잡았다. 반쯤만 지켜지던 규칙이다.
242편이면 아직 견딘다. 1,000편이면 셋 다 무너진다. 그리고 이런 것을 고치기 가장 싼 시점은 무너지기 전이다.
기준선은 상대에게 유리하게 박는다
착수 전에 넘어야 할 선을 먼저 못박기로 했다. 판정 기준은 하나로 뒀다 — grep 보다 나은가. 질문 31개를 만들고, 정확한 식별자를 찾는 질문부터 서술형 질문까지 섞고, 검색어는 손으로 골라 grep 에게 유리하게 줬다. 넉넉하게 잡은 기준선을 이겨야 값을 번 것이다.
결과가 recall@5 90% 로 나왔다. 예상보다 훨씬 높았다. 그 앞에서 잠깐 물러섰다가, 실패 문항을 뜯어보던 중에 90% 의 정체를 알았다. 내가 손으로 준 검색어가 정답 문서의 제목 그 자체였다. ‘한 줄에 데이터를 얼마나 담을 수 있나’ 라는 질문에 나는 로우·길이·상한 을 검색어로 줬고, 정답 문서의 제목은 「로우 길이 상한」이었다. 기준선에 정답을 유출한 것이다.
그래서 두 번째 기준선을 만들었다. 검색어를 사람이 주지 않고 질문에서 기계적으로 뽑는다. 재현율이 55% 로 떨어지고 토큰은 27,197 로 뛰었다. 실제 에이전트는 이 둘 사이 어딘가에 있다.
교훈은 이 프로젝트를 넘어서 걸린다. 기준선을 자기에게 유리하게 잡으면 이겨도 아무것도 증명하지 못하고, 상대에게 유리하게 잡으면 이길 수 없어 보인다. 유리하게 잡는 쪽이 옳되, 왜 유리한지를 끝까지 캐야 한다. 그 수치가 실력인지 유출인지는 대개 실패 문항이 아니라 성공 문항 안에 있다.
승부처는 재현율이 아니라 반환 단위였다
Postgres 에 청킹과 역색인을 얹고 같은 자로 다시 쟀다.
| grep ① 검색어를 손으로 | grep ② 질문에서 기계 추출 | 렉시컬 색인 | |
|---|---|---|---|
| recall@5 | 90% (정답 유출) | 55% | 77% |
| 문항당 토큰 | 9,391 | 27,197 | 1,692 |
| 검색 지연 | — | — | 12ms |
재현율만 보면 색인은 유출된 기준선에 진다. 승부는 같은 표의 다른 줄에서 갈렸다. 1,692 토큰. 열여섯 배다.
이유는 랭킹이 아니라 반환 단위에 있다. grep 의 반환 단위는 파일이라 3천 자짜리 문서에서 한 문단이 필요해도 3천 자를 준다. 색인의 반환 단위는 청크와 그 청크가 문서 안에서 어느 헤딩 아래 있었는지다. 에이전트는 문서를 여는 대신 문단과 그 좌표를 받고, 정말 필요할 때만 문서를 편다.
여기서 습관 하나가 깨졌다. 검색 품질을 재현율로만 재던 습관이다. 사람이 쓰는 검색은 과잉 반환이 거의 공짜다 — 눈으로 훑고 버리면 그만이다. 에이전트가 쓰는 검색은 반환된 것이 전부 컨텍스트에 실리고, 실린 것은 값을 치른다. 그러니 지표가 하나 더 필요하다. 맞췄는가가 아니라, 맞추는 데 얼마를 썼는가.
임베딩을 뺐더니 전부 좋아졌다
당연히 벡터 검색을 넣었다. 의미 검색 없이 어휘 불일치를 어떻게 뚫겠는가.
채널을 통째로 끄고 다시 쟀더니 결과가 완전히 같았다. 재현율 77% 로 동일했고, 실패한 문항의 집합까지 같았다. 정답 문서의 코사인 거리가 0.49에서 0.84 사이에 뭉쳐 있었다. 모델이 한국어 기술 문서를 변별하지 못한 것이다. 어휘 불일치가 벽이 되는 것은 질의가 영어이고 문서가 한국어일 때이지, 한국어 문서를 한국어로 찾을 때가 아니었다. 게다가 브레인은 링크 규율 때문에 제목과 파일명이 유난히 서술적이다. 규율을 지킨 대가를 어휘 검색이 먼저 받아먹고 있었다.
빼자 나머지가 전부 좋아졌다. 전량 색인이 594초에서 1.9초로, 검색 지연이 83ms 에서 12ms 로, 상주 메모리가 300MB 에서 0이 됐다. 벡터 확장도, 임베딩 모델도 필요 없었다. 순정 PostgreSQL 하나면 됐다.
한국어 형태소 확장도 쓰지 않는다. 색인기가 렉심을 직접 만들어 파서를 우회한다.
-- The default parser splits identifiers on underscores,
-- so an exact-symbol query never lands on the document that defines it.
SELECT to_tsvector('simple', 'pthread_mutex_lock');
-- 'lock':3 'mutex':2 'pthread':1
-- The indexer emits lexemes itself: identifiers stay whole,
-- Korean is expanded into syllable bigrams. No parser, no extension.
SELECT array_to_tsvector(ARRAY['pthread_mutex_lock', '잠금', '금해', '해제']);그리고 뺀 자리에서 복잡도가 하나 더 사라졌다. 전량 재색인이 1.9초면 증분 갱신 로직 자체가 필요 없다. 저장할 때마다 통째로 다시 만들면 된다. 최적화하지 않아도 되는 상태를 최적화가 아니라 삭제로 얻은 셈이다.
정본이 옮겨오면 ‘재구축’ 은 지식 삭제다
여기까지는 색인이 파생물이었다. 정본은 여전히 git 의 마크다운이었고, 그래서 스키마 첫 줄에 이렇게 적혀 있었다. ‘색인은 파생물이라 통째로 재구축한다.’ DB 가 통째로 날아가도 push 한 번이면 복구되니 안전한 문장이었다.
정본을 DB 로 올리는 순간 그 문장이 위험한 거짓말이 된다. DROP 후 CREATE 는 더 이상 색인 재구축이 아니라 지식 삭제다. 그래서 층을 갈랐다. 본문과 이력은 정본이고, 청크·역색인·링크는 본문에서 언제든 다시 만드는 파생이다. 스키마에서 DROP 을 걷어내고, 재유도 경로는 파생물만 손대게 했다. 이관 API 는 전량 재구축에서 upsert 전용으로 강등했다 — 페이로드에 없는 문서는 건드리지 않는다.
git 이 공짜로 해주던 일도 직접 해야 한다. 매 쓰기마다 직전 본문을 리비전으로 남기고, 삭제는 소프트 삭제로 되돌릴 수 있게 했다. 백업은 이 시점부터 선택이 아니라 전제다. 매일 덤프를 떠 외부 저장소로 올리고, 복원까지 실제로 해 봐야 백업이라 부를 수 있다.
이 대목에서 한 번 헛디뎠다. 검색 서비스가 문서를 마크다운으로 다시 내보내는 ‘거울’ 을 만들어 뒀는데, 그것이 슬그머니 백업으로 읽히기 시작했다. 거울은 백업이 아니다. 리비전을 담지 못해 누가 언제 왜 바꿨는지가 복원되지 않고, 되돌아오는 경로는 사고를 겪은 뒤 일부러 막아 둔 상태였다. 복원 경로가 없는 것을 백업이라 부를 수는 없다. 결국 거울을 폐기했는데, 그때 드러난 사실이 더 아팠다. 거울 생성이 매시간 실패하고 있었고 그 실패를 사흘 뒤에야 발견했다. 아무도 복원해 본 적 없는 사본은 조용히 죽어 있어도 티가 나지 않는다.
링크도 그제야 DB 답게 굴기 시작했다. 파일 브레인에서 링크가 이름과 경로를 가리켰던 이유는 파일에게 그것 말고 정체성이 없어서였다. DB 문서에는 불변 키가 있다. 문서를 옮기면 옛 주소가 별칭으로 남아 남이 써 둔 링크가 계속 닿는다. 파일이었다면 링크를 200군데 고치거나 깨진 채 두는 수밖에 없던 일이다. 덤으로 오래된 버그도 드러났다. 소프트 삭제된 문서가 그래프에 간선을 계속 내고 있어서, 끊긴 링크가 411건으로 부풀어 있었다. 실제로 끊긴 것은 14건이었다.
사람과 에이전트가 같은 랭킹을 친다
마지막 단계는 이 저장소를 MCP 서버로 감싸 팀 전체에 연 것이다. 에이전트가 쓰는 동사는 여섯이다. 검색하고, 문서 하나를 링크·역링크와 함께 열고, 변경 이력을 보고, 정합성을 재고, 쓰고, 옮긴다. 삭제는 없다 — 아카이브로 옮기는 것이 삭제를 대신한다.
한 가지는 처음부터 못박았다. 사람이 브라우저에서 치는 검색창과 에이전트의 검색 도구가 같은 경로를 친다. 랭킹이 두 벌이면 사람이 본 순서와 에이전트가 본 순서가 갈리고, 그때부터 둘은 같은 브레인을 쓰는 것이 아니다.
열고 두 주가 지난 지금 브레인은 문서 559편, 저장소 여덟 곳이다. 두 달에 걸쳐 242편이 된 것이 두 주 만에 다시 두 배가 됐다. 가장 빨리 늘어난 폴더는 예상대로 트러블슈팅이었다. 로그가 비어 보이는데 실은 실패가 아니었던 진단, 재부팅 뒤에만 재현되는 경합, 배포 키가 가려서 나던 푸시 거부 — 하나같이 처음 만난 사람은 반나절을 태우고 두 번째 만난 사람은 이름조차 모르는 것들이다.
효율의 정체는 화려하지 않다. 같은 질문을 두 번 조사하지 않는 것 하나다. 어제 옆자리에서 사흘을 태운 장애가 오늘 내 세션에서 12ms 에 문단 하나로 나온다. 이슈 처리에서 실제로 시간을 먹는 것은 고치는 일이 아니라 ‘이거 전에 누가 겪지 않았나’ 를 확인하는 일인데, 그 확인이 grep 이던 시절에는 확인 비용이 그냥 조사하는 비용보다 비쌌다. 그래서 아무도 확인하지 않았다.
마무리
지난 글의 결론은 유지였다. 그래프를 연결된 채로 지키는 일이 진짜 작업이라고 썼고, 지금도 그 문장을 고칠 생각은 없다. 다만 그 뒤에 배운 것이 하나 있다. 인출이 비싸면 유지할 이유가 사라진다. 아무도 꺼내 쓰지 않는 그래프를 누가 손으로 엮겠는가. 링크는 읽을 때의 이득을 위해 쓸 때 치르는 비용인데, 읽을 때의 이득이 토큰에 잡아먹히면 그 거래는 성립하지 않는다.
돌아보면 이 작업에서 실제로 한 일의 목록은 대부분 뺀 것이다. 임베딩을 뺐고, 검색 확장을 뺐고, 증분 갱신 로직을 뺐고, 백업인 척하던 거울을 뺐다. 남긴 것은 셋뿐이다. 정본과 이력, 그리고 랭킹 한 벌.
지금 브레인은 문서가 늘어도 질문 하나의 비용이 늘지 않는다. 559편에서 1,000편이 되어도, 에이전트가 답 하나에 치르는 값은 문단 몇 개어치로 같다. 앞 글에서 나는 그래프가 공짜로 따라온다고 썼는데, 이제는 한 줄을 덧붙이고 싶다. 그래프가 공짜인 것은 맞지만, 거기서 무언가를 꺼내는 값은 공짜가 아니고, 그 값을 상수로 만드는 것이 지식 시스템의 설계 문제다. 문서가 늘수록 나빠지던 시스템이 이제 문서가 늘수록 좋아진다. 바뀐 것은 규율이 아니라 비용 구조 하나다.
댓글 0