research

ZeroClaw — Memory 시스템

ZeroClaw — Memory 시스템

Layer 2 학습. 실제 소스 코드 기반. 파일: src/memory/traits.rs, src/memory/mod.rs, src/memory/retrieval.rs


전체 구조

Memory trait (계약)
    │
    ├─ SqliteMemory     ← 기본값. FTS5 + 벡터 하이브리드 검색
    ├─ LucidMemory      ← SQLite 위에 추가 기능 레이어
    ├─ MarkdownMemory   ← 파일 기반 (단순)
    ├─ PostgresMemory   ← feature flag 필요
    ├─ QdrantMemory     ← 외부 벡터 DB
    └─ NoneMemory       ← 비활성화 (noop)

RetrievalPipeline
    ├─ Stage 1: Hot Cache (in-memory LRU, TTL 300s)
    ├─ Stage 2: FTS (keyword, 점수 0.85 이상이면 early return)
    └─ Stage 3: Vector (임베딩 유사도 + FTS 하이브리드 병합)

Memory trait — 계약

#[async_trait]
pub trait Memory: Send + Sync {
    fn name(&self) -> &str;

    async fn store(
        &self, key: &str, content: &str,
        category: MemoryCategory, session_id: Option<&str>
    ) -> anyhow::Result<()>;

    async fn recall(
        &self, query: &str, limit: usize,
        session_id: Option<&str>,
        since: Option<&str>, until: Option<&str>,  // ISO 8601
    ) -> anyhow::Result<Vec<MemoryEntry>>;

    async fn get(&self, key: &str) -> anyhow::Result<Option<MemoryEntry>>;
    async fn forget(&self, key: &str) -> anyhow::Result<bool>;
    async fn list(...) -> anyhow::Result<Vec<MemoryEntry>>;
    async fn count(&self) -> anyhow::Result<usize>;
    async fn health_check(&self) -> bool;

    // default impl: noop (SQLite 등이 override)
    async fn store_procedural(&self, messages: &[ProceduralMessage], ...) {}
    async fn recall_namespaced(&self, namespace: &str, ...) {}
    async fn store_with_metadata(&self, ..., namespace, importance) {}
}

MemoryEntry — 저장 단위

pub struct MemoryEntry {
    pub id: String,
    pub key: String,           // 식별자 (예: "user_preference_language")
    pub content: String,       // 실제 내용
    pub category: MemoryCategory,
    pub timestamp: String,     // RFC 3339
    pub session_id: Option<String>,
    pub score: Option<f64>,    // recall 시 관련도 점수
    pub namespace: String,     // agent 간 격리 (기본값 "default")
    pub importance: Option<f64>, // 0.0~1.0, 우선순위 검색용
    pub superseded_by: Option<String>, // 충돌 시 대체된 항목 ID
}

MemoryCategory

pub enum MemoryCategory {
    Core,              // 장기 사실, 선호도, 결정 사항
    Daily,             // 일일 세션 로그
    Conversation,      // 대화 컨텍스트
    Custom(String),    // 사용자 정의 ("schedule", "assignment" 등)
}

내 프로젝트에서:

// 과제 정보 저장
memory.store(
    "assignment_알고리즘설계_hw3",
    "3주차 과제: 다익스트라 구현, 마감 2026-04-01",
    MemoryCategory::Custom("assignment".into()),
    Some(session_id),
).await?;

RetrievalPipeline — 3단계 검색

recall("다익스트라 과제")
    │
    ├─ Stage 1: Hot Cache
    │   TTL 5분 / LRU 256개
    │   └─ 캐시 히트 → 즉시 반환
    │
    ├─ Stage 2: FTS (SQLite FTS5 키워드 검색)
    │   └─ 상위 score >= 0.85 → early return (Stage 3 스킵)
    │       score < 0.85 → Stage 3로
    │
    └─ Stage 3: Vector (임베딩 유사도)
        쿼리를 임베딩 벡터로 변환 → 코사인 유사도 계산
        FTS 결과와 가중 합산:
          final_score = vector_weight * vec_score
                      + keyword_weight * fts_score
        기본값: vector_weight=0.7, keyword_weight=0.3

