AI 에이전트에게 메모리와 사설 지식 기반을 단일 SQLite 파일로 구현한 방법
요약
AI 에이전트의 기억 상실과 환각 문제를 해결하기 위해 SQLite 기반의 로컬 메모리 및 지식 기반 시스템을 구현하는 방법을 소개합니다. MemoHood와 MemoBase라는 두 플러그인을 통해 세션 간 대화 맥락을 유지하고, 외부 문서에 대해 정확한 인용과 정직한 거절을 수행하도록 설계되었습니다.
핵심 포인트
- SQLite를 활용한 단일 파일 기반의 로컬 메모리 및 지식 저장 구현
- 세션 간 맥락 유지를 위한 자동 정보 캡처 및 버전 관리 메모리 방식
- 환각 방지를 위해 프롬프트가 아닌 코드 수준에서 인용 및 거절 로직 적용
- 클라우드 의존성을 없애기 위해 로컬 ONNX 임베딩 및 하이브리드 검색 사용
제가 사용했던 모든 AI 에이전트는 같은 두 가지 실패를 겪었습니다. 채팅창을 닫는 순간 모든 것을 잊어버렸고, 가지고 있지 않은 사실들을 자신감 있게 꾸며냈습니다. 저는 제 문서를 클라우드 서비스에 맡기고 싶지 않았고, 에이전트를 포크(fork)할 수도 싶지 않았습니다. 그래서 두 개의 플러그인을 만들었습니다.
이것은 그 플러그인들 뒤에 숨겨진 설계 결정들에 대한 이야기입니다: 왜 하이브리드 검색(hybrid search)인지, 왜 '인용하거나 거부하기(quote or refuse)'가 프롬프트가 아닌 코드에 포함되어야 하는지, 제가 의도적으로 PyTorch를 버린 방법, 버전 관리 메모리(SUPERSEDE)가 덮어쓰기보다 나은 이유, 그리고 클라우드 키 없이 전체 시스템을 어떻게 실행할 수 있는지 (로컬 ONNX 임베딩 포함). 거대한 벡터 검색 및 에이전트 메모리 프로젝트들이 취한 방향과 같은 흐름으로, 하나의 로컬 파일로 패키징했습니다.
두 가지 실패 지점
제가 시도했던 '에이전트 메모리'는 두 가지 방식 중 하나로 저를 실망시켰습니다.
첫 번째: 실제로 기억하지 못합니다. 제가 사용하는 에이전트는 각각 몇백 토큰짜리 작은 파일 두 개와 함께 제공되는 메모리를 가지고 있습니다. 이들을 채우면 압축하는 대신 오류가 발생합니다. 그리고 모델이 대화 도중에 도구를 호출하기로 결정하지 않는 한 아무것도 기록되지 않습니다. 따라서 저의 결정, 저의 수정 사항, 제가 원하는 방식은 기본적으로 사라집니다. 새 세션이 시작되면 어제는 없었던 일이 됩니다.
두 번째: 제 문서는 알지 못하지만, 어쨌든 그 문서에 대해 답변합니다. 실제 지식 기반이 없었습니다. 제가 넣은 파일들은 캐시에 들어가서 24시간 후에 삭제되었습니다. 일반적인 LLM에게 문서에 대해 질문하면 두 가지 나쁜 선택지가 있습니다. 전체 내용을 프롬프트에 붙여넣는 것(비용이 많이 들고, 중간에 내용이 손실됨) 아니면 그 일반 지식에 의존하여 그것이 태연한 표정으로 꾸며낸 것을 보게 되는 것입니다.
저는 핵심 코드를 패치하고 포크를 영원히 돌보지 않고 싶었습니다. 그래서 두 개의 플러그인을 작성했습니다. 이들은 에이전트의 플러그인 API에 연결되며, 소스 코드의 어떤 줄도 건드리지 않습니다.
MemoHood는 대화를 기억합니다. 무엇이 중요한지 파악하고, 다음 답변을 하기 전에 이를 다시 불러오며, 오래된 사실을 언제 교체해야 할지 스스로 결정합니다.
MemoBase는 사용자의 파일, 웹 페이지, YouTube, 오디오, 그리고 Obsidian 노트를 기반으로 하는 사설 지식 기반 (private knowledge base)입니다. 오직 사용자의 소스 (sources)에서만 답변하며, 정확한 인용구 (exact quote)를 제공하거나, 답변이 없으면 솔직하게 없다고 말합니다.
각각 하나의 SQLite 파일로 구성됩니다. 원한다면 둘 다 완전히 로컬 (local)에서 실행할 수 있습니다. 이어지는 내용은 제가 내린 모든 결정의 이유에 대한 설명입니다.
어떻게 작동하는가
이 기능들이 무엇을 위한 것인지 보여주는 두 가지 순간입니다.
세션 간의 메모리 (Memory across sessions). 월요일에 제가 무심코 말합니다: "참고로, 저는 페니실린 알레르기가 있어요." 한 달 뒤, 완전히 새로운 채팅에서 묻습니다: "목이 아픈데 항생제를 추천해 줘." 에이전트는 알레르기를 고려하여 답변합니다. 저는 아무것도 상기시키지 않았습니다. 이것이 MemoHood입니다. 그 사실은 자동으로 캡처되었고 (알레르기는 명백히 보관해야 할 정보입니다), 정보가 퇴색되지 않도록 고정되었으며, 답변 전에 자동으로 다시 불러와졌습니다.
신뢰할 수 있는 답변, 혹은 깔끔한 거절. 계약서 PDF를 넣고 계약 기간에 대해 묻습니다. 정확한 조항이 인용된 페이지 번호와 함께 답변이 돌아옵니다. 그다음 계약서에 포함되지 않은 내용을 묻습니다. 즉흥적인 답변은 없습니다. 지식 기반 (base)에 답변이 없다고 말합니다. 이것이 MemoBase이며, 이러한 정직함은 제가 프롬프트 (prompt)로 유도한 성격이 아닙니다. 코드 (code)로 강제된 것입니다. 이것이 다음 두 섹션의 핵심 주제입니다.
하이브리드 검색 (FTS5 + vector + RRF)을 사용하는 이유
모든 검색 시스템 (retrieval system)은 정보를 찾는 방식을 선택해야 합니다. 두 가지 명확한 옵션은 각각 서로 다른 방향에서 한계가 있습니다.
키워드 검색 (전체 텍스트 검색, BM25)은 문자 그대로의 검색입니다. "계약서 (the contract)"에 대해 물으면, 단지 "이 합의서 (this agreement)"라고만 적힌 청크 (chunk)는 찾아내지 못합니다. 의미는 같지만 단어가 다르기 때문에 매칭되지 않습니다. 하지만 정확한 토큰 (tokens): 에러 코드, SKU, 함수 이름, 누군가의 성씨 등에서는 타의 추종을 불허합니다.
벡터 검색 (Vector search)은 그 반대입니다. 의미에 따라 매칭하기 때문에 "agreement"와 "contract"가 서로 가깝게 위치합니다. 하지만 정확한 토큰 (tokens)은 놓치게 됩니다. A-4471과 같은 주문 번호를 검색하면, 그런 문자열은 근처에 있을 만한 "의미"가 없기 때문에 시맨틱 유사도 (semantic similarity) 방식은 그냥 무시해 버립니다.
하나를 선택하면 다른 하나의 사각지대를 감수해야 합니다. 그래서 저는 두 방식 모두를 실행하고, 거대 검색 스택들이 사용하는 것과 동일한 트릭인 상호 순위 결합 (Reciprocal Rank Fusion, RRF)을 사용하여 결과를 융합합니다. RRF는 아름다울 정도로 단순합니다. 원시 점수 (raw scores)를 무시하고 (어차피 두 시스템 간에는 비교가 불가능하니까요) 순위 (rank)를 기준으로 결합합니다.
두 개의 순위 리스트: 하나는 FTS5/BM25에서, 다른 하나는 벡터 인덱스 (vector index)에서 가져온 것.
RRF는 원시 점수가 아닌 위치에 따라 융합합니다. k=60은 표준 상수입니다.
def rrf(rank, k=60):
return 1 / (k + rank)
키워드 검색에서 #2위이면서 동시에 벡터 검색에서 #5위인 청크는
단일 리스트에서 #1위이지만 다른 리스트에는 없는 청크보다 더 높은 순위를 차지합니다.
score = rrf(fts_rank) + rrf(vec_rank)
두 리스트 모두에서 강력하게 나타나는 청크가 승리합니다. 이것이 바로 여러분이 원하는 것입니다: 의미로도 찾고 정확한 용어로도 찾는 것 말이죠. 만약 Cohere 키가 있다면, 상위 후보들은 추가로 한 번의 재순위화 (rerank) 과정을 거칩니다. 키가 없다면? 에러를 내는 대신 RRF 순위에 따라 그대로 진행합니다.
가장 좋은 점은 이 모든 것이 하나의 SQLite 파일 안에 존재한다는 것입니다. 전문 검색 (Full-text)은 FTS5를 사용하며 (별도의 컬럼에 러시아어 어간 추출 (stemming)을 적용하여 "договора"가 "договор"을 찾을 수 있게 합니다), 벡터는 동일한 데이터베이스 내의 sqlite-vec에 저장됩니다. 별도의 벡터 서버도, 관리해야 할 외부 인덱스도 없습니다. 복사하거나, 백업하거나, 삭제할 수 있는 단 하나의 파일입니다.
"인용하거나 거절하라"는 명령이 프롬프트가 아닌 코드에 포함되어야 하는 이유
모델에게 소스에서만 답변하고 모르는 것은 모른다고 인정하라고 요청할 수 있습니다. 사람들은 정확히 그렇게 하기 위해 긴 시스템 프롬프트 (system prompts)를 작성합니다. 그것은 작동합니다, 단 한 번 작동하지 않을 때까지는 말이죠. 그리고 여러분은 정직한 답변과 자신감 있게 내뱉는 가짜 답변을 구별할 방법이 없습니다.
프롬프트는 요청입니다. 저는 보증을 원했습니다. 그래서 MemoBase는 환각 방지 (anti-hallucination) 부분이 정중하게 요청되는 것이 아니라 구조 자체에 내장된 4단계 루프를 실행합니다:
- sufficiency gate (충분성 게이트) -> 검색 결과가 확신할 수 있을 만큼 관련성 있는 청크(chunk)를 반환했는가? 그렇지 않다면, 모델이 무엇인가를 생성하기 전에 지금 즉시 거절합니다.
- tool-less answer (도구 없는 답변) -> 모델은 오직 손에 쥐어진 검색된 청크들만 사용하여 답변하며, 다른 도구가 연결되지 않고 무엇인가를 "채워 넣을" 수 있는 것이 없습니다.
- citation check (인용 확인) -> 답변 내의 모든 인용구는 소스 청크와 글자 그대로(verbatim, 정확하게 또는 퍼지하게) 대조됩니다. 조작되었거나 부정확한 인용구는 삭제됩니다.
- honest refusal (정직한 거절) -> 검증된 인용구가 하나도 남지 않는다면, 검증되지 않은 텍스트 대신 "base에 없음"을 반환합니다. 인용 확인(citation check)은 이 시스템을 지탱하는 핵심 요소입니다. 모델은 자신이 원하는 것은 무엇이든 주장할 수 있습니다. 하지만 모든 인용구는 여전히 원본 소스 텍스트와 차이점 분석(diff)을 거칩니다. 토씨 하나 틀리지 않고 일치하지 않는다면? 삭제됩니다. 살아남는 것이 없다면, 근거 없는 매끄러운 문장이 아니라 거절 메시지를 받게 됩니다. 여기서 거짓말을 하지 말라고 권장하는 것이 아닙니다. 단지 거짓말을 할 수 있는 수단이 없을 뿐입니다.
이것이 바로 도구가 없는(tool-less) 모델이 답변을 작성하는 이유이기도 합니다. 생성 과정 중에 웹 접속 권한을 주면, "이 청크들로만 답변하라"는 지시는 모델이 우회할 수 있는 하나의 제안이 되어버립니다. 도구를 제거하면, 모델이 물리적으로 가질 수 있는 것은 오직 파편화된 정보들뿐입니다.
일부러 PyTorch를 제외한 이유
기본적인 RAG 스택은 무겁습니다. 전형적인 임베딩(embedding) 설정은 PyTorch를 끌어들이고, 수 기가바이트의 모델 가중치(model weights)를 불러오며, 종종 AGPL 라이선스 하에 배포됩니다. AGPL은 이를 기반으로 구축할 경우 귀하의 코드까지 오픈 소스로 공개하도록 강제할 수 있는 라이선스입니다. 이 중 어느 것도 5달러짜리 VPS에 적합하지 않습니다. 그리고 저는 제 의존성 트리(dependency tree) 안에 라이선스 변호사가 살고 있는 것을 원치 않았습니다.
그래서 규칙은 간단했습니다: 표준 라이브러리와 몇 개의 가벼운 패키지만 사용하며, 설치는 몇 초 안에 끝내야 합니다. 벡터 인덱스(vector index)는 sqlite-vec를 사용합니다. 어간 추출(stemming)은 PyStemmer를 사용합니다. 오프라인 임베더(embedder)가 필요한 경우에는 ONNX Runtime을 통해 모델을 실행하는 fastembed를 사용합니다. PyTorch는 어디에도 없으며, AGPL도 없고, 컴파일할 것도 없습니다.
그 결과는 지루할 정도로 안정적이며, 저는 이를 매우 좋아합니다. 저렴한 서버에 바로 올려져 빠르게 시작되며, 다른 사람의 컴퓨터에서 작동할 때 고장 날 만한 기이한 요소가 전혀 없습니다.
로컬이든 클라우드든, 키(key)는 필요 없습니다
설치 시 갈림길을 선택하게 됩니다. 양쪽 모두 실제적인 경로이며, 하나는 "제대로 된" 방식이고 다른 하나는 장난감 같은 방식인 식의 구분은 아닙니다.
클라우드(Cloud) 방식은 최소한의 설정으로 가장 높은 품질을 제공합니다. Cloudflare Workers AI(BGE-M3)를 통한 임베딩 (embeddings), 선택 사항인 Cohere 리랭크 (rerank), 그리고 추가 소스들(YouTube 자막, 오디오 전사 (audio transcription))이 그것입니다. 이들 대부분은 무료 티어 (free tier)를 제공합니다. 또한 제공자별 월간 지출 한도 (monthly spend ceiling)가 설정되어 있어, 제어되지 않는 작업이 갑작스러운 청구서로 이어지는 것을 방지할 수 있습니다.
완전 로컬 (Fully local) 방식은 제가 ONNX 경로를 구축하는 데 공을 들인 이유입니다. 플래그 하나로 재설치하면 fastembed와 다국어 모델(~2.2 GB, 1회 다운로드)을 사용할 수 있습니다. 그 이후에는 API 키도 필요 없고, 비용도 들지 않습니다.
./install.sh --local # Linux / macOS
.\install.ps1 -Local # Windows
config.yaml — 두 플러그인이 공유하는 하나의 임베더 (embedder)
memory:
provider: memohood
memohood:
embedder: { provider: local, model: intfloat/multilingual-e5-large, dims: 1024 }
memobase:
embedder: { provider: local, model: intfloat/multilingual-e5-large, dims: 1024 }
개인정보 보호에 관하여: 클라우드 모드에서도 사용자의 기기를 떠나는 것은 임베딩 (embedding)과 리랭킹 (reranking)을 위한 질문 텍스트와 후보 청크 (candidate chunks)뿐입니다. 소스 파일과 데이터베이스의 나머지 부분은 절대 업로드되지 않습니다. 로컬 모드에서는 아무것도 외부로 나가지 않습니다.
버전을 유지하는 메모리: SUPERSEDE와 게이트 (gate)
MemoHood는 제가 가장 많은 고민을 쏟은 부분입니다. 왜냐하면 "사실을 기억하는 것"은 쉬운 절반에 불과하기 때문입니다. 어려운 절반은 사실이 변할 때 어떤 일이 일어나는가 하는 점입니다.
대부분의 메모리는 덮어쓰기 (overwrite)를 합니다. 마감일이 금요일이라고 말했다가 월요일이라고 말하면, 금요일 정보는 사라집니다. 키워드 검색 (keyword search)이 오래된 요약본을 다시 불러와서 에이전트가 지난주 계획으로 당신을 "교정"하기 전까지는 괜찮습니다. 저는 실제로 그런 일이 발생하는 것을 목격했습니다. 그래서 MemoHood는 덮어쓰지 않습니다. 대신 대체 (supersede)합니다.
새로운 사실이 기존의 사실과 모순되나요? 새로운 사실이 상단에 위치합니다. 기존의 사실은 오래된 것 (stale)으로 표시되고, 날짜가 찍힌 채 기록의 이력 (history)에 보관됩니다. 일반적인 검색은 현재 버전만 보여주지만, 원할 때는 언제든 호출 한 번으로 이전 버전을 확인할 수 있습니다. 메모리는 항상 "현재 상태"와 "과거 상태"를 모두 알고 있으며, 사용자의 과거를 조용히 다시 쓰지 않습니다.
두 가지 결정이 더 시스템을 저렴하고 조용하게 유지합니다.
캡처 경로(capture path)는 우선 비용이 들지 않습니다. 명시적인 신호(수정, 결정, 혹은 "이것을 기억해 줘"라는 단호한 요청)는 LLM 호출 없이 키워드 스코어러(keyword scorer)에 의해 포착됩니다. 오직 정말로 경계선에 있는 대화(borderline turns)만이 저렴한 모델에 대한 한 번의 호출 비용을 발생시키며, 해당 모델은 이를 저장할 가치가 있는지 결정합니다. 뻔한 내용을 기억하기 위해 비용을 지불할 필요는 없습니다.
그리고 회상(recall) 단계 전에는 게이트(gate)가 존재합니다. 아주 작은 오프라인 정적 임베딩 모델(Model2Vec, 네트워크나 키가 필요 없음)이 들어오는 대화가 메모리 관련 질문인지 여부를 판단합니다. 단순한 "고마워"라는 말은 검색을 트리거하지 않습니다. 이 규칙은 의도적으로 신중한 쪽을 택합니다. 즉, 잡담(small-talk) 신호가 명확하게 우세할 때만 회상을 건너뜁니다. 회상을 놓치는 것이 추가로 수행하는 것보다 더 나쁘기 때문입니다.
지속적인 정보(이름, 생일, 알레르기 등)는 고정(pinned)되어 매일 밤 발생하는 망각 곡선(forgetting curve)을 완전히 건너뜁니다. 그 외의 모든 것은 시간이 지남에 따라 서서히 감쇠(decay)하며, 일시적인 이벤트는 빠르게, 안정적인 사실은 느리게 처리됩니다. 이 과정은 답변의 핫 패스(hot path)를 방해하지 않는 백그라운드 작업(background job)에서 이루어집니다.
저는 이 중 그 어떤 것도 발명하지 않았으며, 이는 좋은 일입니다.
공정한 질문을 던져보겠습니다. 제가 여기서 실제로 발명한 것이 있나요? 대부분은 아닙니다. 그리고 그것이 안심이 되는 부분인데, 이는 이 설계가 훨씬 더 많은 자본을 가진 팀들이 지향하는 방향과 일치함을 의미하기 때문입니다. 저는 단지 이를 로컬 환경의 단일 파일로 패키징했을 뿐입니다.
RRF(Reciprocal Rank Fusion)와 결합된 하이브리드 검색(Hybrid search)은 Microsoft가 Azure AI Search(Copilot의 검색 엔진)에서 제공하고, Google이 Vertex AI Search에서, Amazon이 OpenSearch에서, Elastic이 Elasticsearch에서 제공하는 방식입니다. 거대한 클라우드 엔진들입니다. 저는 동일한 아이디어를 디스크 위의 파일 하나에 담았습니다.
출처만을 바탕으로 인용과 함께 답변하나요? 그것은 Perplexity와 Google의 NotebookLM이 하는 일입니다. 클라우드 제품들이죠. 저는 로컬에서 작동하는 무료 버전을 구현했습니다.
사실 추출형 메모리 (Fact-extracting memory)는 이제 하나의 완전한 분야가 되었습니다. mem0는 동일한 방식으로 모델을 통해 사실을 추출하며, AWS가 Bedrock AgentCore의 기본 메모리로 채택했을 정도로 충분히 잘 작동합니다. Zep은 메모리를 시계열 그래프 (temporal graph)로 유지하며 이전 상태를 지우지 않는데, 이는 제 SUPERSEDE의 정신적 지향점과 맞닿아 있습니다. Letta (구 MemGPT)는 메모리를 운영체제 (OS)처럼 취급합니다. 거대 기업들도 이를 출시했습니다. OpenAI는 ChatGPT Memory를 보유하고 있으며, Anthropic은 대화 기록으로부터 사실을 구축하는 Claude Memory를 출시했습니다. 이러한 개념은 어디에나 존재합니다. 하지만 이 특정한 조립 방식 (두 부분 모두 로컬이며, 하나의 SQLite 파일로 구성되고, 핵심 변경 사항이 없는 방식)은 저만의 것입니다.
솔직한 한계점
모든 도구에는 장점만 있는 것이 아닙니다. 설치하기 전에 알아두어야 할 사항은 다음과 같습니다.
이전 기록은 소급 적용되지 않습니다. MemoHood는 설치 이후의 대화 턴 (turns)에서 사실을 추출합니다. 회상을 위해 기존 기록을 인덱싱 (indexing)할 수는 있지만, 이전 채팅에서 새로운 사실을 채굴 (mine)하지는 않습니다.
비용 추정치는 근사치입니다. 지출 수치는 제공업체의 공개 가격 책정을 기반으로 하며, 보장된 청구 금액이 아닙니다. 월간 상한선은 실재하지만, 수치는 최선의 추정치로 간주하십시오.
전사 (Transcription) 타임코드는 경로에 따라 다릅니다. 기본 Whisper 경로는 실제 디코더 타임코드를 제공합니다. 폴백 (fallback) 경로는 이를 추정하며 긴 오디오에서는 오차가 발생할 수 있고, 해당 전사본은 신뢰도가 낮은 것으로 표시됩니다.
원클릭 소셜 리퍼 (social ripper)는 지원하지 않습니다. 링크 뒤에 있는 페이지는 텍스트로 수집됩니다. 특정 Instagram 또는 TikTok 동영상을 가져오려면 파일을 직접 다운로드하여 비디오로 입력해야 합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기