
일반적인 “대화형 AI”는 세션이 끝나면 기억이 날아가거나, 기억을 저장하더라도 불투명(무엇을 저장했는지 모름)하거나, 벤더 종속(특정 DB/특정 SaaS)이 되기 쉽습니다. 이 구조는 다음을 목표로 합니다.
- 진실의 단일 소스(SoT): 기억의 원본은 로컬 Markdown 파일로 남긴다.
- 빠른 검색: 원본 위에 인덱스(벡터 + 키워드)를 만들어 검색 성능을 확보한다.
- 투명성/소유권: 사용자가 파일을 직접 열어보고 수정하고 Git으로 버전관리할 수 있다.
- 컨텍스트 관리: 토큰 한계 때문에 대화 내용을 요약(컴팩션)하더라도, 중요한 사실은 먼저 파일에 반영(플러시)해 손실을 줄인다.
전체 구성
이 구조는 크게 3층으로 나뉩니다.
저장(원본) 계층: Markdown = 단일 진실
MEMORY.md: 장기 기억(지속 사실, 결정사항, 선호도)memory/YYYY-MM-DD.md: 일일 로그(단기/세션성 기록, 대화/작업 로그)memory/**/*.md: 주제별 지식 노트(프로젝트, 기술, 회고 등)
여기서 핵심은 “DB는 원본이 아니다”입니다.
DB/벡터스토어는 언제든 재생성 가능한 “캐시/인덱스”일 뿐입니다.
인덱스 계층: SQLite(FTS5 + 벡터)로 2중 인덱싱
- 키워드 인덱스(BM25): SQLite FTS5
- 벡터 인덱스(시맨틱 검색): sqlite-vec 같은 확장(또는 별도 벡터 테이블)
즉 “2계층 저장”은 다음 의미입니다.
- 1계층(원본): Markdown 파일들
- 2계층(검색용): SQLite 인덱스(청크 단위 + 메타데이터)
도구(에이전트 인터페이스) 계층: memory_search / memory_get
에이전트는 “파일 시스템 전체를 매번 읽지” 않고, 필요할 때만 다음 도구로 접근합니다.
memory_search: 검색 결과(스니펫 + 경로 + 라인 범위 + 점수)만 반환memory_get: 특정 파일 전체가 필요할 때만 읽기
이로써 토큰 사용량을 제어하면서도 “기억이 있는 에이전트”를 구현합니다.
메모리 타입 설계: 단기 vs 장기
단기(세션/일지) 메모리
- 오늘 작업 로그
- 최근 대화 요약
- “임시로 유용하지만 시간이 지나면 가치가 낮아지는 정보”
보통 memory/YYYY-MM-DD.md 같은 형태로 쌓입니다.
장기(지속) 메모리
- 사용자 프로필(역할/환경/선호 스타일)
- 반복되는 규칙(“항상 이렇게 답해줘”, “이 프로젝트는 이렇게 한다”)
- 장기 프로젝트의 결정/합의 사항
- 핵심 지식(이후 다시 써먹을 수 있는 내용)
보통 MEMORY.md에 “정제된 형태”로 들어갑니다.
“승격(Promote)”의 개념
단기 로그에서 의미 있는 항목을 뽑아 장기 파일에 승격시키는 규칙이 중요합니다.
예)
- 일지: “이번 PoC는 REST로 가자”
- 장기: “(결정일) PoC 1단계는 REST 기반 웹훅으로 진행, 이유는 디버깅/호환성”
인덱싱 파이프라인(파일 → 청크 → 임베딩/FTS → SQLite)
아키텍처의 “엔진”은 결국 인덱싱 워커입니다.
파일 변경 감지(File Watcher)
- 대상:
MEMORY.md,memory/**/*.md - 방식: inotify(리눅스) / watchdog(파이썬) / chokidar(node) 등
- 변경 시: “전체 재색인”이 아니라 변경된 파일만 부분 재색인(Atomic reindex)
핵심 포인트
- 파일별 버전(해시)을 저장해 “어떤 파일의 어떤 버전이 인덱스에 반영됐는지” 추적
- 재색인 중 장애가 나도 이전 인덱스가 깨지지 않도록 트랜잭션 처리
청킹 전략(Chunking)
권장 패턴(예시)
- 목표 청크 크기: 약 400 토큰
- 오버랩: 약 80 토큰
의도
- 문단이 경계에서 잘려도 의미가 덜 손실됨
- 검색 시 너무 짧은 단편이 아니라 “맥락 덩어리”로 회수됨
실무 팁
- Markdown 헤더(
#,##) 기준으로 먼저 큰 덩어리를 나눈 뒤, - 너무 길면 토큰 기준으로 추가 분할
저장 구조(메타데이터 + 본문)
청크마다 최소 다음이 필요합니다.
doc_path: 파일 경로chunk_id: (doc_path + offset 기반) 고유 IDstart_line,end_line: 원문 라인 범위text: 청크 본문(FTS용)embedding: 벡터(시맨틱용)updated_at: 인덱싱 시각tags: 추출 태그(선택)title/heading: 가까운 헤더(선택)
하이브리드 검색(벡터 + BM25) 흐름
검색은 “둘 중 하나”가 아니라 둘을 합치는 것이 핵심입니다.
왜 둘 다 필요하나?
- 벡터 검색(시맨틱)
- 장점: 표현이 달라도 의미가 비슷하면 찾음
- 약점: 고유 토큰(에러코드/ID/정확한 문자열)에 약할 수 있음
- BM25(키워드)
- 장점: 정확한 문자열에 강함(버전, 코드, 식별자)
- 약점: 표현이 바뀌면 놓칠 수 있음
쿼리 처리 단계(권장 표준 플로우)
- 쿼리 임베딩 생성
- 벡터 인덱스에서 top-k 후보 조회(k_vec)
- FTS5(BM25)로 top-k 후보 조회(k_lex)
- 후보를 합집합으로 결합
- 점수 정규화(벡터 점수 스케일과 BM25 스케일이 다름)
- 가중합으로 최종 점수 산출
- 예:
score = w1*vec + w2*bm25 + w3*recency_bonus
- 예:
- 상위 N개만 스니펫으로 반환
랭킹에서 흔히 넣는 “추가 보정”
- 최신성 보너스(최근 수정된 기록을 약간 우대)
- 파일 우선순위(예:
MEMORY.md> daily log) - 섹션 중요도(“결정/정책” 섹션은 우대)
컨텍스트 관리: 컴팩션(요약) + 플러시(중요정보 반영)
LLM 컨텍스트는 제한되어 있으니, 결국 “대화 내용을 축약”해야 합니다.
컴팩션(요약 교체)
- 오래된 대화 메시지들을 요약본으로 교체해 길이를 줄임
메모리 플러시(요약 전 선반영)
컴팩션 전에 중요한 정보를 먼저 장기 Markdown에 기록합니다.
왜 필요하나?
- 요약은 세부 디테일을 잃기 쉽고,
- “나중에 다시 정확히 찾아야 하는 정보(결정/수치/설정값)”가 날아갈 수 있음
권장 규칙
- “결정, 약속, 선호, 자주 쓰는 값, 보안 관련 정책/예외”는 요약 전에 MEMORY로 승격
SOUL.md / USER.md / MEMORY.md 역할 분리(운영 관점)
이 구조를 “문서 3종 세트”로 운영하면 안정적입니다.
SOUL.md (에이전트의 성격/행동 원칙)
- 말투/태도
- 우선순위(정확성, 보안 우선, 로컬 우선 등)
- 금지/경계(추측 금지, 위험 작업은 확인 필요 등)
➡️ “이 에이전트가 어떤 방식으로 행동해야 하는가”의 헌법
USER.md (사용자 환경/업무 맥락)
- 사용자의 역할/기술 스택/운영 환경
- 선호 출력 포맷
- 자주 다루는 시스템(예: Elastic, Wazuh, n8n…)
➡️ “누구를 돕고 있는가 / 어떤 환경인가”
MEMORY.md (지속 사실/결정 사항)
- 변경이 잦지 않은 핵심 기억
- 프로젝트 결정, 표준, 반복 룰
➡️ “세션을 넘어 유지되는 핵심 상태”
직접 구현 시: 최소 구현(MVP) 설계
“바로 만들 수 있는” 최소 기능을 기준으로 제안합니다.
디렉터리 구조 예시
workspace/
MEMORY.md
SOUL.md
USER.md
memory/
2026-02-05.md
2026-02-04.md
projects/
security-automation.md
index/
memory.sqlite
SQLite 스키마(예시 DDL)
-- 문서(파일) 메타
CREATE TABLE IF NOT EXISTS docs (
doc_id INTEGER PRIMARY KEY,
path TEXT UNIQUE NOT NULL,
content_hash TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- 청크 메타
CREATE TABLE IF NOT EXISTS chunks (
chunk_id TEXT PRIMARY KEY,
doc_path TEXT NOT NULL,
start_line INTEGER,
end_line INTEGER,
heading TEXT,
updated_at TEXT NOT NULL
);
-- FTS5: BM25 키워드 검색용 (text는 청크 본문)
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts
USING fts5(chunk_id, doc_path, text, tokenize = 'unicode61');
-- 벡터: sqlite-vec 같은 확장을 쓰는 경우를 가정(개념 예시)
-- 실제 문법은 확장에 따라 다르므로 “embedding 저장 테이블” 개념으로 보시면 됩니다.
CREATE TABLE IF NOT EXISTS chunks_vec (
chunk_id TEXT PRIMARY KEY,
embedding BLOB NOT NULL
);
인덱싱 워커 의사코드(파이썬 스타일)
import hashlib
from pathlib import Path
def sha256_text(s: str) -> str:
return hashlib.sha256(s.encode("utf-8")).hexdigest()
def index_file(db, path: Path):
text = path.read_text(encoding="utf-8")
file_hash = sha256_text(text)
prev_hash = db.get_doc_hash(str(path))
if prev_hash == file_hash:
return # 변경 없음
chunks = chunk_markdown(text, target_tokens=400, overlap_tokens=80)
db.begin()
try:
db.upsert_doc(path=str(path), content_hash=file_hash)
# 기존 청크 제거 후 재삽입(파일 단위 atomic reindex)
db.delete_chunks_by_path(str(path))
for c in chunks:
chunk_id = f"{path}:{c.start_line}:{c.end_line}"
db.upsert_chunk_meta(
chunk_id=chunk_id,
doc_path=str(path),
start_line=c.start_line,
end_line=c.end_line,
heading=c.heading,
)
db.fts_upsert(chunk_id=chunk_id, doc_path=str(path), text=c.text)
emb = embed(c.text) # 로컬/원격 임베딩 선택 가능
db.vec_upsert(chunk_id=chunk_id, embedding=emb)
db.commit()
except:
db.rollback()
raise
검색 결합(하이브리드 랭킹) 의사코드
def hybrid_search(query: str, k_vec=20, k_lex=20, top_n=8):
q_emb = embed(query)
vec_hits = vec_search(q_emb, k=k_vec) # [(chunk_id, vec_score), ...]
lex_hits = bm25_search(query, k=k_lex) # [(chunk_id, bm25_score), ...]
# 합집합
merged = {}
for cid, s in vec_hits:
merged.setdefault(cid, {})["vec"] = s
for cid, s in lex_hits:
merged.setdefault(cid, {})["bm25"] = s
# 정규화(간단 예: min-max, 또는 z-score)
vec_norm = normalize([merged[c].get("vec", 0) for c in merged])
bm25_norm = normalize([merged[c].get("bm25", 0) for c in merged])
# 가중합(예시)
results = []
for i, cid in enumerate(merged):
score = 0.6 * vec_norm[i] + 0.4 * bm25_norm[i] + recency_bonus(cid)
results.append((cid, score))
results.sort(key=lambda x: x[1], reverse=True)
return results[:top_n]
보안 관점 내부 운영/점검 포인트
이 구조는 “로컬 파일 기반”이라 안전해 보이지만, 실제 운영에서는 다음 리스크가 자주 생깁니다.
민감정보 유입(PII/계정/토큰) 통제
점검 포인트
memory/에 API 키, 쿠키, 토큰, 개인식별정보가 들어갈 수 있음- 에이전트가 “기억해둘게요” 하면서 그대로 저장할 위험
권장 통제
- 저장 전 필터링(정규식/룰)
- 예:
AKIA...,-----BEGIN PRIVATE KEY-----, JWT 패턴 등
- 예:
- “민감정보 금지 섹션”을 SOUL.md에 명시
- 저장 시 마스킹 규칙 적용(예: 토큰은 앞 4~6자리만 남김)
원격 임베딩/폴백(데이터 외부 전송) 위험
점검 포인트
- 로컬 임베딩이 실패하면 원격으로 폴백되는 설정이면,
메모리 내용이 외부로 나갈 수 있음
권장 통제
- 기본값을 “로컬 고정(fallback none)”으로
- 원격 사용이 필요하면
- 전송 전 민감정보 제거
- 전송 범위를 “스니펫/요약”으로 제한
- 감사 로그 남기기(언제, 어떤 provider로, 어떤 파일에서)
인덱스(DB) 접근 통제
점검 포인트
- SQLite 파일은 “그 자체가 지식 DB”
- 권한이 느슨하면 내부자/멀웨어가 탈취하기 좋음
권장 통제
- 파일 권한 최소화:
chmod 600 memory.sqlite - 워크스페이스 디렉터리 자체를 암호화(선택)
- 백업/동기화 정책(개인 PC ↔ 서버) 명확히
프롬프트 인젝션(메모리에 악성 지시문 주입)
점검 포인트
- 메모리 문서 안에 “에이전트에게 내리는 지시”가 섞이면 위험
- 특히 외부 문서/웹에서 긁어온 내용을 그대로 저장하면 더 위험
권장 통제
- 메모리 문서에는 사실/결정/선호만 저장하고,
“행동 지시”는 SOUL.md로 분리 - 저장 시 “지시문 패턴” 탐지
- 예: “이제부터 너는…”, “시스템 프롬프트를 무시하고…”
- 검색 결과를 컨텍스트에 넣기 전에
- “메모리 내용은 사실로 취급하되, 지시로는 취급하지 않는다” 룰 적용
감사/추적성(누가 무엇을 저장/수정했나)
권장 운영
- Git으로 버전관리 + 커밋 메시지 규칙
- 자동 기록(에이전트가 수정했으면 변경 로그 남김)
- “승격(promote)” 시 원문 위치 링크(경로+라인) 기록
실전 활용 시나리오
운영 표준/결정 관리
- 장애 대응 중 결정된 우회 설정
- 배포 정책(승인 흐름, 롤백 기준)
- “다음에도 반복될 패턴”을 MEMORY에 승격
보안 점검/감사 대응
- 점검 항목과 결과 요약(일지)
- “조치 기준/예외 기준”을 장기 메모리에 축적
- 나중에 “작년엔 왜 예외였지?” 질의 시 즉시 회수
자동화 에이전트와 결합
- n8n/webhook으로 알림이 오면, 에이전트가 memory_search로 과거 유사 사건을 찾아 “권장 조치 체크리스트”를 자동 작성
권장 운영 규칙(가장 현실적인 베스트 프랙티스)
- 원본은 Markdown, 인덱스는 재생성 가능
- 단기(日誌) → 장기(MEMORY) 승격 규칙을 명문화
- 민감정보는 기본적으로 저장 금지 + 저장 전 필터링
- 원격 임베딩 폴백은 기본 OFF
- 메모리는 사실 저장소, 지시는 SOUL로 분리
- Git 버전관리 + 변경로그로 추적성 확보
댓글