FTS early return 이유: 키워드가 정확히 일치하면 굳이 벡터 임베딩 API 호출할 필요 없음. 비용과 지연 절약.


turn()과의 통합

// agent.rs - turn() 내부

// 1. 매 대화마다 관련 기억 로드
let context = memory_loader.load_context(
    memory.as_ref(), user_message, session_id
).await.unwrap_or_default();

// 2. 기억을 user message 앞에 주입
let enriched = if context.is_empty() {
    format!("[{now}] {user_message}")
} else {
    format!("{context}[{now}] {user_message}")
};

// 3. auto_save = true면 user message 자동 저장
if self.auto_save {
    memory.store("user_msg", user_message, Conversation, session_id).await;
}

DefaultMemoryLoader: 기본값 recall limit = 5, min_relevance_score = 0.3


백엔드 선택 흐름 (create_memory)

config.memory.backend = "sqlite"
    │
    ├─ "sqlite"   → SqliteMemory::with_embedder(...)
    ├─ "lucid"    → LucidMemory(SqliteMemory)   // 추가 기능 레이어
    ├─ "markdown" → MarkdownMemory(workspace_dir)
    ├─ "postgres" → PostgresMemory (feature flag)
    ├─ "qdrant"   → QdrantMemory (외부 서버)
    ├─ "none"     → NoneMemory (noop)
    └─ 기타        → warn + fallback to MarkdownMemory

storage.provider.config.provider 설정이 있으면 그걸 우선함.


SqliteMemory — 내부 구조

brain.db (SQLite 파일)
├─ memories 테이블 (FTS5)
│   key, content, category, timestamp, session_id, namespace, importance
│
├─ embeddings 테이블
│   memory_id → embedding_vector (BLOB, f32 배열)
│
└─ FTS5 인덱스 (content 컬럼 전문 검색)

임베딩 provider 선택:

[memory]
embedding_provider = "openai"    # openai, cohere, ollama, none
embedding_model = "text-embedding-3-small"
embedding_dimensions = 1536
vector_weight = 0.7
keyword_weight = 0.3

embedding_provider = "none" 이면 FTS 전용 (벡터 계산 없음). 로컬 환경에서 API 비용 없이 쓸 수 있음.


부가 기능들

Snapshot / Hydration:

brain.db 없음 + MEMORY_SNAPSHOT.md 있음
    → "Cold boot" 감지 → Markdown에서 SQLite로 자동 복원

Memory Hygiene:

오래된 Conversation 기억 자동 삭제 (retention_days)
중복/충돌 항목 병합 (conflict.rs)

Procedural Memory:

tool call 패턴을 "how to" 형태로 저장
다음 유사한 요청 시 절차적 기억으로 활용

Namespace 격리:

여러 agent가 같은 DB 공유할 때
namespace = "schedule_agent" vs "crawler_agent" 로 분리

내 프로젝트 설정

[memory]
backend = "sqlite"
auto_save = true
embedding_provider = "none"   # 로컬 환경, 벡터 없이 FTS만
# embedding_provider = "ollama"  # 로컬 임베딩 원하면
# embedding_model = "nomic-embed-text"
# embedding_dimensions = 768
vector_weight = 0.0
keyword_weight = 1.0
min_relevance_score = 0.3
max_history_messages = 50

LLM tool 로도 직접 조작:

memory_store(key, content, category)   # 기억 저장
memory_recall(query, limit)            # 기억 검색
memory_forget(key)                     # 기억 삭제

관련

  • agent-loop — turn()에서 memory_loader.load_context() 호출
  • tool-system — MemoryStoreTool, MemoryRecallTool, MemoryForgetTool
  • config-schema — [memory] 섹션 전체
  • overview — ZeroClaw 학습 지도