에이전트 메모리 엔진 (10/10) — Context Tree + BM25 하이브리드 검색
46KB 단일 파일에서 다층 계층 구조 + BM25 검색으로 전환한 오픈클로 메모리 시스템
핵심 요약
- 단일 파일 비대화(46KB)와 키워드 flat 매칭의 한계를 Context Tree + BM25 하이브리드로 해결했다
- 한국어 교착어 문제('가치관이'→'가치관' 매칭 실패)를 부분 매칭으로 처리한다
- 결과: 검색 정밀도 65→90점, 파일 크기 75% 감소, memory-warning 다수→소수
배경
AI 에이전트의 메모리 시스템은 세션 간 지식을 보존하는 핵심 인프라다. 오픈클로(OpenClaw)는 안정적으로 운영 중인 멀티에이전트 플랫폼으로, 이전 세션에서 학습한 내용, 사용자 선호도, 프로젝트 규칙 등을 메모리에 저장하고 다음 세션에서 검색해 활용한다.
초기 설계에서는 메모리를 단일 파일(self-architecture.md)로 관리했다. 규모가 작을 때는 문제가 없었다. 그러나 운영이 축적되면서 이 파일이 46KB까지 성장했고, 에이전트가 참조할 때마다 46KB 전체를 컨텍스트에 로딩해야 하는 구조가 됐다. 파일 하나가 컨텍스트 윈도우의 상당 부분을 점유하기 시작했다.
파일 크기보다 더 근본적인 문제가 있었다 — 검색 정밀도였다.
본문
1. 기존 시스템의 3가지 한계
한계 1: 단일 파일 비대화
모든 지식이 한 파일에 집중되면, 작은 정보 하나를 조회할 때도 전체를 로딩해야 한다. "가치관 관련 기록"만 필요한 시점에도 46KB 전체가 컨텍스트에 올라간다. 불필요한 정보가 응답 생성에 간섭하고, 토큰 효율이 낮아진다.
한계 2: 키워드 flat 매칭의 한국어 문제
기존 검색은 단순 완전 키워드 매칭이었다. "가치관"으로 검색하면 해당 문자열이 정확히 존재하는 줄만 매칭된다. 문제는 한국어의 구조적 특성이다. 한국어는 교착어로, 동일한 단어가 조사와 결합하여 "가치관이", "가치관을", "가치관에서" 등 다양한 형태로 텍스트에 존재한다. 원형 키워드로는 이 변형들을 잡지 못한다 — 결과는 NO_MATCH다.
영어 환경에서는 "values"로 검색하면 대부분 매칭되지만, 한국어에서는 조사가 붙는 순간 원래 어간이 다른 문자열로 분화된다.
한계 3: 중복 축적과 분류 혼재
신규 지식 추가 시 기존 유사 내용을 확인하는 절차가 없었다. "TTL은 30분으로 설정"과 같은 정보가 서로 다른 맥락에서 중복 기록됐다. 기술적 결정, 개인 선호, 프로젝트 규칙이 단일 파일 안에 혼재해 구조적 분류가 불가능했다.
2. Context Tree — 노드 스키마와 계층 구조
해결의 첫 번째 축은 구조화다. 단일 파일을 주제별로 분할하고, tree-index.json이 계층 구조를 관리한다.
노드 스키마
각 노드는 다음 필드로 정의된다:
{
"id": "identity/values",
"path": "bank/identity/values.md",
"keywords": ["가치관", "판단기준", "원칙"],
"parent": "identity",
"children": [],
"weight": 1.0
}
id: 고유 식별자. 슬래시로 계층 표현 (category/topic)path: 실제 파일 경로keywords: 이 노드로 라우팅할 검색어 목록parent/children: 트리 관계 정의weight: 검색 스코어 보정 계수
계층 예시 (parent-child 관계)
root
├── identity/ (parent: root)
│ ├── identity/values (parent: identity)
│ └── identity/personality
├── technical/ (parent: root)
│ ├── technical/heartbeat-reflect
│ └── technical/cron-design
└── project/ (parent: root)
├── project/rules
└── project/decisions
다수의 노드가 이 구조로 정의된다.
트리 탐색 방식 (traversal)
쿼리가 들어오면 다음 순서로 탐색한다:
tree-index.json의 모든 노드를 순회하며keywords배열과 검색어를 비교- 매칭된 노드를 후보 목록에 추가
- 후보가 없으면 부모 노드로 올라가며 재탐색 (fallback)
- 최종 후보 노드의 파일만 로딩 — 평균 2~5KB
이 구조로 46KB 전체 로딩 대신 필요한 파일만 선택적으로 로딩한다.
3. BM25 검색 엔진
구조화만으로는 부족하다. 검색어가 tree-index의 키워드와 정확히 매칭되지 않는 경우가 존재하기 때문이다.
BM25(Best Match 25)는 정보 검색 분야의 표준 스코어링 알고리즘이다. TF-IDF 기반에 문서 길이 정규화를 추가한 형태로, 문서 길이가 다를 때도 공정하게 비교할 수 있다.
오픈클로에 적용한 BM25에는 세 가지 추가 요소가 있다.
IDF 캐시
IDF(Inverse Document Frequency)는 단어의 희귀도를 나타내는 값이다. 전체 문서 집합에서 특정 단어가 얼마나 드물게 등장하는지를 수치화한다. 매 검색마다 이 값을 재계산하면 비효율적이므로, 캐시로 보관하고 문서가 변경될 때만 갱신한다.
idf_cache = {}
def get_idf(term, corpus):
if term in idf_cache:
return idf_cache[term]
df = sum(1 for doc in corpus if term in doc)
idf = math.log((len(corpus) - df + 0.5) / (df + 0.5) + 1)
idf_cache[term] = idf
return idf
한국어 부분 매칭
형태소 분석기(KoNLPy 등) 도입을 검토했으나, 의존성이 크고 환경에 따라 동작이 불안정했다. 대신 substring match 방식을 채택했다. "가치관이"라는 텍스트에서 "가치관"이 포함되어 있으므로 매칭 성공으로 처리한다. 형태소 분석 없이 교착어 문제를 실용적으로 해결한다.
def token_match(query_term, document_text):
# 정확 매칭 우선, 없으면 부분 매칭
if query_term in document_text.split():
return 1.0
elif query_term in document_text:
return 0.7 # 부분 매칭은 가중치 감쇄
return 0.0
30분 TTL 쿼리 캐시
동일 검색어가 반복될 때 캐시된 결과를 반환한다. TTL을 처음에는 5분으로 설정했으나, 같은 맥락에서 반복 질문이 많은 운영 패턴에서 너무 빨리 만료됐다. 30분이 적정값으로 확인됐다.
4. 하이브리드 스코어링
Context Tree 키워드 매칭과 BM25를 결합한 최종 스코어:
final_score = 0.4 × tree_keyword_score + 0.6 × bm25_score
- tree_keyword_score: 노드 키워드 매칭 여부 +
weight보정 - bm25_score: 문서 내용 기반 관련도
초기에는 0.5/0.5 균등 가중치를 적용했다. 문제는 tree 키워드가 없는 문서가 순위에서 지속적으로 밀려나는 현상이었다. 키워드 목록에 없더라도 내용상 관련 있는 문서가 존재하기 때문이다. BM25 비중을 0.6으로 높인 이후 해당 문제가 해소됐다.
가중치를 BM25 쪽에 더 부여한 것은 실험 결과다 — 키워드가 부분적으로만 매칭되더라도 BM25 점수가 높은 경우가 실제로 더 정확한 문서를 가리켰다.
5. MemTree 자동 분할과 retain-merge
bank-size-watch.py는 파일 크기를 주기적으로 모니터링한다. 파일이 임계치(10KB)를 초과하면 주제별로 자동 분할한다. 임계치를 5KB로 설정했을 때는 파일이 과도하게 세분화되어 관리 복잡도가 높아졌다. 10KB가 분할 빈도와 파일 수 사이의 균형점이었다.
retain-merge의 TAG_ROUTING은 새로운 지식을 적절한 파일로 자동 분류한다. 각 지식에 태그(W/B/O/S)가 부여되고, 키워드 기반으로 올바른 노드로 라우팅된다.
TAG_ROUTING = {
"W": ["가치관", "원칙", "판단기준"], # → identity/values.md
"B": ["크론", "스케줄", "설계"], # → technical/cron-design.md
"O": ["Reflect", "파이프라인", "heartbeat"], # → technical/heartbeat-reflect.md
"S": ["프로젝트", "규칙", "결정"], # → project/rules.md
}
신규 지식이 들어오면 TAG_ROUTING을 통해 해당 파일에 배치되기 전에 기존 내용과 비교한다. 중복이면 병합, 충돌이면 플래그 처리한다.
6. 3계층 메모리 구조
전체 메모리는 3계층으로 나뉜다:
| 계층 | 디렉토리 | 역할 |
|---|---|---|
| 원시 데이터 | memory/ |
일일 로그. 가공 전 상태. 불변 |
| 큐레이션 지식 | bank/ |
검증 후 승격된 내용. Context Tree가 관리 |
| 검색 버퍼 | recall/ |
자주 참조되는 내용의 캐시 |
데이터 흐름: memory/ → (검증/추출) → bank/ → (검색 시) → recall/
쿼리는 항상 recall/ → bank/ 순으로 조회한다. memory/는 원시 데이터이므로 직접 검색 대상에서 제외한다.
7. 실제 쿼리 예시
쿼리 1: "가치관이 뭐야?"
[tree traversal]
검색어 "가치관이"
→ keyword "가치관" ⊂ "가치관이" → 부분 매칭 성공
→ 노드: identity/values (weight: 1.0)
[bm25]
identity/values.md: score 0.84
project/rules.md: score 0.31
[final]
identity/values: 0.4×1.0 + 0.6×0.84 = 0.904 ← 1위
project/rules: 0.4×0.0 + 0.6×0.31 = 0.186
→ identity/values.md 로딩 (2.1KB)
기존: NO_MATCH → 현재: 정확 매칭 후 해당 파일만 로딩
쿼리 2: "Reflect 파이프라인 구조"
[tree traversal]
"Reflect" → keywords ["Reflect", "파이프라인", "heartbeat"]
→ 노드: technical/heartbeat-reflect (weight: 1.0)
[bm25]
heartbeat-reflect.md: score 0.91
[final]
0.4×1.0 + 0.6×0.91 = 0.946 ← 1위
→ heartbeat-reflect.md 로딩 (3.4KB)
기존: 46KB 전체 로딩 → 현재: 3.4KB 직접 매칭
8. 측정 결과
검색 정밀도: 65점 → 90점 - 한국어 변형 쿼리 매칭 실패율이 대폭 감소
파일 크기: 46KB → 10.6KB (75% 감소) - 단일 파일이 다수의 파일로 분할. 개별 파일 평균 2~5KB
중복 축적: 0건 - retain-merge가 기존 내용과 비교 후 중복이면 병합
memory-warning: 다수 → 소수 - 컨텍스트 윈도우 압박 감소로 메모리 관련 경고 대폭 감소
현재 상태와 헤르메스(Hermes)
오픈클로는 현재 안정 운영 중이다. Context Tree + BM25 구조는 프로덕션에서 검증된 상태다.
헤르메스(Hermes)는 오픈클로의 다음 단계로 설계된 플랫폼이다. 초기 마이그레이션에서 토큰 폭주 현상이 발생했고, 오픈클로로 회귀한 뒤 헤르메스 재설계를 진행했다. 현재는 재검증 단계다. 메모리 아키텍처 설계는 오픈클로에서 검증된 구조를 기반으로 한다.
마무리
AI 에이전트의 메모리 시스템에서 "저장"보다 "검색"이 핵심이다. 아무리 많이 저장해도 찾지 못하면 없는 것과 같다.
Context Tree는 구조를 제공한다 — 노드 스키마와 parent-child 관계가 "어디에 있는가"를 정의한다. BM25는 검색 정밀도를 제공한다 — 내용 기반 관련도 스코어링이 "얼마나 관련 있는가"를 정의한다. 하이브리드로 결합하면 "구조적으로 맞는 위치에서 내용적으로도 관련 있는 문서"가 정확히 선택된다.
한국어 교착어 처리는 형태소 분석 없이 부분 매칭으로 실용적으로 해결했다. 복잡한 의존성 없이 실제 운영 환경에서 충분히 작동한다.
메모리 시스템을 설계한다면, 저장 구조보다 검색 구조를 먼저 설계하는 것을 권한다. 찾을 수 없는 기억은 기억이 아니다.
시리즈 전체 안내: 시리즈 목차
댓글
댓글 쓰기