자기소개서 한 문단 뜯어보기

지식과 기억, 왜 서로 다른 서랍에 담았는가

oxios는 "아는 것"과 "겪어서 알게 된 것"을 서로 다른 규칙으로 다루는 두 개의 저장소를 두었습니다. 이 문서는 그 설계를 실제 소스코드(커밋 d7ee269, oxios-memory·oxios-markdown 기준)까지 확인해가며 한 줄씩 풀어 설명합니다.

지식 · Knowledge

에디터로 쓰는 평문 파일

사용자가 직접 쓰고 고치는 마크다운 저장소. 파일이 원본이고, 색인은 그 파일에서 언제든 다시 만들 수 있는 부산물입니다.

  • 정통 RAG — 청킹 → 색인 → 질의 시 검색해 주입
  • 문서 간 연결은 양방향 백링크로 추적
  • 생애주기 없음, 공간 전체에 하나
기억 · Memory

에이전트가 스스로 쌓는 기록

작업 중 알아낸 것을 남기는 구조화된 저장. 회상은 RAG와 같지만, 감쇠·보호·압축·정리가 붙어 있다는 점이 정적 문서 검색과 다릅니다.

  • 인지과학 모델에서 따온 9개 타입
  • 워크스페이스마다 독립, 일부 타입만 전역
  • 에디터로 못 고침 — API로만 접근

파일이 원본이고, 색인은 부산물이다

"지식은 사용자가 일상적으로 쓰는 마크다운 문서 저장소로, 에디터로 직접 쓰고 고치는 평문 파일입니다. 정통 RAG 구조를 따라 문서를 청킹해 색인하고 질의가 들어오면 검색해 주입합니다. 문서 간 연결은 양방향 백링크로 추적합니다. 생애 주기는 없고, 기준은 평문 파일이라 색인은 언제든 다시 만들 수 있습니다." — 자기소개서 원문

지식은 oxios-markdown 크레이트가 다룹니다. 이 크레이트의 모듈 문서는 스스로를 이렇게 못박아 둡니다 — "커널 의존성 없음. AI 의존성 없음." 즉 파일시스템과 파일명 검색, 백링크 색인만 이 크레이트의 일입니다.

1

사용자가 oximemo·Obsidian·VS Code 등 자기가 쓰던 에디터에서 마크다운 파일을 그대로 쓰고 고칩니다. oxios는 이 파일을 소유하지 않고 읽기만 합니다.

2

파일이 바뀌면 노트 전체가 별도의 brain 데몬으로 전달되어 의미 기반 색인이 이루어집니다 — 실제 RAG 청킹·임베딩은 oxios-markdown이 아니라 커널의 KnowledgeLens가 위임받아 처리합니다.

3

질의가 들어오면 recall_for_context가 관련 노트를 찾아 시스템 프롬프트에 그대로 이어붙입니다 — "검색해 주입한다"는 표현 그대로입니다.

4

파일을 삭제·이동해도 걱정할 게 없습니다 — 색인은 디렉터리 전체를 다시 훑어 처음부터 새로 만들 수 있는 파생물이기 때문입니다.

백링크는 두 문법을 모두 인식한다

문서 간 연결은 [[위키링크]][텍스트](경로) 마크다운 링크 둘 다에서 추출됩니다. 정방향 링크를 모으면서 동시에 역방향 해시맵(backward: HashMap<String, HashSet<String>>)을 함께 채워두기 때문에, "이 문서를 누가 참조하고 있는지"는 O(1) 조회로 바로 나옵니다.

코드 확인 · oxios-markdown

KnowledgeBase::index_all()은 디렉터리 전체를 걸어 파일마다 백링크를 지우고 처음부터 다시 채우는 완전 재색인 함수입니다. 이 크레이트 어디에도 decay 관련 코드는 없습니다 — "생애주기가 없다"는 설명 그대로, 감쇠라는 개념 자체가 존재하지 않습니다.

겪어서 알게 된 것은 다른 규칙으로 쌓인다

