지식과 기억, 왜 서로 다른 서랍에 담았는가
oxios는 "아는 것"과 "겪어서 알게 된 것"을 서로 다른 규칙으로 다루는 두 개의 저장소를 두었습니다. 이 문서는 그 설계를 실제 소스코드(커밋 d7ee269, oxios-memory·oxios-markdown 기준)까지 확인해가며 한 줄씩 풀어 설명합니다.
에디터로 쓰는 평문 파일
사용자가 직접 쓰고 고치는 마크다운 저장소. 파일이 원본이고, 색인은 그 파일에서 언제든 다시 만들 수 있는 부산물입니다.
- 정통 RAG — 청킹 → 색인 → 질의 시 검색해 주입
- 문서 간 연결은 양방향 백링크로 추적
- 생애주기 없음, 공간 전체에 하나
에이전트가 스스로 쌓는 기록
작업 중 알아낸 것을 남기는 구조화된 저장. 회상은 RAG와 같지만, 감쇠·보호·압축·정리가 붙어 있다는 점이 정적 문서 검색과 다릅니다.
- 인지과학 모델에서 따온 9개 타입
- 워크스페이스마다 독립, 일부 타입만 전역
- 에디터로 못 고침 — API로만 접근
파일이 원본이고, 색인은 부산물이다
지식은 oxios-markdown 크레이트가 다룹니다. 이 크레이트의 모듈 문서는 스스로를 이렇게 못박아 둡니다 — "커널 의존성 없음. AI 의존성 없음." 즉 파일시스템과 파일명 검색, 백링크 색인만 이 크레이트의 일입니다.
사용자가 oximemo·Obsidian·VS Code 등 자기가 쓰던 에디터에서 마크다운 파일을 그대로 쓰고 고칩니다. oxios는 이 파일을 소유하지 않고 읽기만 합니다.
파일이 바뀌면 노트 전체가 별도의 brain 데몬으로 전달되어 의미 기반 색인이 이루어집니다 — 실제 RAG 청킹·임베딩은 oxios-markdown이 아니라 커널의 KnowledgeLens가 위임받아 처리합니다.
질의가 들어오면 recall_for_context가 관련 노트를 찾아 시스템 프롬프트에 그대로 이어붙입니다 — "검색해 주입한다"는 표현 그대로입니다.
파일을 삭제·이동해도 걱정할 게 없습니다 — 색인은 디렉터리 전체를 다시 훑어 처음부터 새로 만들 수 있는 파생물이기 때문입니다.
백링크는 두 문법을 모두 인식한다
문서 간 연결은 [[위키링크]]와 [텍스트](경로) 마크다운 링크 둘 다에서 추출됩니다. 정방향 링크를 모으면서 동시에 역방향 해시맵(backward: HashMap<String, HashSet<String>>)을 함께 채워두기 때문에, "이 문서를 누가 참조하고 있는지"는 O(1) 조회로 바로 나옵니다.
KnowledgeBase::index_all()은 디렉터리 전체를 걸어 파일마다 백링크를 지우고 처음부터 다시 채우는 완전 재색인 함수입니다. 이 크레이트 어디에도 decay 관련 코드는 없습니다 — "생애주기가 없다"는 설명 그대로, 감쇠라는 개념 자체가 존재하지 않습니다.
겪어서 알게 된 것은 다른 규칙으로 쌓인다
기억은 이제는 삭제된 oxios-memory 크레이트(2026-08-15, RFC-047로 은퇴)가 구현했던 서랍입니다. 삭제 직전 커밋을 열어보면 타입 정의 바로 위에 이런 주석이 있습니다.
/// Memory entry type — 9 types derived from the SOAR/ACT-R cognitive model.
SOAR와 ACT-R은 인간의 기억을 절차 기억·의미 기억·일화 기억 등으로 나누어 설명하는 대표적인 인지 아키텍처 모델입니다. "9개 타입"이 정확히 그 아래 나열되어 있습니다.
중요도가 아니라 "언제 불러올지"의 세 단계
세 단계로 분류된다는 설명은 MemoryTier와 맞아떨어집니다 — 다만 기준은 중요도 점수 자체가 아니라 얼마나 자주 컨텍스트에 올라오는가입니다.
| 티어 | 동작 | 기준 |
|---|---|---|
| Hot | 항상 에이전트 컨텍스트에 로드 | ~50건 · ~3,000 토큰 |
| Warm | 필요할 때만 불러옴 | ~500건 · ~50,000 토큰 |
| Cold | 압축된 장기 보관 | ~10,000건 · 무제한 |
이 티어와 별도로, "얼마나 쉽게 잊혀도 되는가"를 정하는 5단계 ProtectionLevel이 있습니다 — 생애주기 절에서 다룹니다.
/// Whether this type is stored globally (cross-Space).
pub fn is_global(&self) -> bool {
matches!(self, MemoryType::UserProfile | MemoryType::Preference)
} 이 함수가 설계 의도를 정확히 담고 있습니다. 다만 정직하게 덧붙이면 — 삭제 직전 커밋 기준으로 이 함수를 실제로 호출하는 코드는 크레이트 안에 없었습니다. 워크스페이스 격리는 이 함수의 분기가 아니라, 워크스페이스마다 memory.db 파일 자체를 따로 두는 방식으로 이루어지고 있었습니다. 의도는 코드에 명시돼 있고, 배선까지 끝난 건 이 시점 다음이었을 수 있습니다.
두 축 모두 같은 파이프라인으로 꺼낸다
지식이든 기억이든, 일단 색인된 뒤에는 똑같은 방식으로 검색됩니다. 아래는 그 검색 계층 자체 — 벡터 검색, BM25, RRF, 재순위 — 를 하나씩 풀어본 것입니다.
"검색은 한 가지만 쓰지 않았습니다 — 벡터 검색만으로는 함수명 같은 정확 일치 질의를 놓치고, BM25만으로는 표현이 다른 같은 뜻을 놓쳐, 순위의 역수를 더하는 RRF로 융합했습니다. 융합 결과를 직접 구현한 Flash Attention으로 재순위해 문맥상 관련성을 보완합니다."
왜 하나로는 부족한가
코드베이스를 검색할 때 들어오는 질의는 크게 두 종류입니다. 하나는 정확한 이름을 아는 경우, 다른 하나는 기능을 말로 설명하는 경우입니다. 이 둘은 정반대의 검색 방식을 요구합니다.
벡터 검색 — 의미를 좌표로 바꿔서 가까운 것을 찾는다
임베딩 모델이 텍스트를 고정 길이 숫자 벡터로 바꿔 미리 인덱스에 저장해둡니다.
질의가 들어오면 같은 모델로 질의도 벡터로 바꿉니다.
코사인 유사도가 가장 큰, 즉 벡터 공간에서 가장 가까운 문서를 찾습니다.
"가깝다"는 표현이 같다는 뜻이 아니라 뜻이 비슷하다는 뜻입니다 — 그래서 동의어·패러프레이즈에 강합니다.
강점
표현이 달라도 의미가 같으면 찾는다.
약점
함수명·변수명처럼 희귀하고 정확한 토큰의 형태는 오히려 흐려지기 쉽다.
BM25 — 단어가 겹치는지, 얼마나 희귀한지를 본다
핵심 아이디어는 세 가지입니다 — 단어 빈도(TF)는 포화되고, 역문서빈도(IDF)는 희귀한 단어에 더 큰 가중치를 주며, 문서 길이 정규화가 긴 문서의 우연한 이점을 보정합니다.
- f(t,D)
- — 문서 D에서 단어 t가 등장한 횟수
- IDF(t)
- — 단어 t가 얼마나 희귀한지
- |D| / avgdl
- — 문서 길이가 평균보다 얼마나 긴지
- k₁, b
- — 포화·길이보정 정도를 조절하는 상수
SELECT rowid, id, -bm25(memories_fts) as score FROM memories_fts WHERE memories_fts MATCH ?1 ORDER BY score DESC LIMIT ?2
위 공식을 직접 구현하지는 않았습니다. SQLite FTS5에 내장된 bm25() 함수를 그대로 씁니다. 이 코드가 실제로 하는 일은 CJK(한중일) 문자를 한 글자씩 쪼개 OR로 묶는 질의 전처리 — 검증된 랭킹 함수를 직접 재구현하지 않고 그대로 재사용한 선택입니다.
RRF — 점수가 아니라 등수로 두 순위표를 합친다
벡터 검색의 점수(코사인 유사도)와 BM25의 점수는 스케일이 완전히 다릅니다. 그냥 더하면 스케일이 큰 쪽이 결과를 지배합니다. RRF는 점수를 버리고 몇 등인지(등수)만 가져와 등수의 역수를 더합니다.
- ranki(d)
- — 검색기 i가 매긴 문서 d의 등수
- k
- — 완충 상수, 보통 60을 사용
*scores.entry(*id).or_default() += 1.0 / (k + rank as f64 + 1.0); // ... let fused = reciprocal_rank_fusion(tier_results, 60.0);
공식과 상수 모두 그대로입니다. k=60이 실제 호출부에 하드코딩되어 있습니다.
예시로 계산해보기
같은 질의에 대해 벡터 검색과 BM25가 각각 매긴 순위가 다음과 같다고 해봅시다 (k=60).
| 함수 | 벡터 순위 | BM25 순위 | RRF 계산 | RRF 점수 | 최종 순위 |
|---|---|---|---|---|---|
| funcA | 1등 | 3등 | 1/61 + 1/63 | 0.0323 | 공동 1위 |
| funcC | 3등 | 1등 | 1/63 + 1/61 | 0.0323 | 공동 1위 |
| funcB | 2등 | 5등 | 1/62 + 1/65 | 0.0315 | 공동 3위 |
| funcE | 5등 | 2등 | 1/65 + 1/62 | 0.0315 | 공동 3위 |
| funcD | 4등 | — | 1/64 | 0.0156 | 공동 5위 |
| funcF | — | 4등 | 1/64 | 0.0156 | 공동 5위 |
양쪽에서 골고루 인정받은 funcA·funcC가 공동 1위로 오르고, 한쪽 명단에만 있던 funcD·funcF는 순위가 크게 밀립니다. RRF는 한쪽만 확신하는 결과보다 양쪽이 동의하는 결과를 신뢰합니다.
저장 방식 — 기본은 JSON, SQLite는 얹는 가속 계층
자기소개서의 "SQLite 또는 JSON"은 데이터 종류별로 나눠 쓴다는 뜻이 아닙니다. 메모리 항목 하나하나는 항상 타입별 디렉터리 아래 개별 JSON 파일로 저장됩니다 — 이게 원본입니다. SQLite는 그 위에 선택적으로 얹는 검색 가속 계층입니다.
[features] default = [] sqlite-memory = ["dep:rusqlite", "dep:sqlite-vec"] #[cfg(feature = "sqlite-memory")] pub mod sqlite;
기본값은 꺼져 있습니다. 이 feature를 켜야 앞서 본 BM25·RRF·벡터 검색·Flash Attention 코드가 통째로 컴파일에 포함됩니다.
#[cfg(feature = "sqlite-memory")]
if let Some(ref sqlite) = self.sqlite_store {
return sqlite.list_by_tier(tier, limit); // 빠른 경로
}
// ... 없으면 JSON 파일을 전부 읽어 브루트포스로 처리 SQLite가 설정돼 있으면 검색·조회를 그쪽에 위임하고, 없으면 JSON 파일을 전부 읽는 느린 폴백으로 떨어집니다. 이론상의 선택지가 아니라 실제로는 거의 항상 켜져 있었습니다 — 마이그레이션 직전 oxios-kernel은 이 feature를 기본으로 켜서 넘겨주고 있었습니다(default = ["sqlite-memory"]).
청킹 — 문장을 살려서 자르기
지식이든 기억이든 색인 전에는 일정 크기로 잘라야 합니다. 이 크레이트에는 두 전략이 각각 구현되어 있습니다.
고정 크기 (chunk_fixed)
문자 512자 단위로 슬라이딩 윈도, 64자씩 겹치게. 크기는 균일하지만 문장 중간에서 잘릴 수 있습니다.
문단 경계 (chunk_paragraphs)
빈 줄(\n\n) 기준으로 문단째로 묶어 512자까지 채웁니다. 문단은 항상 통째로 살아남습니다.
max_chunk_size: 512, overlap: 64, min_chunk_size: 50
다만 이 시점 코드베이스에서는 두 함수가 하나로 합쳐 호출되지는 않고 있었습니다 — 각자 독립된 유틸리티로 존재하며, 서로 다른 상황(균일한 크기가 필요할 때 vs 문장을 살려야 할 때)에 쓰도록 설계된 두 개의 조각입니다.
임베딩 — 환경에 따라 세 단계로 바꿔 끼운다
벡터 검색에는 임베딩이 필요합니다. 설정값 하나(provider)로 세 단계 중 하나를 고릅니다.
| 단계 | 구현 | 특징 |
|---|---|---|
| api | 원격 임베딩 API | 네트워크 필요, 가장 정확 |
| gguf | EmbeddingGemma-300m (로컬 GGUF) | 오프라인, aarch64에서 기능 플래그로 활성화 |
| tfidf | TF-IDF | 항상 사용 가능한 기본값 |
기본값은 "tfidf"입니다 — 무엇도 설정하지 않아도 검색이 동작하도록, 가장 가벼운 단계를 기본으로 깔아둔 선택입니다.
pub struct TextVector { tf: HashMap<String, f64> } // 문서빈도(IDF) 없음 — 정규화된 TF뿐
impl EmbeddingProvider for TfIdfEmbeddingProvider {
async fn embed(&self, text: &str) -> Result<EmbeddingVector> {
Ok(EmbeddingVector::Sparse(TextVector::from_text(text).tf_map().clone()))
}
} 이름은 "TF-IDF"지만 실제로는 IDF가 없습니다. 코퍼스 전체에서 얼마나 흔한 단어인지는 세지 않고, 문서 안에서의 등장 비율(TF)만 계산합니다 — 흔한 단어와 희귀한 단어가 같은 무게로 취급됩니다.
꺼지면 무슨 일이 벌어지나
EmbeddingVector는 세 형태 중 하나입니다 — API/GGUF는 고정 차원 실수 배열(Dense/DenseF32)을, TF-IDF는 "단어 → 가중치" 해시맵(Sparse)을 만듭니다. SQLite의 벡터 인덱스와 Flash Attention은 둘 다 고정 차원 배열만 받습니다.
let f32_vec = match vec.to_f32_dense() {
Some(v) => v,
None => return Ok(None), // Sparse(TF-IDF)는 여기서 항상 None
}; TF-IDF 벡터는 to_f32_dense()가 항상 None을 돌려줍니다. 그 결과가 그대로 위로 전파돼 벡터 검색과 재순위 둘 다를 건너뛰게 만듭니다.
| 임베딩 | 벡터 검색 (sqlite-vec) | RRF | Flash Attention 재순위 |
|---|---|---|---|
| api / gguf | 정상 동작 | 벡터+BM25 두 채널 융합 | 정상 동작 |
| tfidf | 건너뜀 (None) | BM25 한 채널만 | 건너뜀 — 후보를 그대로 반환 |
임베딩 모델이 없으면 "정확도가 조금 낮은 벡터 검색"이 되는 게 아니라, 벡터 검색과 재순위 단계 자체가 통째로 빠지고 BM25 단독 검색으로 되돌아갑니다.
Attention이란 무엇인가
질의 하나(Query)를 후보 여럿(Key)과 각각 내적해 "얼마나 관련 있는지" 점수를 매기고, 그 점수를 소프트맥스로 정규화해 합이 1인 가중치로 바꾼 다음, 그 비율대로 후보들의 값(Value)을 섞어 새 벡터 하나를 만들어내는 계산입니다. 한 줄로 줄이면 — 가중 평균인데, 가중치를 질의가 직접 정한다.
| 후보 | 질의와의 내적 점수 | softmax 가중치 |
|---|---|---|
| A | 2.4 | 0.65 |
| B | 1.1 | 0.22 |
| C | -0.3 | 0.13 |
점수가 높을수록 소프트맥스가 그 차이를 증폭합니다 — A가 C보다 2.7점 앞섰을 뿐인데 가중치는 5배 차이가 납니다. 왜 필요한가: RRF까지는 후보 각각을 독립적으로 채점했을 뿐, 후보들끼리 서로 어떤 관계인지는 한 번도 보지 않았습니다. Attention은 후보 전체를 한 번에 놓고 "질의가 실제로 어느 쪽을 가리키는지"를 다시 계산합니다.
Flash Attention 재순위 — 추려낸 후보만 다시 정밀하게 대조
RRF는 등수만 보고 빠르게 합친 결과일 뿐입니다. 벡터 검색조차 질의 벡터와 문서 벡터를 미리 따로 만들어 거리만 재는 방식(bi-encoder)이라, 질의의 어느 단어가 문서의 어느 부분과 연결되는지는 보지 못합니다. 재순위는 RRF로 추려낸 상위 후보만 골라 질의와 문서를 함께 놓고 attention으로 한 번 더 대조합니다.
//! Flash Attention — block-wise attention for O(N) memory usage. //! Triton-inspired CPU implementation... //! Reference: "FlashAttention: Fast and Memory-Efficient //! Exact Attention with IO-Awareness" (Dao et al., 2022)
실제로 러닝 맥스를 유지하며 블록 단위로 소프트맥스를 다시 스케일링하는 online-softmax 로직이 그대로 구현되어 있습니다 — 라이브러리 호출이 아니라 직접 짠 코드입니다. 같은 크레이트에는 전체 행렬을 그대로 계산하는 naive_attention도 있어서, 둘의 차이를 자체 테스트로 검증합니다.
이 구현이 실제로 갖는 특성들
이름만 빌려온 게 아니라는 걸, 테스트가 하나하나 증명해둔 방식으로 보여줍니다.
let fa1 = FlashAttention::new(FlashAttentionConfig { block_size: 2, ..Default::default() });
let fa2 = FlashAttention::new(FlashAttentionConfig { block_size: 32, ..Default::default() });
// ...
assert!((v1 - v2).abs() < 1e-4, "Block size shouldn't affect output: {} vs {}", v1, v2); 블록을 잘게 쪼개든 크게 묶든 결과가 같아야 한다는 걸 테스트로 못박아뒀습니다 — "타일링해도 정답은 안 바뀐다"는 Flash Attention 논문의 핵심 주장을 이 크레이트 안에서 직접 재현·검증한 셈입니다.
let mut rng_state = 42u64; rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); let val = ((rng_state >> 33) as f32 / (1u64 << 31) as f32) - 1.0;
rand 같은 외부 크레이트를 쓰지 않고, LCG(선형 합동 생성기)를 직접 굴려 테스트용 벡터를 만듭니다. 시드가 고정돼 있어(42u64) 매번 같은 벡터가 나오고, benchmark() 메서드는 이 벡터들로 naive와 flash를 둘 다 돌려 속도차와 결과 일치 여부(5% 이내)를 런타임에 스스로 검증합니다.
결과가 실제로 어떻게 나오는가
let candidates = self.recall(query, max_recall * 3).await?; // RRF 결과의 3배를 미리 뽑음
let queries = vec![query_vec];
let keys: Vec<Vec<f32>> = candidate_vecs.iter().map(|(_, v)| v.clone()).collect();
let attention_output = fa.attention(&queries, &keys, &keys); // Key = Value로 자기 자신을 참조
let output = attention_output.first()...;
let mut scored: Vec<(MemoryEntry, f32)> = candidate_vecs.into_iter().zip(keys.iter())
.map(|((entry, _), key_vec)| (entry, cosine_similarity(output, key_vec)))
.collect();
scored.sort_by(|a, b| b.1.partial_cmp(&a.1)...); // 이 코사인 유사도로 최종 정렬 질의 벡터 하나를 Query로, 후보 전체의 벡터를 Key이자 Value로 넣습니다. attention의 출력은 후보들의 가중 평균 벡터 하나입니다 — 위 표처럼 질의와 가까운 후보(A, B) 쪽으로 쏠린 벡터입니다. 그다음이 핵심입니다: 이 블렌드 벡터와 각 후보를 다시 한번 코사인 유사도로 비교해 최종 순위를 매깁니다.
A·B처럼 서로 비슷하면서 질의와도 가까운 후보들은 블렌드 벡터에 가장 많이 기여했으니, 그 블렌드 벡터와도 자연히 가장 가깝습니다 — 순위가 더 단단해집니다. C처럼 혼자 동떨어진 후보는 블렌드 벡터에 거의 기여하지 못했으니 재채점에서 더 밀려납니다. "질의와 개별적으로 얼마나 비슷한가"가 아니라 "다른 좋은 후보들과 함께 놓고 봐도 여전히 질의를 잘 설명하는가"로 다시 채점하는 것 — RRF가 등수로 하던 다수결을, 재순위는 벡터 공간에서 한 번 더 하는 셈입니다.
직접 밟아보기 — RRF가 넘긴 6개 후보, 재순위 전후
위 RRF 절에서 쓴 그 6개 후보(funcA~F)를 그대로 이어씁니다. 실제 벡터는 수백 차원이라 눈으로 볼 수 없으니, 같은 수식(내적 → softmax → 가중합 → 코사인 유사도)을 2차원으로 단순화해 실제로 계산한 값입니다 — 숫자는 꾸며낸 게 아니라 아래 좌표로 직접 뽑아낸 결과입니다.
RRF가 넘긴 순서
| 후보 | RRF 순위 |
|---|---|
| funcA | 공동 1위 |
| funcC | 공동 1위 |
| funcB | 공동 3위 |
| funcE | 공동 3위 |
| funcD | 공동 5위 |
| funcF | 공동 5위 |
이 여섯을 질의 벡터 하나와 함께 attention에 넣습니다. Key=Value로 자기 자신을 참조합니다.
다만 이건 트랜스포머 cross-encoder처럼 문장을 토큰 단위로 쪼개 서로 대조하는 방식은 아닙니다 — 후보 하나당 벡터 하나로 뭉친 뒤, 그 벡터들 사이에서 attention 수식만 돌리는 방식입니다.
에이전트가 스스로 서랍을 여닫는 법
실제로는 "기억 도구" 하나가 아니라 세 개의 개별 도구로 나뉘어 있습니다.
"사용자에 대한 사실, 행동 패턴, 세션 관찰, 선호 교정을 저장 — 에이전트 내부용. 세션을 넘어 유지된다."
id를 주면 그 항목의 현재 상태를, 생략하면 조립된 컨텍스트를 돌려줍니다.
하이브리드(어휘+의미) 검색으로 기억을 찾습니다.
지식 도구는 설명대로 단 하나이고, 동작 이름으로 분기하는 구조입니다.
"개인 마크다운 볼트 — 문서, 글, 노트, 일지. 파일 기반이며 백링크·전문 검색·디렉터리 구조를 지원한다."
설명하신 여섯 가지 핵심 동작은 정확히 다 있습니다. 실제 액션 목록은 그보다 훨씬 넓어서, 체크리스트(checklist_add/complete), 대화 로그(chat_append), 일지(journal_add), 습관 추적(habits) 같은 21개 동작이 더 있습니다 — 노트 도구로 시작했다가 개인 운영체제 수준으로 자라난 흔적입니다.
쌓이기만 하면 찾는 데 방해가 된다
기억 하나에는 서로 다른 질문에 답하는 세 개의 독립된 값이 동시에 붙어 있습니다. 이름이 비슷비슷해서 헷갈리기 쉬운데, 먼저 나란히 놓고 구분해두면 아래 내용이 훨씬 잘 들어옵니다.
| 축 | 값 | 무엇을 답하는가 | 언제·왜 바뀌는가 |
|---|---|---|---|
| MemoryTier (Hot/Warm/Cold) | 3단계 | 지금 당장 에이전트 컨텍스트에 불러올지 | Hot 정원(50건)이 차면, 보호 등급이 낮고 이미 많이 잊힌 것부터 Warm으로 밀려남 |
| ProtectionLevel (None~Permanent) | 5단계 | 감쇠에 얼마나 저항하는지 | 접근 횟수·세션 등장·사용자 교정으로 자동 오르내림 (아래 "자동 보호" 절) |
| CompactionTier (Raw~Root) | 5단계 | 본문이 원문인지 요약본인지 | Dream이 오래된 기억을 묶어 압축할 때 한 단계씩 진행 (아래 "압축과 정리" 절) |
셋을 잇는 실은 decay_score(0~1 사이 연속값, 에빙하우스 곡선에서 나옵니다)입니다 — 보호 등급이 이 점수가 떨어지는 속도를 정하고, 그 점수가 다시 Hot 자리를 지킬지 정하는 순번이 됩니다. Compaction은 이 둘과는 별개로, "내용 자체를 얼마나 짧게 요약했는가"만 다루는 완전히 다른 축입니다.
// 타입마다 시작 티어가 다름 UserProfile · Preference · Decision · Fact → 처음부터 Hot Knowledge · Skill · Episode · Session · Conversation → 처음부터 Warm // 보호 등급이 감쇠 "속도"를 직접 곱함 None → ×1.0 Low → ×0.5 Medium → ×0.2 High → ×0.05 Permanent → ×0.0(불변)
보호 등급이 한 단계씩 오를 때마다 감쇠 속도가 눈에 띄게 느려지도록 설계되어 있습니다 — High가 되면 사실상 20배 느리게 잊히고, Permanent는 아예 멈춥니다.
candidates.sort_by(|a, b| {
a.protection.cmp(&b.protection) // 1순위: 보호 등급 낮은 것부터
.then(a.decay_score.partial_cmp(&b.decay_score)...) // 2순위: 더 잊힌 것부터
});
// 정렬된 순서대로 Hot 정원을 넘은 만큼 Warm으로 강등 Hot 자리에 50건보다 많이 몰리면, 보호 등급이 낮고 decay_score가 낮은(오래 안 꺼내본) 순서로 밀려납니다 — Hot/Warm/Cold는 "얼마나 중요한가"가 아니라 정원이 있는 자리다툼이고, 감쇠·보호가 그 다툼의 순위를 매겨주는 겁니다.
감쇠 — 에빙하우스 망각 곡선
//! Ebbinghaus-inspired decay engine for memory importance scoring. //! Implements a forgetting curve: R(t) = e^(-rate × t) let effective_rate = base_rate * multiplier * protection_mult / access_boost; let retention = (-effective_rate * hours_since_access).exp();
자주 꺼낸 기억이 덜 잊히는 이유는 access_boost가 감쇠율을 나누기 때문입니다 — 접근이 많을수록 분모가 커져 유효 감쇠율이 낮아집니다.
중요도 — 실제 공식
/// Effective importance = base_importance × (1 + ln(1 + access_count)) × decay_score. importance * (1.0 + (1.0 + access_count as f32).ln()) * decay_score
"보존도(retention) × 접근 횟수의 로그"라는 설명과 결이 같습니다 — 다만 실제로는 로그값에 1을 더한 배수를 base_importance와 decay_score 양쪽에 곱하는 형태입니다.
자동 보호 — 오르내리는 다섯 단계
보호 등급은 승급만 있는 게 아니라 강등도 있습니다 — 접근이 끊기면 시간이 지나며 다시 낮아집니다.
"2회 접근 → 낮은 보호, 5회나 교정 → 높은 보호"라는 설명은 실제로는 그 사이에 Medium 단계가 하나 더 있는 4단계 사다리입니다.
압축과 정리 — 5단계로 내려가며 요약
Dream — 네 단계 배경 작업
모듈 문서는 정확히 "4-phase background memory consolidation"이라 못박고 있고, DreamCheckpoint는 completed_phase·cached_signals·cached_plan·cached_state를 저장하며 age.num_hours() >= 1일 때만 처음부터 다시 시작합니다. 다만 Phase 4(Prune & Index) 구현 내부에는 하이퍼볼릭 임베딩 재구성과 Flash Attention 재순위가 각각 "Phase 5", "Phase 6"이라는 이름으로, PageRank 점수 계산(감쇠 0.85, 30회 반복)은 Phase 2 안에 더 세분화되어 있습니다 — 코드 자체가 "4단계"와 "실제로는 더 잘게 나뉜 하위 단계들" 사이에서 스스로도 정확히 통일돼 있지는 않습니다.
질의 시점과 배경 작업은 분리되어 있다
이 한 문단에 압축된 판단들
지식과 기억을 나눈 것도, 나눈 뒤 다시 검색 계층을 공유시킨 것도, 기억이 스스로 관리되게 만든 것도 — 전부 서로 다른 문제에 서로 다른 도구를 고르고, 그 이유를 설명할 수 있는 판단입니다.
- 경계 설계 — 사용자가 소유해야 할 것(지식)과 에이전트가 스스로 쌓아야 할 것(기억)을 구분하고, 각각에 맞는 저장 규칙(생애주기 없음 vs 감쇠·보호·압축)을 따로 두었다.
- 재사용 가능한 공통 계층 — 서로 다른 두 축이라도 "찾아서 꺼내는" 문제는 같았고, 검색 계층 하나로 통일해 벡터·BM25·RRF·재순위를 두 번 만들지 않았다.
- 기억이 스스로 관리되게 — 감쇠·보호·압축·Dream까지, 사람이 손대지 않아도 자주 쓰는 기억은 살아남고 안 쓰는 기억은 옅어지도록 생애주기 전체를 설계했다.