research

ZeroClaw — SQLite + 벡터 검색 내부

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 기준
);

핵심: embeddingmemories 테이블 안에 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 없이 벡터 검색 가능.


관련