"기억은 에이전트가 작업 중 알아낸 것을 남기는 구조화된 저장으로, SQLite 또는 JSON에 저장합니다. 회상은 RAG와 같지만 감쇠·보호·압축·정리가 붙어 있다는 점에서 정적 문서 검색과 다릅니다. 기억 항목은 인지 과학 SOAR/ACT-R 모델에서 따온 아홉 타입으로, 중요도에 따라 세 단계로 분류됩니다. 사용자가 에디터로 고칠 수 없고 API로만 접근합니다." — 자기소개서 원문

기억은 이제는 삭제된 oxios-memory 크레이트(2026-08-15, RFC-047로 은퇴)가 구현했던 서랍입니다. 삭제 직전 커밋을 열어보면 타입 정의 바로 위에 이런 주석이 있습니다.

코드 확인 · memory/types.rs:35
/// Memory entry type — 9 types derived from the SOAR/ACT-R cognitive model.

SOAR와 ACT-R은 인간의 기억을 절차 기억·의미 기억·일화 기억 등으로 나누어 설명하는 대표적인 인지 아키텍처 모델입니다. "9개 타입"이 정확히 그 아래 나열되어 있습니다.

Conversation
대화 한 턴의 기록
Session
세션 단위 요약
Fact
확인된 사실 하나
Episode
특정 시점에 있었던 일
Knowledge
일반화된 앎
Skill
익힌 절차·방법
Preference
사용자의 선호
Decision
내려진 결정과 이유
UserProfile
사용자에 대한 안정적 사실

중요도가 아니라 "언제 불러올지"의 세 단계

세 단계로 분류된다는 설명은 MemoryTier와 맞아떨어집니다 — 다만 기준은 중요도 점수 자체가 아니라 얼마나 자주 컨텍스트에 올라오는가입니다.

티어동작기준
Hot항상 에이전트 컨텍스트에 로드~50건 · ~3,000 토큰
Warm필요할 때만 불러옴~500건 · ~50,000 토큰
Cold압축된 장기 보관~10,000건 · 무제한

이 티어와 별도로, "얼마나 쉽게 잊혀도 되는가"를 정하는 5단계 ProtectionLevel이 있습니다 — 생애주기 절에서 다룹니다.

지식 — 공간 전체에서 하나뿐인 볼트 워크스페이스 A 워크스페이스 B 기억 저장소 이 공간에서만 기억 저장소 이 공간에서만 UserProfile · Preference
지식은 공간 전체에 하나, 기억은 워크스페이스마다 따로 있지만 UserProfile·Preference 타입만은 모든 워크스페이스에서 공유되도록 설계되어 있다.
코드 확인 · memory/types.rs:140
/// 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으로 재순위해 문맥상 관련성을 보완합니다."

— 자기소개서 원문

왜 하나로는 부족한가

코드베이스를 검색할 때 들어오는 질의는 크게 두 종류입니다. 하나는 정확한 이름을 아는 경우, 다른 하나는 기능을 말로 설명하는 경우입니다. 이 둘은 정반대의 검색 방식을 요구합니다.

시나리오 A · 정확한 이름을 아는 질의
질의
"deleteUserAccount 함수 어디서 호출돼?"
= 토큰 그대로 일치
코드베이스
async function deleteUserAccount(id) { … }
BM25희귀 토큰 그대로 일치 → 최상위
벡터 검색의미가 비슷한 함수들과 뭉뚱그려짐
시나리오 B · 말로 풀어서 설명하는 질의
질의
로그인 실패하면 재시도하는 로직 어디 있어?
뜻이 비슷함
코드베이스
handleAuthRetry(attempt) // retries login after auth failure
BM25겹치는 단어가 거의 없어 순위 밖으로 밀림
벡터 검색의미가 가까워 상위권에 올라옴
V

벡터 검색 — 의미를 좌표로 바꿔서 가까운 것을 찾는다

1

