ZeroClaw — SQLite + 벡터 검색 내부
실제 소스 코드 기반. 파일:
src/memory/sqlite.rs,src/memory/vector.rs
DB 스키마
-- 핵심 테이블
CREATE TABLE memories (
id TEXT PRIMARY KEY,
key TEXT NOT NULL UNIQUE, -- 식별자
content TEXT NOT NULL, -- 실제 내용
category TEXT NOT NULL DEFAULT 'core',
embedding BLOB, -- f32 벡터 직렬화 (BLOB)
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
session_id TEXT, -- 세션 격리
namespace TEXT DEFAULT 'default', -- agent 격리
importance REAL DEFAULT 0.5
);
-- FTS5 가상 테이블 (BM25 키워드 검색)
CREATE VIRTUAL TABLE memories_fts USING fts5(
key, content,
content=memories, -- memories 테이블 참조
content_rowid=rowid -- rowid 동기화
);
-- FTS5 자동 동기화 트리거
CREATE TRIGGER memories_ai AFTER INSERT ON memories BEGIN
INSERT INTO memories_fts(rowid, key, content)
VALUES (new.rowid, new.key, new.content);
END;
-- memories_ad (DELETE), memories_au (UPDATE)도 동일
-- 임베딩 캐시 (LRU 방출)
CREATE TABLE embedding_cache (
content_hash TEXT PRIMARY KEY, -- SHA-256 (앞 8바이트, 16 hex)
embedding BLOB NOT NULL,
created_at TEXT NOT NULL,
accessed_at TEXT NOT NULL -- LRU eviction 기준
);
핵심: embedding이 memories 테이블 안에 BLOB으로 저장돼. 외부 벡터 DB 없이 SQLite 하나로 전부 처리.
PRAGMA 설정
PRAGMA journal_mode = WAL; -- 쓰기 중 읽기 가능, 충돌 안전
PRAGMA synchronous = NORMAL; -- 2배 쓰기 속도, WAL 모드에서 내구성 유지
PRAGMA mmap_size = 8388608; -- OS page cache 8MB 활용
PRAGMA cache_size = -2000; -- in-process 2MB (~500 hot page)
PRAGMA temp_store = MEMORY; -- 임시 테이블 디스크 안 씀
벡터 직렬화 (vector.rs)
f32 벡터 ↔ BLOB 변환이 단순해:
// f32 → BLOB (little-endian)
pub fn vec_to_bytes(v: &[f32]) -> Vec<u8> {
v.iter().flat_map(|&f| f.to_le_bytes()).collect()
// 1536차원 벡터 → 1536 * 4 = 6,144 bytes
}
// BLOB → f32
pub fn bytes_to_vec(bytes: &[u8]) -> Vec<f32> {
bytes.chunks_exact(4)
.map(|chunk| f32::from_le_bytes(chunk.try_into().unwrap()))
.collect()
}
코사인 유사도 계산
전용 벡터 DB 없이 순수 Rust로 직접 계산:
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
let mut dot = 0.0_f64;
let mut norm_a = 0.0_f64;
let mut norm_b = 0.0_f64;
for (x, y) in a.iter().zip(b.iter()) {
dot += x * y;
norm_a += x * x;
norm_b += y * y;
}
// 분모가 0이면 (zero vector) → 0.0 반환
let denom = norm_a.sqrt() * norm_b.sqrt();
if denom < f64::EPSILON { return 0.0; }
// [-1, 1] 범위를 [0, 1]로 클램프
(dot / denom).clamp(0.0, 1.0) as f32
}
주의: f32 대신 f64로 계산. 1536차원 dot product에서 f32는 누적 오류가 커서 정밀도 손실이 생기기 때문.
vector_search()는 모든 행을 풀스캔해서 코사인 유사도를 계산:
pub fn vector_search(conn, query_embedding, limit, category, session_id) {
let sql = "SELECT id, embedding FROM memories WHERE embedding IS NOT NULL";
// category, session_id 필터가 있으면 AND 조건 추가
// 모든 행에 대해 코사인 유사도 계산
for (id, blob) in rows {
let emb = bytes_to_vec(&blob);
let sim = cosine_similarity(query_embedding, &emb);
if sim > 0.0 { scored.push((id, sim)); }
}
scored.sort_by_desc(); // 유사도 내림차순
scored.truncate(limit);
}
→ 메모리가 수만 개를 넘어가면 느려짐. 그 시점에 Qdrant 같은 ANN 인덱스가 필요해.
임베딩 캐시 — get_or_compute_embedding()
같은 텍스트를 반복 임베딩하지 않도록 SHA-256 기반 캐시:
text → SHA-256(앞 8바이트) → 16 hex hash
↓
embedding_cache 테이블에서 조회
│
├─ 캐시 히트 → accessed_at 갱신 + BLOB 반환
└─ 캐시 미스 → embedder.embed_one(text) 호출 (async API)
→ BLOB 저장
→ LRU 방출:
"DELETE WHERE accessed_at ASC LIMIT (count - max_cache)"
cache_max 기본값 = 10,000개. embedding_provider = "none" 이면 이 전체 경로를 스킵.
recall() 전체 흐름
recall(query, limit, session_id, since, until)
│
├─ query 비어있음? → recall_by_time_only() (최신순 반환)
│
├─ 1. get_or_compute_embedding(query)
│ → query를 벡터로 변환 (캐시 우선)
│
├─ 2. fts5_search(conn, query, limit*2)
│ SQL: SELECT m.id, bm25(memories_fts) as score
│ FROM memories_fts f JOIN memories m ON m.rowid = f.rowid
│ WHERE memories_fts MATCH '"word1" OR "word2"'
│ ORDER BY score LIMIT ?
│ BM25는 음수 반환 (낮을수록 좋음) → negation해서 양수로
│
├─ 3. vector_search(conn, query_embedding, limit*2, ...)
│ 전체 embedding BLOB 스캔 + cosine_similarity 계산
│
├─ 4. hybrid_merge(vector_results, keyword_results, 0.7, 0.3, limit)
│ → 점수 정규화 + 가중 합산 + 중복 제거
│
├─ 5. 병합된 id로 IN 쿼리 (N+1 방지)
│ SELECT * FROM memories WHERE id IN (?, ?, ...)
│
├─ 6. since/until 필터, session_id 필터 적용
│
└─ 결과 없으면 LIKE 폴백
WHERE content LIKE '%word%' OR key LIKE '%word%'
hybrid_merge() — 핵심 로직
pub fn hybrid_merge(
vector_results: &[(String, f32)], // (id, cosine_sim 0~1)
keyword_results: &[(String, f32)], // (id, bm25_score)
vector_weight: f32, // 기본 0.7
keyword_weight: f32, // 기본 0.3
limit: usize,
) -> Vec<ScoredResult>
정규화 방법의 차이:
cosine_similarity → 이미 0~1 범위 (정규화 불필요)
BM25 → 양수이지만 범위 없음 (예: 0~100)
→ max(BM25) 로 나눠서 0~1로 정규화
최종 점수:
final_score = 0.7 * cosine_score + 0.3 * normalized_bm25_score
id가 양쪽에 모두 있으면 (같은 문서가 두 방법으로 다 걸린 경우):
final_score = 0.7 * 0.9 + 0.3 * 1.0 = 0.93 ← 둘 다 걸리면 높은 점수
store() 흐름
store(key, content, category, session_id)
│
├─ 1. get_or_compute_embedding(content) → embedding BLOB
│
└─ 2. INSERT OR REPLACE INTO memories
(id, key, content, category, embedding, ...)
ON CONFLICT(key) DO UPDATE SET
content = excluded.content,
embedding = excluded.embedding,
...
→ key 충돌 시 upsert (기존 행 업데이트)
→ 트리거가 FTS5도 자동 동기화
upsert라서 같은 key로 저장하면 내용이 교체돼. "사용자는 Rust를 선호한다" → "사용자는 Python을 선호한다"로 업데이트 가능.
tokio 비동기 + blocking 패턴
SQLite는 동기 라이브러리(rusqlite)야. async runtime 안에서 blocking 호출을 직접 쓰면 runtime thread가 막혀.
ZeroClaw의 해결 방법:
// 임베딩 계산 (async I/O) → tokio 런타임에서 직접
let embedding = self.embedder.embed_one(text).await?;
// SQLite 작업 (blocking) → spawn_blocking으로 OS 스레드에 위임
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let conn = conn.lock(); // parking_lot Mutex
conn.execute("INSERT ...", ...)?;
Ok(())
}).await??;
Arc<Mutex<Connection>> 패턴으로 스레드 간 Connection 공유. parking_lot::Mutex는 표준 라이브러리 Mutex보다 빠르고 공정성 보장.
로컬 환경 설정 예시
VRAM 16GB 기준:
[memory]
backend = "sqlite"
embedding_provider = "none" # 임베딩 API 콜 없음, FTS 전용
vector_weight = 0.0
keyword_weight = 1.0
의미 검색 추가 시:
embedding_provider = "ollama"
embedding_model = "nomic-embed-text" # 274M 파라미터, 768차원
embedding_dimensions = 768
vector_weight = 0.7
keyword_weight = 0.3
Ollama로 로컬 임베딩 실행 → 외부 API 없이 벡터 검색 가능.
관련
- memory-system — Memory trait + 백엔드 선택
- agent-loop — turn()에서 recall 호출
- config-schema — [memory] 설정
- overview — ZeroClaw 학습 지도