-
사본 다섯 개가 주입 예산을 다 먹었다: triple 재조립 폴백이 만든 semantic 중복과 네 겹의 방어선
MEMENTO 2026. 11. 14. 09:02반응형작업 기록을 남기려고
memory_injection을 불렀더니 기억 다섯 개가 돌아왔다. 다섯 개 모두 글자 하나까지 같았다. 토큰 예산 713은 사본 다섯 개가 전부 가져갔고, 찾던 작업 기록(episodic)은 결과에 없었다. 같은 질의를recall로 보내면 그 기록이 바로 나왔다. 검색 엔진이 틀린 것이 아니었다. 저장소에 사본이 쌓였고, 사본이 상위 슬롯을 독식했다.Memento 이슈 #1137의 출발점이다. 이 글은 사본이 어디서 생겼는지, 쓰기, 읽기, 기존 데이터, 배포판 네 군데를 왜 따로 막아야 했는지 기록한다. 기존 데이터를 정리하는 방어선은 처음 만든 형태가 배포판 사용자에게 닿지 않아 마이그레이션으로 다시 만들어졌다.
사본은 실패한 재조립에서 나왔다
Memento는 episodic 기억에서 triple(주어, 서술어, 목적어)을 뽑아 semantic 기억을 만든다. semantic 행의 본문(content)은 triple을 자연어 문장으로 재조립해 채운다. 재조립이 실패하면 원본 episodic의 본문을 그대로 복사하는 폴백이 있었다. 원문을 보존한다는 #768의 의도였다.
문제는 재조립 실패가 예외가 아니라 기본 경로였다는 점이다.
conjugatePredicate는 서술어가 한글로 끝나지 않으면null을 돌려준다. 추출 모델이stores,uses,fixes같은 영문 서술어를 내면 재조립은 항상 실패한다.- 실패한 triple의
fallbackText는 triple과 무관한 원본 본문이다. 한 episodic에서 triple이 k개 나와 모두 실패하면 k개 행이 같은 본문을 갖는다. - 중복 가드
hasEligibleExactCandidate는 subject, predicate, object만 비교한다. 본문이 같아도 S/P/O가 다르면 통과한다.
세 결함이 겹쳐서 가드가 사본 생성을 허락했다. 가드만 막으면 반대 문제가 생긴다. 재조립에 실패한 triple이 전부 버려져 정보가 사라진다. 이슈 본문도 가드 수정을 폴백 수정과 묶어서 검토하라고 적었다.
규모는 우연이 아니었다
2026-08-28의
remember호출 한 번이 사례다. importance 0.1짜리 episodic이13:03:10에 저장됐고,13:03:19부터13:03:25사이에 semantic 사본 8개가 생겼다. 서술어 8개는 모두 영문(stores,uses,fixes,cover등)이었고 본문 8개는 원본 그대로였다. importance는 사본이 0.95였다. 원본이 0.1이니 내용이 같은 사본이 검색 점수에서 원본을 압도한다.운영 DB(읽기 전용)를 세어 보니 사건이 아니라 구조였다.
지표 값 서술어가 있는 semantic 1,818 영문, 숫자, )로 끝나는 서술어(재조립 실패 조건)1,476 (81%) 그중 본문 중복 1,467 (99.4%) 본문 중복 그룹 / 중복 행 506 / 2,733 중복 행 내역 semantic 1,806, episodic 731, procedural 192 원본 episodic 하나당 semantic 사본이 평균 2.5개 붙었다. 재조립 실패 조건에 걸린 행의 99.4%가 중복이라는 숫자가 원인을 확정해 준다. 실패 조건이 곧 중복 조건이다.
막을 곳은 네 군데였다
이슈는 해법을 A부터 E까지 제안했고, 구현에서는 서로 다른 층을 하나씩 맡는 구조가 됐다.
층 조치 막는 것 못 막는 것 쓰기 A 원문 폴백 제거. 재조립 실패 시 subject · predicate · object로 표기신규 사본 기존 행 쓰기 B 추출 프롬프트가 서술어를 한글 ㅁ형 단일 토큰으로 내게 강제, canonicalizer가 -s,-ed,-ing어간화재조립 실패 자체 모델이 규칙을 어길 때 읽기 D memory_injection의 overfetch 루프에서 본문 프리픽스로 dedup, 걷힌 만큼 검색 한도 확장예산 낭비 DB 행 수 데이터 E 기존 semantic을 재렌더하고 완전 중복은 soft-delete 과거 부채 새로 생기는 사본 A에서 가장 중요한 변화는 본문이 triple에만 의존하게 됐다는 점이다. 본문이 원본 episodic과 무관해졌으니 같은 episodic에서 나온 triple끼리 본문이 같아질 길이 구조적으로 닫힌다. 원문 보존이라는 #768의 의도는 포기했고, 원문은
extracted_from이 가리키는 원본 episodic에 남는다.D에서는 두 가지를 챙겼다. 본문을 500자로 자른 사본도 원문 행과 같은 그룹으로 묶는다. 사본을 걷어낸 만큼
searchLimit을 늘려, dedup 때문에 예산이 오히려 비는 일을 막는다.쓰기만 고쳐서는 기존 2,733행이 남고, 읽기만 고쳐서는 DB가 계속 부푼다. 이슈 본문이 두 쪽 모두 필요하다고 못 박은 이유다.
실측: 프롬프트 한 줄이 한글 ㅁ형 비율을 올렸다
B의 효과는 gpt-4o-mini로 episodic 20건에서 triple을 뽑아 재 봤다.
triple 서술어 한글 ㅁ형 게이트 통과 변경 전 246 186 (75.6%) 187 (76.0%) 변경 후 212 192 (90.6%) 191 (90.1%) triple 수가 246에서 212로 줄었다는 점도 같이 읽어야 한다. 형식을 강제하자 모델이 모호한 triple을 덜 만들었다. 이 표본은 20건이라 비율의 오차가 크고, 90%가 상한이라는 뜻은 아니다. 남은 10%는 B2의 canonicalizer와 A의 폴백이 받는다.
E의 dry-run은 313MB 스냅샷에서 재렌더 1,448건, 완전 중복 soft-delete 1건으로 나왔다. 시뮬레이션상 중복 그룹이 228개에서 16개로 줄었다. 잔여 16개는 triple 컬럼이 없는 행이라 재렌더 대상이 아니고, 읽기 D가 흡수한다. 실제
--apply는 PR 범위 밖에서 MCP를 멈추고 백업을 뜬 뒤 별도로 실행했다.지표 적용 전 적용 후 중복 그룹 228 16 중복 행 1,566 96 triple이 있는 중복 1,449 0 소요는 16초였다. 한 가지 짚어 둘 숫자가 있다. 이슈 시점에 506그룹, 2,733행이던 값이 착수 전에는 228그룹, 1,566행이었다. 기록에는 이유가 남아 있지 않다. 집계 범위가 달랐을 가능성이 있지만 확인하지 못했다. 같은 기록은 다른 정정도 하나 담고 있다. 2026-09-05의 게이트(#813) 이후 신규 중복 본문은 0건이었다. 이슈가 쓴 "진행 중"은 9월 1일부터 4일까지 쌓인 물량이었다. 그래서 과제는 새 사본 차단이 아니라 기존 부채 정리와 다중 방어선으로 재정의됐다.
정리 스크립트는 배포판에 없었다
PR #1138은 E를
npm run memory:repair-duplicate-semantic으로 제공했다. 체크아웃한 저장소에서는 돌아간다. 후속 이슈 #1139가 짚은 것은 이 스크립트가 npm으로 설치한 사용자에게 닿지 않는다는 사실이다.package.json의files에 스크립트가 없다. 실측하니 담긴 것은auto-setup.js,lib/cli-runtime.js,lib/postinstall-db-init.js셋뿐이었다.- 스크립트를 실행하는
tsx는devDependencies에 있다.
업그레이드 후에는 신규 중복이 안 생기지만, 이미 생긴 사본은 영구히 남는다. 쓰기와 읽기를 고친 PR이 데이터 층에서는 절반만 전달됐다.
해법은 정리를 마이그레이션 048로 옮기는 것이었다. 마이그레이션은 설치된 패키지 안에서 돈다. 판정 로직
buildDuplicatePlan을scripts/에서@memento/core로 승격해야 했다. 마이그레이션이scripts/를 import하면 배포판에서 깨지기 때문이다. 이동 전후 diff는 타입, 주석, import 세 줄뿐이었다. 마이그레이션은 파일명/^\d{3}-/패턴으로 자동 등재되므로 index를 따로 고치지 않는다. 빌드한 dist로 확인하니 마이그레이션이 47개이고 꼬리가48 repair-duplicate-semantic-content였다.설계 결정 둘
임베딩은 만들지 않는다. 임베딩 모델을 마이그레이션 트랜잭션 안에서 로드하면 서버 시작이 막히고 쓰기 락이 오래 잡힌다. 본문 갱신은
UPDATE memory_item이 FTS 트리거를 태워 같은 트랜잭션에서 재색인한다. 스크래치 DB로 확인하니 새 본문 토큰은 1건 걸리고 옛 본문 토큰은 0건이었다. 벡터만 오래된 값으로 남는다. 반환되는 본문은 새 것이라 손상은 아니고, 옛 본문 기준으로 걸려서 새 본문이 나오는 불일치다. 이 상태를 고칠 수단이 배포판에 없다는 사실은 별도 이슈(#1155)가 됐다.수렴할 때까지 최대 5회 반복한다. 재렌더가 새 중복을 만들기 때문이다. 행 X를
도커는 컨테이너를 사용합니다로 재렌더하면, 원래 그 문장을 본문으로 가졌던 행 Z와 2회차에 같은 그룹이 된다. 마이그레이션은 끝난 뒤 plan이 완전히 비었는지 검증하고, 남았으면 자동 롤백한다. 루프가 없으면 전체가 되돌려진다. 테스트 로그가passes: 2로 2회차 진입을 실제로 보여 준다.테스트를 망가뜨려 보니 구멍 하나가 나왔다
마이그레이션 테스트가 가드 역할을 하는지 확인하려고 구현을 일부러 훼손했다.
훼손 결과 MAX_PASSES5를 1로적색 ( expected +0 to be 1)softDelete.run(...)제거적색 (2건 실패) registerNormalizeFunction없이UPDATE memory_item적색 ( no such function: normalize_reflection_notes)후보 SQL type='semantic'을IN ('semantic','episodic')로녹색, 7개 전부 통과 네 번째가 구멍이었다.
duplicatedCTE가 semantic만 세기 때문에 픽스처의 episodic 행은 애초에 후보가 아니었다. 훼손해도 녹색이었던 이유는 가드가 튼튼해서가 아니라 픽스처가 그 경로를 밟지 않아서였다.빠진 경우는 원본 episodic 행이 중복 semantic 쌍과 같은 본문을 갖는 경우다. #1137의 발생 원리가 원본 본문을 복사하는 것이었으니 실제 사용자 DB에는 거의 반드시 있다. 이 상황을 프로브로 만들어 같은 훼손을 가하자 원본 episodic이 soft-delete됐다(
expected 1 to be +0). 사용자의 원본 기록이 지워지는 파괴적 회귀라 테스트로 고정했다.반증 대상을 고를 때 그 조건이 픽스처에서 실제로 살아 있는지를 먼저 확인해야 한다는 교훈이다.
CI는 두 번 붉어졌고, 문서는 같은 실수를 반복했다
심볼을 옮기면서 놓친 것이 두 개였다.
scripts/repair-duplicate-semantic-content.spec.ts가 이동 전 위치에서 import해buildDuplicatePlan is not a function으로 실패했다. 심볼을 옮길 때는grep -rn '<심볼>' --include='*.ts'로 스펙까지 찾아야 한다.memory-cluster-topology.spec.ts는 클러스터별 production 파일 수를 정확한 숫자로 고정한 tripwire다. 파일 하나를 추가하자semantic: 12가 13이 됐다. 로컬에서 부분 실행하면 보이지 않고, 패키지 전체 테스트(162초)를 돌려야 나온다.
문서에도 같은 실수가 있었다. 초안은 배포판 사용자에게
npm run reindex-embeddings를 권했다. 이 명령도tsx기반이라 그 독자는 실행하지 못한다. 이 PR의 원인이 스크립트가 배포판에 없다는 것인데, 해법을 안내하는 문장에서 같은 실수를 되풀이했다. 문서의 독자와 명령의 배포 여부를 교차 확인해야 한다. 정정된 문구는 "아직 수단이 없다"이다.남은 것
이 작업이 고치지 않은 항목은 범위 밖으로 명시됐다.
- importance 상속: episodic 0.1이 semantic 0.95로 뛰는 공식은 그대로다. 사본이 사라져 증상은 줄었지만, 하이브리드 랭킹에서 importance 가중이 큰 문제는 남는다.
- 계사와 속성 서술어(
is를임으로 옮기는 경우)에서 조사가 어긋나는 비율이 약 21%다. - 피동
됨형 서술어. - Reflexion procedural 중복(
recall 실패 기록141건 등)은 원인이 달라 별도다. - 마이그레이션 048이 남긴 벡터 stale 상태를 갱신할 배포판 수단.
정리
- 사본의 근원은 재조립 실패가 기본 경로였다는 설계다. 원문 폴백이 실패를 중복으로 바꿨다.
- 중복은 쓰기, 읽기, 기존 데이터, 배포판 네 층이 각자 맡은 몫이 달라서 어느 한 층의 수정으로는 끝나지 않는다.
- 정리 도구는 사용자가 실행할 수 있는 곳에 있어야 한다. 체크아웃에서만 도는 스크립트는 배포판 사용자에게 존재하지 않는 도구다.
- 파괴적 마이그레이션의 테스트는 조건을 일부러 훼손해 적색이 나오는지 확인하고, 녹색으로 남는 훼손은 픽스처가 그 경로를 밟는지부터 의심한다.
- 정리 결과는 수치로 남긴다. 중복 그룹 228에서 16, triple이 있는 중복 1,449에서 0이라는 숫자가 있어야 다음 회귀를 알아본다.
반응형'MEMENTO' 카테고리의 다른 글
관계로 기억을 넓히려면 어디서 출발해야 하는가: 앵커 정밀도가 막은 그래프 확장 (0) 2026.11.12 한 번 정한 임계값은 언제 다시 재야 하는가: 기각 게이트 0.4와 분리되지 않는 두 변수 (0) 2026.11.10 긴 기억은 왜 벡터 검색에서 지는가: 평균 풀링의 산수와 max-sim (0) 2026.11.07 한 줄짜리 보안 패치가 아니었다: CI 신호를 믿지 못한 네 장면과 조용한 임베딩 폴백 (0) 2026.11.05 AI도 잠을 자야 기억이 정리된다 (1) 2026.04.14