임베딩 모델이 텍스트를 고정 길이 숫자 벡터로 바꿔 미리 인덱스에 저장해둡니다.

2

질의가 들어오면 같은 모델로 질의도 벡터로 바꿉니다.

3

코사인 유사도가 가장 큰, 즉 벡터 공간에서 가장 가까운 문서를 찾습니다.

4

"가깝다"는 표현이 같다는 뜻이 아니라 뜻이 비슷하다는 뜻입니다 — 그래서 동의어·패러프레이즈에 강합니다.

강점

표현이 달라도 의미가 같으면 찾는다.

약점

함수명·변수명처럼 희귀하고 정확한 토큰의 형태는 오히려 흐려지기 쉽다.

B

BM25 — 단어가 겹치는지, 얼마나 희귀한지를 본다

핵심 아이디어는 세 가지입니다 — 단어 빈도(TF)는 포화되고, 역문서빈도(IDF)는 희귀한 단어에 더 큰 가중치를 주며, 문서 길이 정규화가 긴 문서의 우연한 이점을 보정합니다.

score(D, Q) = Σt∈Q IDF(t) · f(t,D)·(k₁+1) / ( f(t,D) + k₁·(1 − b + b·|D|/avgdl) )
f(t,D)
— 문서 D에서 단어 t가 등장한 횟수
IDF(t)
— 단어 t가 얼마나 희귀한지
|D| / avgdl
— 문서 길이가 평균보다 얼마나 긴지
k₁, b
— 포화·길이보정 정도를 조절하는 상수
코드 확인 · sqlite/search/bm25.rs:77
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는 점수를 버리고 몇 등인지(등수)만 가져와 등수의 역수를 더합니다.

RRF(d) = Σi 1 / (k + ranki(d))
ranki(d)
— 검색기 i가 매긴 문서 d의 등수
k
— 완충 상수, 보통 60을 사용
코드 확인 · sqlite/search/rrf.rs:19, mod.rs:88
*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 점수최종 순위
funcA1등3등1/61 + 1/630.0323공동 1위
funcC3등1등1/63 + 1/610.0323공동 1위
funcB2등5등1/62 + 1/650.0315공동 3위
funcE5등2등1/65 + 1/620.0315공동 3위
funcD4등1/640.0156공동 5위
funcF4등1/640.0156공동 5위

양쪽에서 골고루 인정받은 funcA·funcC가 공동 1위로 오르고, 한쪽 명단에만 있던 funcD·funcF는 순위가 크게 밀립니다. RRF는 한쪽만 확신하는 결과보다 양쪽이 동의하는 결과를 신뢰합니다.

저장 방식 — 기본은 JSON, SQLite는 얹는 가속 계층

자기소개서의 "SQLite 또는 JSON"은 데이터 종류별로 나눠 쓴다는 뜻이 아닙니다. 메모리 항목 하나하나는 항상 타입별 디렉터리 아래 개별 JSON 파일로 저장됩니다 — 이게 원본입니다. SQLite는 그 위에 선택적으로 얹는 검색 가속 계층입니다.

코드 확인 · Cargo.toml, memory/mod.rs:64
[features]
default = []
sqlite-memory = ["dep:rusqlite", "dep:sqlite-vec"]

#[cfg(feature = "sqlite-memory")]
pub mod sqlite;

기본값은 꺼져 있습니다. 이 feature를 켜야 앞서 본 BM25·RRF·벡터 검색·Flash Attention 코드가 통째로 컴파일에 포함됩니다.

코드 확인 · manager/ops.rs:174
#[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자까지 채웁니다. 문단은 항상 통째로 살아남습니다.

코드 확인 · memory/chunking.rs:18
max_chunk_size: 512, overlap: 64, min_chunk_size: 50

다만 이 시점 코드베이스에서는 두 함수가 하나로 합쳐 호출되지는 않고 있었습니다 — 각자 독립된 유틸리티로 존재하며, 서로 다른 상황(균일한 크기가 필요할 때 vs 문장을 살려야 할 때)에 쓰도록 설계된 두 개의 조각입니다.

