-
긴 기억은 왜 벡터 검색에서 지는가: 평균 풀링의 산수와 max-sim
MEMENTO 2026. 11. 7. 09:11반응형에이전트의 기억 저장소에서 가장 값진 기록은 대개 길다. 장애 회고, 런북, 며칠에 걸친 디버깅 기록은 결론과 근거를 함께 담느라 길어진다. 오픈소스 프로젝트 Memento의 벡터 검색에서 이런 글이 구조적으로 밀렸다. 내용이 틀려서도, 주제가 어긋나서도 아니었다.
Memento의 검색 품질 벤치마크 코퍼스(benchmark-v3) 3,461건에서 질의
HTTP 서버 에러 처리의 코사인 바닥값은 10위 0.5944, 40위 0.4416이었다. 이 코퍼스에서 질의와 가장 비슷한 문서 60건을 골라 12,000자로 이어 붙이고 코사인을 쟀다. 결과는 0.3094, 40위 밖이다. 가장 관련 높은 내용만 모아도 길면 졌다.요약
- 윈도를 평균 낸 문서 벡터의 점수는 윈도별 코사인의 평균에 비례한다. 윈도 하나만 질의와 맞으면 그 기여가 약 1/N로 줄어든다.
- 긴 문서를 앞에서 자르던 시절보다 평균 풀링이 낫다는 측정과, 평균 풀링 탓에 긴 문서가 경쟁에서 진다는 측정은 둘 다 참이다. 묻는 질문이 달랐다.
- max-sim으로 바꾸자 정답 순위 3,409위 문서가 35위로 올랐다. 인덱스 행은 4.45% 늘었다.
- 벡터 프리페치를 깊게 잡는 변경은 단독으로 순손실이다. max-sim과 묶어야 벡터 채널 MRR@20이 0.5324에서 0.5830으로 오른다.
- 코사인 절댓값은 길이가 다른 문서 사이에서 믿기 어렵다. 판단은 코퍼스 순위로 내려야 한다.
절단을 고친 측정도 틀리지 않았다
이 문제의 출발점은 다른 버그의 수정이었다. Memento의 MiniLM 임베딩 서비스는 원래 본문을
substring(0, 1024)로 잘랐다. 영어 기준 휴리스틱으로는 안전해 보였지만 한국어는 토큰 밀도가 달라, 긴 기억의 뒷부분은 어떤 질의로도 벡터 레인에서 찾을 길이 없었다.수정 방향은 토크나이저로 510토큰 윈도를 나눠 각각 임베딩하고, 평균을 내어 다시 정규화하는 방식이었다. 문서 하나가 벡터 하나라는 구조를 지키므로 스키마와 검색 경로를 건드리지 않아도 됐다. 문서를 청크로 나눠 벡터를 여러 개 두는 안은 유사도 이득이 더 컸지만(운영 DB의 긴 기억 8건 기준 0.6519) 당시에는 스키마 변경 부담 때문에 밀렸다.
당연히 따라올 반론도 재 봤다. 평균을 내면 앞부분이 흐려지지 않느냐는 우려다. 같은 8건에서 뒤쪽 내용을 묻는 질의의 코사인은 0.4369에서 0.5724로, 앞쪽 내용을 묻는 질의도 0.4494에서 0.4781로 올랐다. 당시 기록은 "희석은 일어나지 않는다"고 적었다.
며칠 뒤 다른 조사에서 긴 문서가 벡터 검색 상위에 거의 오르지 못한다는 사실이 드러났다. 얼핏 앞선 결론을 뒤집는 발견처럼 보이지만 그렇지 않다. 앞선 측정은 같은 문서의 절단본과 평균본을 비교했고, 평균이 절단보다 나은지를 물었다. 뒤의 측정은 서로 다른 문서를 한 코퍼스에서 줄 세웠고, 긴 문서가 짧은 문서와 경쟁이 되는지를 물었다. 평균 풀링은 긴 문서의 코사인을 자기 자신의 절단본 대비 끌어올렸다. 그래도 짧은 문서들이 차지한 대역에는 닿지 못했다. 문서 안에서 비교하는 실험은 이 문제를 원리상 보여 주지 못한다.
그래서 해법의 후보에서 절단 복귀는 처음부터 빠졌다. 뒷부분을 다시 검색 불가능하게 만들기 때문이다.
평균이 깎는 점수의 정체
긴 문서가 왜 지는지는 짧은 식 하나로 설명된다. 윈도 벡터
w1 ... wN과 질의 벡터q가 모두 단위 벡터이고 윈도 평균을m이라 하면 다음이 성립한다.평균 풀링 점수 = cos(q, m / |m|) = ( cos(q, w1) + ... + cos(q, wN) ) / N / |m| max-sim 점수 = max( cos(q, w1), ..., cos(q, wN) )평균 풀링의 점수는 윈도별 코사인의 평균을
|m|으로 나눈 값이다. 긴 글이 질의와 맞닿는 곳이 한 윈도뿐이면 그 신호는 1/N로 깎이고, 나머지 윈도의 점수가 평균을 결정한다. 윈도들이 서로 어긋날수록|m|이 줄어 점수를 약간 되돌려 주지만, 관련 윈도의 몫을 되찾아 주지는 못한다.글을 쓰며 같은 모델로 작은 문서를 만들어 곡선을 다시 그려 봤다. 조건은 Memento 프로덕션과 같은
Xenova/paraphrase-multilingual-MiniLM-L12-v2(q8), 윈도 510토큰, 윈도 상한 16개다. HTTP 에러 처리 단락(82토큰)을 6번 반복해 한 윈도를 채우고, 뒤에 이 블로그의 다른 글 본문에서 관련 단어를 지운 무관한 윈도를 덧붙였다. 질의는HTTP 서버 에러 처리다. 0.3206과 0.3132의 차이는 첫 윈도 경계가 조금 밀린 결과다.윈도 개수 평균 풀링 max-sim 평균 벡터 길이 1 0.3206 0.3206 1.0000 3 0.1779 0.3132 0.9154 5 0.1320 0.3132 0.9066 9 0.0946 0.3132 0.8963 16 0.0748 0.3132 0.9016 16윈도 행의 산수는 이렇다. 윈도 코사인 평균은 0.0748 × 0.9016 ≈ 0.0674다. 이 가운데 주제 윈도의 몫은 0.3132 / 16 ≈ 0.0196뿐이고, 나머지는 무관한 윈도가 채운다.
|m|은 0.90에서 멈췄다. 한국어 산문 윈도는 서로 꽤 닮아서, 이 값으로 역산한 윈도 쌍의 평균 코사인이 약 0.80이다. 벡터 길이의 축소보다 1/N이 점수를 훨씬 크게 깎았다.이 표는 문서 한 건, 질의 한 건으로 만든 데모다. 산수를 눈으로 보여 줄 뿐 검색 품질의 근거는 아니다. 근거는 뒤에 나오는 benchmark-v3 순위 측정이다.
윈도 하나 안에서도 같은 산수가 돈다
처음 조사 기록은 이 현상을 이렇게 정리했다. 질의 문장을 5번 반복한 70자 문서가 0.8706, 20번 반복한 280자 문서가 0.7546, 12,000자를 채운 문서가 0.4773이었고 전부 윈도 평균의 희석으로 설명했다.
다시 재 보니 이 설명은 절반만 맞았다. 원 측정의 문서 구성을 똑같이 맞추지 못해 코사인 값은 다르게 나왔지만(0.8948, 0.8094, 0.3789), 윈도 구조는 구성과 상관없이 분명했다. 앞의 두 문서는 25토큰과 100토큰으로 윈도가 하나뿐이다. 윈도 평균이 끼어들 틈이 없다. 같은 문장으로 약 11,000자를 채운 마지막 문서는 윈도가 8개지만
|m|이 0.9993이다. 평균을 내도 벡터가 변하지 않는다는 뜻이다. 윈도 간 평균이 설명하는 부분은 앞 절의 표처럼 윈도마다 내용이 다른 경우이고, 코퍼스 문서 60건을 이어 붙인 0.3094가 여기에 가깝다.그럼 윈도 안에서는 무슨 일이 벌어질까. 모델 호출이
pooling: 'mean'이다. 토큰 벡터를 평균 내는 같은 연산이 한 단계 아래에서 먼저 돈다. 키워드형 질의HTTP 서버 에러 처리에서는 효과가 또렷했다. 주제 단락만 있으면 0.5953, 같은 윈도에 무관한 토큰을 80개 섞으면 0.5694, 160개면 0.4416, 400개면 0.2336이다. 윈도를 나누지 않아도 점수가 절반 아래로 떨어진다.여기서 윈도를 더 작게 자르면 된다고 결론 내리고 싶어진다. 원본 모델 설정의
max_seq_length가 128이라 더 그렇다. Memento가 쓰는 Xenova 변환본의 토크나이저 설정은 512이고, 프로덕션 윈도는 510토큰이다. 실제로 주제 단락 뒤에 무관한 글 4,000토큰을 붙인 문서에서, 이 질의의 max-sim 점수는 윈도를 128토큰으로 줄이자 0.1273에서 0.5821로 뛰었다.질의를 두 개 더 던지자 이 그림이 무너졌다. 같은 문서에 질문형 질의
서버 예외를 어떤 상태 코드로 돌려줘야 하나와Retry-After 헤더는 언제 붙이나를 던지자, 128토큰 윈도의 max-sim은 오히려 0.3549에서 0.2902로, 0.4042에서 0.3063으로 내려갔다. 더 곤란한 수치도 나왔다. 두 질의에서 주제 단락의 코사인은 0.2700과 0.0815인데, 무관한 글만 490토큰 담은 윈도 세 개는 0.28에서 0.36 사이였다. 주제와 아무 상관 없는 긴 윈도가 정답 단락을 이겼다.세 질의로 윈도 크기의 정답을 정할 길은 없다. 이 실험이 보여 준 사실은 하나다. 코사인 절댓값은 길이가 다른 텍스트 사이에서 관련도의 척도로 믿기 어렵다. 처음부터 코퍼스 순위로 재야 했던 이유이기도 하다.
네 가지 후보에서 max-sim을 고른 이유
선택지는 넷이었다. 앞쪽 윈도에 가중치를 주는 안은 뒷부분을 다시 불리하게 만들어, 앞서 고친 절단 문제를 되살린다. 윈도 수로 점수를 보정하는 안은 상수 튜닝으로 흐르는데, 이전 측정에서 이미 함정으로 지적된 방식이다. 남은 두 안은 사실 한 묶음이다. 윈도마다 벡터를 저장하고, 질의와 가장 가까운 윈도의 코사인을 문서 점수로 삼는다. ColBERT의 late interaction을 문서 쪽 윈도 단위로 줄인 형태다.
max-sim은 비용 구조도 유리했다. 윈도가 하나인 문서는 평균과 max-sim의 벡터가 비트 단위로 같다. 코퍼스 3,461건 가운데 윈도가 둘 이상인 문서는 46건(1.33%)이라, 바뀌는 문서도 저장 비용도 이 46건에 묶인다.
효과가 보이지 않던 첫 측정
오프라인 비교 하네스의 첫 실행은 모든 순위를 top-20에서 잘랐다. 정답이 20위 밖이면
mean_rank: null이 찍혔고, 가장 큰 변화가 통째로 보이지 않았다. 전체 순위를 기록하도록 고치자 그림이 달라졌다.recall@k mean max-sim 10 0.3793 0.3793 20 0.4828 0.4828 50 0.5172 0.5862 100 0.5862 0.6552 q_031 "docker 컨테이너가 readonly database 로 죽은 원인" 3,409위 → 35위 q_032 "마이그레이션 실패 시 롤백은 어떤 순서로 하나" 120위 → 37위MRR@20은 0.2619에서 0.2616으로 그대로였고, 질의 29건 중 개선 4건, 회귀 6건, 불변 19건이었다. 건수만 보면 손해 같지만 크기가 다르다. 회귀는 1에서 7계단, 개선은 83에서 3,374계단이었다. 코퍼스 전체를 끌어올리지 않고 정답만 올렸다는 점이 더 중요하다. q_031의 코퍼스 바닥값은 20위 0.5670에서 그대로였고 40위도 0.5431에서 0.5439로 거의 움직이지 않았다. 윈도를 저장하면 관련 없는 긴 문서도 같이 떠오른다는 우려는 실현되지 않았다.
계측 실수는 하나 더 있었다. 초기 측정을 영어 전용
all-MiniLM-L6-v2로 돌렸는데 프로덕션은 다국어 모델이었다. 토크나이저가 달라 윈도 2개 이상 문서가 46건이 아닌 124건으로 나왔고, 인덱스 증가 추정도 두 배 넘게 부풀었다. 이슈에 이미 적은 수치를 정정해야 했다.프리페치만 깊게 하면 나빠진다
35위까지 올라온 정답도 최종
limit: 20안에는 들지 못한다. KNN이 애초에 20행만 가져오면 하이브리드 랭킹이 볼 기회조차 없다. 그래서 KNN 후보 수를 최종 limit에서 분리했다.limit × 8을 100과 512 사이로 제한한 값이다. 지금 코드의 쿼리를 줄이면 이런 모양이다.SELECT me.memory_id, MIN(t.distance) AS similarity FROM ( SELECT rowid, distance FROM memory_item_vec_minilm WHERE embedding MATCH ? ORDER BY distance ASC LIMIT ? -- 프리페치 깊이 ) t JOIN memory_embedding me ON t.rowid = me.id GROUP BY me.memory_id -- 문서별로 가장 가까운 행 하나 ORDER BY similarity ASC LIMIT ?; -- 최종 limit두 변경을 나눠서 재자 눈길을 끄는 결과가 나왔다. 벡터 채널만 켜고
limit=20으로 잰 값이다.지표 기준 프리페치만 프리페치 + max-sim MRR@20 0.5324 0.5152 0.5830 recall@5 0.6500 0.6250 0.6750 recall@20 0.8500 0.8250 0.8750 프리페치만 깊게 하면 모든 지표가 내려간다. 내려간 원인을 따로 분해하지는 않았다. 분명한 점은 평균 벡터로 줄 세운 후보를 더 가져와도 긴 문서의 벡터 점수는 그대로라는 사실이다. 반대로 max-sim만 넣으면 35위 정답이 20행 퍼널을 통과하지 못한다. 두 변경은 따로 머지해서는 안 되는 한 덩어리였다. 프리페치가 max-sim이 쓸 KNN 예산이라는 관계는 나눠서 재지 않았다면 드러나지 않았다.
엔진 경로에서도 q_031과 q_032는 35위와 39위로 오프라인 결과를 재현했다. 기본 가중치의 하이브리드 MRR@20은 0.8092에서 0.8264로 올랐고 recall은 그대로였다. 텍스트 채널이 이미 찾던 답을 벡터 채널도 찾게 되면서 q_031은 2위에서 1위가 됐다.
스키마 비용은 처음 걱정보다 훨씬 작았다. 앞서 청킹을 밀어냈던 벡터 여러 개의 부담을 이번에는
projection_type = 'window:N'이라는 값으로 풀었다. 기존 평균 벡터 행은 그대로 두고 윈도가 둘 이상인 문서에만 행을 더하므로, 새 마이그레이션 없이 트리거 갱신으로 끝났다. 벡터 행은 3,461개에서 3,615개로 늘었다(+4.45%). 다른 임베딩 레인이 쓰는 기존 테이블에는 윈도 행을 넣지 않아 그쪽 KNN 예산도 지켰다. 다만 이 비율은 벤치마크 코퍼스 기준이다. 긴 기억이 많은 저장소라면 행이 더 늘어난다.초록을 믿기 전에 한 번 더
이 작업에서 측정을 망칠 뻔한 장면은 효과 크기만큼 배울 점이 많았다. 프로브를
limit: 200으로 돌리면 프리페치 효과가 보이지 않는다. 기준선의 프리페치도 이미 200이 되기 때문이다. 앞의 세 칸짜리 표는 프로덕션 조건인limit=20으로 재야만 나온다.통합 스펙의
if (!vecAvailable) return;한 줄은 더 조용했다. sqlite-vec이 없는 환경에서 본문을 건너뛰고 통과로 기록한다. 이 줄을it.skipIf(!vecAvailable)로 바꿔 건너뛰었다는 사실이 결과에 드러나게 했다. 새 단언이 실제로 도는지도 확인했다. 정답 윈도 벡터를 질의에서 멀리 떨어뜨리는 돌연변이를 넣자 테스트 2건이 실패했고, 되돌리자 통과했다. 일부러 망가뜨려 보지 않은 계측기의 초록은 그 계측기가 돌았다는 증거조차 못 된다.아직 풀리지 않은 부분
max-sim은 윈도 사이의 희석만 푼다. 윈도 안의 희석은 그대로라 PII 마스킹 문서처럼 벡터 순위가 9위에서 10위로 내려간 사례도 나왔다. 앞 절의 실험대로 짧은 단락 하나가 510토큰 윈도에 묻히면 max-sim으로 건질 신호 자체가 약하다. 윈도를 줄이면 나아질지는 코퍼스 순위로 재지 않았다. 질의 셋의 결과가 엇갈린 만큼, 이 값은 benchmark-v3 같은 평가셋에서 순위로 재기 전까지 바꿀 근거가 없다.
윈도 상한도 남았다. 16윈도, 곧 8,160토큰을 넘는 뒷부분은 지금도 버린다. 기존 데이터베이스는 재임베딩 전까지 윈도 행이 없어서, 배포만으로는
MIN이 평균 벡터 하나를 고르는 데 그치고 동작이 달라지지 않는다.모든 긴 문서가 구제되지도 않았다. 장문 디스트랙터 테스트의 실패는 10건에서 8건으로 줄었을 뿐이다. 남은 실패 가운데 6건은 윈도 행을 정상으로 받고도 상위에 들지 못했는데, 원인은 색인이 아니었다. 그 문서들의 본문이 실제로 질의와 맞지 않았다. 평균 풀링이 가리던 천장을 걷어 내자, 길이 탓으로 보이던 실패와 진짜로 관련 없는 문서가 비로소 구분됐다.
참고
반응형'MEMENTO' 카테고리의 다른 글
한 줄짜리 보안 패치가 아니었다: CI 신호를 믿지 못한 네 장면과 조용한 임베딩 폴백 (0) 2026.11.05 AI도 잠을 자야 기억이 정리된다 (1) 2026.04.14 에이전트는 왜 매번 처음부터일까? — AI 기억 보조 MCP 서버 Memento 소개 (0) 2026.02.25 MCP 기반 장기 기억 시스템 아키텍처: “저장”이 아니라 “기억”을 설계하는 법 (0) 2026.02.17 AI 에이전트의 자기 객관화: memento '메타 메모리' 구현기 (0) 2026.01.14