순수 TypeScript 기반의 의존성 없는 하이브리드 벡터 + 그래프 검색 (Hybrid vector + graph recall)
요약
의존성 없는 순수 TypeScript 기반의 임베디드 메모리 엔진인 'rememori'를 소개합니다. 벡터 유사도에 엔티티 그래프, 중요도, 시간 감쇠를 결합한 하이브리드 검색 방식을 통해 AI 에이전트의 기억력을 개선합니다.
핵심 포인트
- 런타임 의존성이 없는 순수 TypeScript 기반 임베디드 엔진
- 코사인 유사도, 엔티티 가산점, 시간 감쇠를 결합한 점수 산출 공식
- 메모리와 엔티티 간의 이분 그래프를 통한 관계 기반 검색 지원
- Node, Bun, 브라우저 환경에서 모두 실행 가능한 높은 호환성
모든 AI 에이전트는 세션이 종료되면 모든 것을 잊어버립니다. 이를 해결하려면 보통 벡터 데이터베이스 (vector database), 임베딩 파이프라인 (embedding pipeline), 그리고 검색 서비스 (retrieval service)를 연결해야 합니다. 즉, 실행할 서버와 선택해야 할 백엔드가 있는 하나의 플랫폼이 필요합니다. 저는 그 반대인 '프리미티브 (primitive)'를 원했습니다. npm install 한 번으로 디스크에 파일 하나만 있으면 끝나는 것 말이죠. 에이전트 메모리를 위한 SQLite 같은 존재 말입니다.
그래서 저는 rememori를 만들었습니다. 런타임 의존성이 전혀 없는 순수 TypeScript 기반의 임베디드 메모리 엔진입니다. 이 포스트는 결과적으로 중요하게 작용했던 세 가지 설계 결정, 즉 점수 산출 공식 (scoring formula), 엔티티 그래프 (entity graph), 그리고 벤치마크에 대해 교훈을 준 HNSW 인덱스 (HNSW index)에 관한 이야기입니다.
세 가지 동사
전체 API:
import { Memory } from 'rememori';
import { ollama } from 'rememori/embedders';
...
임베더 (embedder)는 인터페이스이며 절대 번들링되지 않습니다 — 로컬 Ollama, 모든 OpenAI 호환 엔드포인트, 또는 사용자 정의 (texts) => Float32Array[]를 사용할 수 있습니다. 이 단 하나의 결정 덕분에 동일한 엔진을 Node, Bun, 그리고 (transformers.js를 사용하여) 브라우저 탭 내부에서 완전히 실행할 수 있습니다.
유사도(Similarity)만으로는 검색(Recall)이 불가능하다
순수 코사인 유사도 (cosine similarity)는 훌륭한 검색 프리미티브이지만, 메모리로는 부족합니다. 에이전트에 이를 사용할 때 즉시 두 가지 문제가 나타납니다.
시간이 존재하지 않습니다. 어제 말한 선호도와 2년 전에 말한 선호도가 동일한 순위로 매겨집니다. 인간의 기억은 그렇게 작동하지 않으며, 에이전트의 메모리도 그래서는 안 됩니다.
이름이 단어보다 강력합니다. "Giorgio에게서 업데이트된 내용이 있나요?"라고 물으면, 그 문장의 임베딩은 "불안정한 배포 파이프라인을 수정함"이라는 문장의 임베딩과 전혀 가까워지지 않을 수 있습니다 — 설령 Giorgio가 그 작업을 수행한 당사자일지라도 말이죠. 연결 고리는 문구가 아니라 '엔티티 (entity)'입니다.
따라서 rememori의 검색 (recall)은 모든 후보를 다음과 같이 점수화합니다:
score = (cosine + entityBonus) × importance × 0.5^(age / halfLife)
entityBonus = min(0.3, 0.1 × shared entities)— 쿼리(query)와 동일한 엔티티(entity)를 언급하는 메모리는 가산점을 받지만, 실제 유사성을 압도하지 않도록 상한선이 설정되어 있습니다.importance는 쓰기 시점(write time)에 설정하는 0..1 사이의 가중치입니다.- 감쇠(decay) 항은 선택 사항이며 쿼리별로 적용됩니다: 반감기(half-life)가 90일이라는 것은 6개월 된 메모리가 최신 메모리보다 높은 순위를 차지하려면 대략 4배의 원시 관련성(raw relevance)이 필요함을 의미합니다.
엔티티(entity) 항은 제가 가장 강력하게 옹호할 부분입니다. 쓰기 시점에 모든 메모리는 언급된 명명된 엔티티(named entities)와 연결됩니다 — 즉, 메모리↔엔티티 이분 그래프(bipartite graph)를 형성합니다. 기본 추출기(extractor)는 의존성이 없는 대문자 기반 휴리스틱(capitalization heuristic) 방식입니다(LLM이 수행하도록 교체 가능합니다). 검색(recall) 시점에는 쿼리에서도 엔티티가 추출되며, 이를 공유하는 모든 메모리는 후보 집합(candidate set)에 포함됨이 보장됩니다. 텍스트 유사성이 '제로'인 메모리라도 '누구(who)'가 일치하기 때문에 표면 위로 떠오를 수 있습니다. 실제로 이 방식은 순수 벡터 검색(pure vector search)이 실수하기 쉬운 부분, 즉 사람, 프로젝트, 제품명과 관련된 쿼리를 정확히 구출해 냅니다.
디스크에는 단일 파일, 브라우저에서는 구조적 복제(structured clone)
저장소(Storage)는 세 가지 실제 구현체를 가진 어댑터 인터페이스입니다:
- Node/Bun: 추가 전용(append-only) JSONL 로그입니다. 쓰기는 추가(append) 방식이며, 삭제는 툼스톤(tombstone) 라인으로 처리되고, 컴팩션(compaction) 단계를 통해 파일을 다시 작성합니다. 구조적으로 충돌 안전(crash-safe)하며, 우연히
grep으로 검색할 수도 있고,less명령어로 에이전트의 메모리를 읽을 수 있습니다. - Browser: 경로(
idb://agent)를 통해 선택되는 IndexedDB입니다. 여기서 즐거운 놀라움이 있는데, IndexedDB는 구조적 복제(structured clone)를 통해Float32Array를 네이티브로 저장합니다 — base64 인코딩이나 별도의 직렬화 계층이 전혀 필요 없습니다. :memory:: 테스트용입니다.
브라우저 저장소 덕분에 rememori.dev의 데모가 현재와 같이 작동합니다: transformers.js가 로컬에서 임베딩(embeddings)을 생성하고, IndexedDB가 이를 영구 저장하며, 모델을 한 번 다운로드한 후에는 메모리를 저장하고 의미론적으로 검색(semantically recall)하는 동안 네트워크(Network) 탭이 조용히 유지되는 것을 볼 수 있습니다. 순수 TypeScript(Pure TypeScript)는 이 시스템의 핵심 제약 사항입니다 — 네이티브 바인딩(native bindings)을 사용했다면 브라우저의 경계에서 멈췄을 것입니다.
HNSW 인덱스, 그리고 나를 속인 벤치마크
연속된 Float32Array에 대한 정확한 검색 (Exact search)은 진정으로 빠릅니다 — 1,000개의 메모리 기준 쿼리당 약 0.5ms가 소요됩니다. 대부분의 에이전트(agent)는 그 이상의 데이터가 필요하지 않습니다. 하지만 "확장성(scale)이 없다"는 지적은 타당하므로, v0.4에서는 순수 TypeScript 기반의 HNSW 인덱스를 추가했습니다. 이는 대부분의 프로덕션 벡터 데이터베이스 (vector databases) 뒤에 있는 것과 동일한 알고리즘인 계층적 탐색 가능 소세계 그래프 (hierarchical navigable small world graph)입니다. 직접 구현한 이진 힙 (binary heap)을 포함하여 약 250줄 정도의 코드입니다.
첫 번째 벤치마크 실행, 10,000개 벡터:
recall@10 = 36.8%
아팠습니다. 버그를 찾기 위해 한 시간을 허비했습니다. 이웃 선택 휴리스틱 (Neighbor selection heuristic) — 교과서적입니다. 레이어 할당 (Layer assignment) — 교과서적입니다. 빔 서치 종료 (Beam search termination) — 교과서적입니다. 그러고 나서 동일한 인덱스를 32차원 벡터로 실행해 보았습니다: 모든 크기에서 재현율 (recall) 100%.
버그는 코드에 있지 않았습니다. 데이터에 있었습니다. 저는 균등하게 무작위인 (uniformly random) 384차원 벡터로 벤치마크를 수행하고 있었는데, 고차원에서는 무작위 점들이 집중되는 현상이 발생합니다. 즉, 모든 쌍이 거의 등거리에 놓이게 되어 이웃 구조가 존재하지 않게 되며, 어떤 그래프 기반 인덱스도 존재하지 않는 구조를 탐색할 수는 없습니다. 이는 이미 문서화된 병리적 현상이며 — ann-benchmarks에서 가장 어려운 데이터셋들이 정확히 이런 형태입니다 — 실제 워크로드와는 아무런 관련이 없습니다. 실제 임베딩 모델 (embedding models)은 저차원 매니폴드 (low-dimensional manifolds) 상의 벡터를 생성하며, 바로 그 구조가 임베딩이 유용한 근본적인 이유입니다.
현실적인 데이터(24차원의 내재적 구조를 가진 384차원 벡터, 저장된 포인트의 섭동(perturbation)으로서의 쿼리 — 즉, 저장된 메모리에 대한 의역된 질문)로 다시 벤치마크를 수행했습니다:
| memories | exact scan | HNSW | recall@10 |
|---|---|---|---|
| 1,000 | 0.5 ms/query | (index off — scan wins) | 100% |
| ... |
제가 전달하고 싶은 두 가지 교훈은 다음과 같습니다:
- 만약 당신의 ANN 벤치마크가 균등 분포의 무작위 고차원 벡터 (uniform random high-dim vectors)를 사용한다면, 그것은 잘못된 세계를 측정하고 있는 것입니다. 고유한 구조 (intrinsic structure)를 가진 데이터를 생성하거나 실제 임베딩 (embeddings)을 사용하세요.
- 항목이 약 1,000개 미만이라면, 인덱싱을 아예 하지 마세요. 지연 시간 (latency), 메모리, 그리고 단순성 측면에서는 정확한 스캔 (exact scan)이 승리합니다. rememori의 기본 설정(
index: 'auto')은 해당 임계값을 넘어서야 그래프를 구축하며, 태그 필터링이 적용된 검색 (tag-filtered recalls)은 항상 정확한 스캔을 사용합니다. 근사 검색 (approximate)과 필터링을 결합하면 정확성이 조용히 무너지기 때문입니다.
의도적으로 배제한 기능들
멀티 유저 서버, 인증 (auth), 클라우드 동기화, 문서 수집 파이프라인 (document-ingestion pipeline)은 포함되지 않았습니다. 이러한 기능이 필요하다면 Cognee와 Mem0가 좋은 플랫폼입니다. rememori는 운영할 것이 아무것도 없이, 5분 만에 당신의 프로세스 내부에 메모리를 두고 싶을 때를 위한 것입니다.
설계 과정에서 추가된 한 가지 사항은, 엔진이 임베드 (embedded) 방식이기 때문에 이를 MCP 서버로 감싸는 데 단 한나절밖에 걸리지 않았다는 점입니다. claude mcp add rememori -- npx -y rememori-mcp 명령어를 사용하면 Claude Code, Cursor, 또는 모든 MCP 클라이언트에 대해 로컬 파일에 저장되고 로컬 Ollama를 통해 임베드되는 세션 간 지속적인 시맨틱 메모리 (semantic memory)를 제공할 수 있습니다.
rememori는 MIT 라이선스이며 초기 버전(v0.4)입니다. 이름은 '기억하다'라는 뜻의 에스페란토어이며, 어디서든 실행되도록 만들어진 언어라는 점에서 이 프로젝트와 잘 어울린다고 느꼈습니다. 사이트 및 브라우저 데모: rememori.dev. 제가 가장 관심 있는 피드백은 검색 공식 (recall formula), 저장 형식 (storage format), 그리고 실제 에이전트 (agent)에 신뢰하고 사용하기 위해 무엇이 더 필요한가 하는 점입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기