임베딩 — 환경에 따라 세 단계로 바꿔 끼운다

벡터 검색에는 임베딩이 필요합니다. 설정값 하나(provider)로 세 단계 중 하나를 고릅니다.

단계구현특징
api원격 임베딩 API네트워크 필요, 가장 정확
ggufEmbeddingGemma-300m (로컬 GGUF)오프라인, aarch64에서 기능 플래그로 활성화
tfidfTF-IDF항상 사용 가능한 기본값

기본값은 "tfidf"입니다 — 무엇도 설정하지 않아도 검색이 동작하도록, 가장 가벼운 단계를 기본으로 깔아둔 선택입니다.

코드 확인 · memory/types.rs:349, embedding.rs:128
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은 둘 다 고정 차원 배열만 받습니다.

코드 확인 · sqlite/store.rs:736
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)RRFFlash Attention 재순위
api / gguf정상 동작벡터+BM25 두 채널 융합정상 동작
tfidf건너뜀 (None)BM25 한 채널만건너뜀 — 후보를 그대로 반환

임베딩 모델이 없으면 "정확도가 조금 낮은 벡터 검색"이 되는 게 아니라, 벡터 검색과 재순위 단계 자체가 통째로 빠지고 BM25 단독 검색으로 되돌아갑니다.

Attention이란 무엇인가

질의 하나(Query)를 후보 여럿(Key)과 각각 내적해 "얼마나 관련 있는지" 점수를 매기고, 그 점수를 소프트맥스로 정규화해 합이 1인 가중치로 바꾼 다음, 그 비율대로 후보들의 값(Value)을 섞어 새 벡터 하나를 만들어내는 계산입니다. 한 줄로 줄이면 — 가중 평균인데, 가중치를 질의가 직접 정한다.

후보질의와의 내적 점수softmax 가중치
A2.40.65
B1.10.22
C-0.30.13

점수가 높을수록 소프트맥스가 그 차이를 증폭합니다 — A가 C보다 2.7점 앞섰을 뿐인데 가중치는 5배 차이가 납니다. 왜 필요한가: RRF까지는 후보 각각을 독립적으로 채점했을 뿐, 후보들끼리 서로 어떤 관계인지는 한 번도 보지 않았습니다. Attention은 후보 전체를 한 번에 놓고 "질의가 실제로 어느 쪽을 가리키는지"를 다시 계산합니다.

Flash Attention 재순위 — 추려낸 후보만 다시 정밀하게 대조

RRF는 등수만 보고 빠르게 합친 결과일 뿐입니다. 벡터 검색조차 질의 벡터와 문서 벡터를 미리 따로 만들어 거리만 재는 방식(bi-encoder)이라, 질의의 어느 단어가 문서의 어느 부분과 연결되는지는 보지 못합니다. 재순위는 RRF로 추려낸 상위 후보만 골라 질의와 문서를 함께 놓고 attention으로 한 번 더 대조합니다.

코드 확인 · memory/flash_attention.rs:2
//! 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도 있어서, 둘의 차이를 자체 테스트로 검증합니다.

이 구현이 실제로 갖는 특성들

이름만 빌려온 게 아니라는 걸, 테스트가 하나하나 증명해둔 방식으로 보여줍니다.

정확함 (근사 아님)
타일로 나눠 계산해도 naive와 수학적으로 동일한 결과. 오차는 알고리즘 근사가 아니라 부동소수점 반올림 수준(<2%).
블록 크기 불변
block_size를 2로 주든 32로 주든 출력이 1e-4 이내로 똑같음. 타일링이 "계산 순서"만 바꿀 뿐 결과엔 손대지 않는다는 증거.
수치적으로 안정적
러닝 맥스를 빼고 나서 지수함수를 계산해, 큰 점수에서 exp()가 무한대로 튀는 걸 막음.
온도로 분포 조절
temperature가 낮으면 소수 후보에 가중치가 쏠리고, 높으면 여러 후보에 고르게 퍼짐.
self / cross 겸용
같은 attention() 코어를 감싼 두 얇은 래퍼로 자기 자신을 보는 것과 다른 시퀀스를 보는 것 모두 지원.
외부 의존성 없음
행렬 연산 라이브러리 없이 순수 Rust. 벤치마크용 테스트 벡터조차 직접 만든 LCG로 생성.
코드 확인 · flash_attention.rs:503-534, test_block_size_effect
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 논문의 핵심 주장을 이 크레이트 안에서 직접 재현·검증한 셈입니다.

코드 확인 · flash_attention.rs:384-403, generate_test_vectors
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% 이내)를 런타임에 스스로 검증합니다.

결과가 실제로 어떻게 나오는가

코드 확인 · sqlite/store.rs:299-361, recall_with_rerank
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차원으로 단순화해 실제로 계산한 값입니다 — 숫자는 꾸며낸 게 아니라 아래 좌표로 직접 뽑아낸 결과입니다.

3.86 3.62 3.06 2.57 -0.70 -1.37 39.6% 31.1% 17.8% 10.9% 0.4% 0.2% 질의 funcA funcC funcB funcE funcD funcF 블렌드 벡터 블렌드 벡터 .979 .9995 .973 .918 .287 .118

RRF가 넘긴 순서

후보RRF 순위
funcA공동 1위
funcC공동 1위
funcB공동 3위
funcE공동 3위
funcD공동 5위
funcF공동 5위

이 여섯을 질의 벡터 하나와 함께 attention에 넣습니다. Key=Value로 자기 자신을 참조합니다.

질의 · 후보 내적 (스케일링 후)

실제로는 1/√d로 스케일링하지만, 여기선 2차원에 맞춰 차이가 잘 보이도록 단순화했습니다. 점수가 높을수록 방향이 질의와 가깝습니다.

Softmax 가중치

funcA
39.6%
funcC
31.1%
funcB
17.8%
funcE
10.9%
funcD
0.4%
funcF
0.2%

합은 항상 100%. A·C 둘이서 70.7%를 가져가고, D·F는 합쳐서 0.6%뿐입니다.

가중 평균으로 블렌드 벡터 만들기

각 후보 벡터를 자기 가중치만큼만 반영해 더합니다. 결과는 A·C 방향으로 크게 기운, 길이가 1보다 짧은 벡터입니다(여러 방향의 평균이라 짧아지는 게 정상입니다) — 방향은 약 26.8°로, funcA(15°)와 funcC(25°) 사이인데 C 쪽에 더 가깝습니다.

블렌드 벡터와 재비교 → 최종 순위

순위후보유사도변화
1funcC0.9995▲ 단독 1위
2funcA0.979거의 그대로
3funcB0.973거의 그대로
4funcE0.918거의 그대로
5funcD0.287▼ 유사도 급락
6funcF0.118▼ 유사도 급락

A·B·C·E는 0.92 이상으로 뭉쳐 있고, D·F는 0.3 아래로 뚝 떨어집니다. RRF에서는 등수만 갈랐던 차이가, 재순위에서는 수치로 뚜렷하게 갈립니다.

다만 이건 트랜스포머 cross-encoder처럼 문장을 토큰 단위로 쪼개 서로 대조하는 방식은 아닙니다 — 후보 하나당 벡터 하나로 뭉친 뒤, 그 벡터들 사이에서 attention 수식만 돌리는 방식입니다.

표준 Attention Flash Attention N×N 행렬 전체를 한 번에 만든다 → 느린 메모리 HBM에 저장 작은 타일 단위로 계산 후 누적 → 빠른 메모리 SRAM에서 처리
표준 attention은 전체 N×N 점수 행렬을 한 번에 만들어 느린 메모리에 저장하지만, Flash Attention은 작은 타일을 빠른 메모리에서 순차 계산·누적해 같은 결과를 더 빠르게 얻는다.

에이전트가 스스로 서랍을 여닫는 법

"에이전트가 작업 루프 중 알아낸 것을 스스로 남길 수 있게 두 도구를 줬습니다. 하나는 기억 도구로, 사용자에 대해 알게 된 사실, 관찰한 패턴, 교정받은 선호를 세션을 넘어 저장해 같은 실수를 반복하지 않게 합니다. 다른 하나는 지식 도구로, 에이전트가 조사해 정리한 것을 마크다운 노트로 남기며 읽기·쓰기·삭제·이동·검색·백링크 조회까지 가능합니다." — 자기소개서 원문

실제로는 "기억 도구" 하나가 아니라 세 개의 개별 도구로 나뉘어 있습니다.

memory_write

"사용자에 대한 사실, 행동 패턴, 세션 관찰, 선호 교정을 저장 — 에이전트 내부용. 세션을 넘어 유지된다."

memory_read

id를 주면 그 항목의 현재 상태를, 생략하면 조립된 컨텍스트를 돌려줍니다.

memory_search

하이브리드(어휘+의미) 검색으로 기억을 찾습니다.

지식 도구는 설명대로 단 하나이고, 동작 이름으로 분기하는 구조입니다.

knowledge

"개인 마크다운 볼트 — 문서, 글, 노트, 일지. 파일 기반이며 백링크·전문 검색·디렉터리 구조를 지원한다."

readwritedeletemovesearchbacklinks + 21개 더
코드 확인 · tools/builtin/knowledge_tool.rs:69

설명하신 여섯 가지 핵심 동작은 정확히 다 있습니다. 실제 액션 목록은 그보다 훨씬 넓어서, 체크리스트(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은 이 둘과는 별개로, "내용 자체를 얼마나 짧게 요약했는가"만 다루는 완전히 다른 축입니다.

코드 확인 · memory/types.rs:120-131, 229-236
// 타입마다 시작 티어가 다름
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는 아예 멈춥니다.

코드 확인 · dream.rs:759-784, manager/ops.rs:329-354
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는 "얼마나 중요한가"가 아니라 정원이 있는 자리다툼이고, 감쇠·보호가 그 다툼의 순위를 매겨주는 겁니다.

"감쇠는 에빙하우스 망각 곡선으로 가중치를 줄이되 자주 꺼낸 기억은 감소를 늦춥니다. 중요도는 보존도에 접근 횟수의 로그를 곱해 냅니다. 자동 보호는 접근 횟수와 세션 등장 횟수로 보호 등급을 정합니다." — 자기소개서 원문

감쇠 — 에빙하우스 망각 곡선

코드 확인 · memory/decay.rs:1, 59
//! 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가 감쇠율을 나누기 때문입니다 — 접근이 많을수록 분모가 커져 유효 감쇠율이 낮아집니다.

중요도 — 실제 공식

코드 확인 · memory/decay.rs:66
/// 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 양쪽에 곱하는 형태입니다.

자동 보호 — 오르내리는 다섯 단계

보호 등급은 승급만 있는 게 아니라 강등도 있습니다 — 접근이 끊기면 시간이 지나며 다시 낮아집니다.

None 보호 없음 Low 낮음 Medium 중간 High 높음 Permanent 영구 2+ 접근 3+ 접근·세션2+ 5+·세션3+·교정 고정 타입·수동 30일 무접근 60일 무접근 90일 무접근
위쪽 화살표는 승급 조건(접근·세션·교정), 아래쪽 화살표는 방치되었을 때의 강등 조건이다. Permanent는 강등되지 않는다.

"2회 접근 → 낮은 보호, 5회나 교정 → 높은 보호"라는 설명은 실제로는 그 사이에 Medium 단계가 하나 더 있는 4단계 사다리입니다.

압축과 정리 — 5단계로 내려가며 요약

"압축과 정리는 기억이 일정량을 넘기면 비슷한 것끼리 묶어 요약하고 쓸모없는 것을 지웁니다. 다섯 단계의 압축 계층을 따라 오래된 기억을 점진적으로 요약합니다." — 자기소개서 원문
Raw 200줄 Daily 300줄 Weekly 500줄 Monthly 무제한 Root 무제한 원문 그대로 압축된 요약만
문턱(줄 수)을 넘으면 비슷한 항목끼리 묶여 다음 단계로 요약되어 내려간다. Raw는 원문, Root로 갈수록 압축된 요약만 남는다.

Dream — 네 단계 배경 작업

"Dream이라는 네 단계 백그라운드 작업이 주도하는데, 도중에 멈춰도 체크포인트에서 이어 끝낼 수 있습니다." — 자기소개서 원문
Orient 현재 상태 스캔 Gather Signal 패턴 탐지 · 자동보호 Consolidate 압축 · 중복 제거 Prune & Index 정리 · 색인 갱신 각 단계 사이 초록 점 = 체크포인트 저장 지점 1시간 이상 지나면 처음부터, 아니면 마지막 체크포인트에서 재개
Dream은 네 단계를 거치며 정기적으로 기억을 정리한다. 각 단계가 끝날 때마다 체크포인트가 저장되어, 중간에 중단돼도 이어서 끝낼 수 있다.
코드 확인 · memory/dream.rs:2, 62

모듈 문서는 정확히 "4-phase background memory consolidation"이라 못박고 있고, DreamCheckpointcompleted_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단계"와 "실제로는 더 잘게 나뉜 하위 단계들" 사이에서 스스로도 정확히 통일돼 있지는 않습니다.

질의 시점과 배경 작업은 분리되어 있다

지식 마크다운 볼트 · 전역 하나 기억 에이전트가 쌓는 저장 공유 검색 계층 청킹 · 임베딩 (API → GGUF → TF-IDF) BM25(FTS5) + 벡터 검색 RRF 융합 (k=60) Flash Attention 재순위 에이전트 컨텍스트 시스템 프롬프트에 주입 Dream 감쇠 · 보호 · 압축 정기 실행 결과 반영
지식과 기억은 같은 검색 계층을 거쳐 에이전트 컨텍스트로 들어간다. Dream은 이 질의-시점 경로와 분리된 별도의 배경 작업으로, 기억에만 정기적으로 작용한다.

이 한 문단에 압축된 판단들

지식과 기억을 나눈 것도, 나눈 뒤 다시 검색 계층을 공유시킨 것도, 기억이 스스로 관리되게 만든 것도 — 전부 서로 다른 문제에 서로 다른 도구를 고르고, 그 이유를 설명할 수 있는 판단입니다.

  1. 경계 설계 — 사용자가 소유해야 할 것(지식)과 에이전트가 스스로 쌓아야 할 것(기억)을 구분하고, 각각에 맞는 저장 규칙(생애주기 없음 vs 감쇠·보호·압축)을 따로 두었다.
  2. 재사용 가능한 공통 계층 — 서로 다른 두 축이라도 "찾아서 꺼내는" 문제는 같았고, 검색 계층 하나로 통일해 벡터·BM25·RRF·재순위를 두 번 만들지 않았다.
  3. 기억이 스스로 관리되게 — 감쇠·보호·압축·Dream까지, 사람이 손대지 않아도 자주 쓰는 기억은 살아남고 안 쓰는 기억은 옅어지도록 생애주기 전체를 설계했다.
현재 상태 참고 — 이 문서가 근거로 삼은 oxios-memory 크레이트는 2026-08-15(v1.40.0, RFC-047)에 저장소에서 삭제되고, 별도 데몬 oxibrain으로 대체되었습니다. 이 문서는 삭제 직전 커밋(d7ee269) 기준 실제 구현을 설명한 것이고, oxibrain은 엔티티·명제·믿음을 다루는 완전히 다른 구조(시간 지식 그래프)로 다시 쓰였습니다. 자기소개서에 시제를 쓸 때 참고할 만한 지점입니다.
oxios · 지식·기억 아키텍처 설명 문서 · 커밋 d7ee269 기준