지식 그래프로 에이전트 기억 만들기 — 문서 레지스트리와 하이브리드 검색
에이전트에게 장기 기억을 주는 가장 흔한 방법은 파일에 적어두고 나중에 읽는 것입니다. 처음엔 잘 됩니다. 파일이 서른 개가 되기 전까지는요. 그다음부터는 "무슨 파일에 적었더라"가 새로운 문제가 됩니다.
임베딩 검색을 붙이면 절반쯤 해결됩니다. 하지만 임베딩은 의미는 알아도 관계는 모릅니다. "이 결정이 어떤 사건 때문에 나왔지"를 물으면, 비슷한 문장은 찾아오지만 인과의 사슬은 못 따라갑니다. 이 글은 파일 더미를 그래프로 바꾸는 설계에 관한 것입니다.
1. 문서 레지스트리 — 내용이 아니라 위치를 저장한다
첫 번째 결정은 "본문을 DB에 넣을 것인가"입니다. 넣지 않는 쪽을 권합니다. 파일은 파일시스템에 두고, DB에는 메타데이터만 둡니다.
CREATE TABLE documents (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL, -- 멀티테넌트 격리
agent_id TEXT NOT NULL, -- 에이전트별 네임스페이스
scope TEXT NOT NULL, -- personal / team / shared
path TEXT NOT NULL, -- 워크스페이스 상대경로
title TEXT,
doc_type TEXT, -- note / memory / context / skill
content_hash TEXT, -- SHA-256, 변경 감지용
embedding BLOB, -- 의미 검색용 벡터
updated_at TIMESTAMP,
UNIQUE (agent_id, scope, path)
);
이렇게 하면 얻는 게 많습니다. 사람이 에디터로 파일을 고쳐도 되고, git으로 버전 관리가 되고, DB가 날아가도 원본은 살아 있습니다. DB는 색인일 뿐이니 다시 만들면 됩니다.
대신 동기화가 필요합니다. content_hash가 그 역할을 합니다. 파일 감시자가 변경을 감지하면 해시를 다시 계산하고, 달라졌을 때만 임베딩을 새로 만듭니다. 임베딩 호출은 돈이 드니까 이 비교 한 줄이 비용을 크게 줄입니다.
func (w *SyncWorker) onFileChanged(path string) error {
content, err := os.ReadFile(path)
if err != nil {
return err
}
sum := sha256.Sum256(content)
hash := hex.EncodeToString(sum[:])
doc, _ := w.store.GetByPath(path)
if doc != nil && doc.ContentHash == hash {
return nil // 내용 동일 → 임베딩 재생성 생략
}
return w.reindex(path, content, hash)
}
2. 위키링크 — 관계를 본문 안에 적는다
관계를 별도 UI로 관리하게 하면 아무도 안 씁니다. 사람이든 에이전트든 마찬가지입니다. 그래서 본문에 쓰는 문법으로 만듭니다.
2026-03 결제 장애는 [[incidents/2026-03-payment]] 참고.
그때 [[decisions/retry-policy|재시도 정책]]을 바꿨다.
[[대상]] 또는 [[대상|표시할 텍스트]] 형태입니다. 파서는 이 패턴을 긁어 링크 테이블에 저장합니다.
CREATE TABLE links (
from_doc_id TEXT NOT NULL,
to_doc_id TEXT NOT NULL,
link_type TEXT NOT NULL, -- wikilink / reference
context TEXT, -- 링크 주변 텍스트 ~50자
UNIQUE (from_doc_id, to_doc_id, link_type)
);
CREATE INDEX idx_links_from ON links(from_doc_id);
CREATE INDEX idx_links_to ON links(to_doc_id); -- 역링크용
두 가지가 핵심입니다.
context컬럼 — 링크 주변 50자를 같이 저장합니다. 나중에 "왜 이 둘이 연결됐지"를 문서를 열어보지 않고도 알 수 있습니다. 그래프를 탐색할 때 이 한 줄이 있느냐 없느냐가 체감 차이가 큽니다to_doc_id인덱스 — 이게 역링크(backlink)를 공짜로 만들어 줍니다. "이 문서를 참조하는 문서들"이 인덱스 조회 한 번이죠. 양방향 링크를 따로 저장할 필요가 없습니다
파싱에서 챙길 잔가지들: 빈 [[]]는 건너뛰고, 앞뒤 공백은 자르고, 확장자가 없으면 .md를 붙이고, 아직 없는 문서를 가리키는 링크는 에러가 아니라 정상으로 둡니다. 나중에 만들 문서를 미리 가리키는 건 자연스러운 사용 패턴입니다.
3. 하이브리드 검색 — 키워드와 의미를 섞는다
둘 중 하나만으로는 부족합니다.
- 키워드 검색(FTS) — 정확한 명사, 에러 코드, 사람 이름에 강합니다.
ORA-01555를 찾는 데 임베딩은 쓸모가 없습니다 - 임베딩 검색 — "그때 결제 느려졌던 문제" 같은 표현이 다른 질의에 강합니다
합치는 방법은 점수 정규화입니다. 두 검색의 점수 체계가 전혀 다르므로(BM25는 상한이 없고 코사인 유사도는 0~1), 각각 0~1로 정규화한 뒤 가중치를 곱해 더합니다.
func hybridSearch(q string, kw, vecWeight float64) []Hit {
ftsHits := ftsSearch(q) // BM25 점수
vecHits := vectorSearch(embed(q)) // 코사인 유사도
scores := map[string]float64{}
for _, h := range normalize(ftsHits) { // 최고점으로 나눠 0~1
scores[h.ID] += h.Score * kw
}
for _, h := range normalize(vecHits) {
scores[h.ID] += h.Score * vecWeight
}
return topN(scores, 10)
}
기억은 한 곳에만 있지 않습니다. 문서 볼트, 세션 요약, 지식 그래프 엔티티가 각각 다른 저장소에 있다면 병렬로 던지고 결과를 합칩니다.
질의 ─┬─→ 문서 볼트 검색 [가중치 0.4]
├─→ 세션 요약 검색 [가중치 0.3]
└─→ 지식 그래프 검색 [가중치 0.3]
↓
각 소스별 0~1 정규화 → 가중치 곱
↓
ID로 병합·중복 제거 → 점수 내림차순 → 상위 N
가중치는 절대적인 값이 아닙니다. 세션 요약이 유난히 유용한 도메인이면 그쪽을 올리세요. 다만 정규화를 먼저 하고 가중치를 나중에 곱하는 순서는 지켜야 합니다. 순서가 바뀌면 점수 스케일이 큰 소스가 항상 이깁니다.
4. 멀티홉 탐색 — 관계를 따라가기
여기가 그래프를 만든 진짜 이유입니다. "이 결정과 관련된 것들"을 물으면, 검색은 직접 언급된 문서만 찾습니다. 2~3홉 떨어진 맥락은 못 가져오죠. 재귀 쿼리로 따라갑니다.
WITH RECURSIVE walk(doc_id, depth) AS (
SELECT :start_id, 0
UNION
SELECT l.to_doc_id, w.depth + 1
FROM links l
JOIN walk w ON l.from_doc_id = w.doc_id
WHERE w.depth < 2 -- 최대 2홉
)
SELECT d.*, w.depth
FROM walk w JOIN documents d ON d.id = w.doc_id
WHERE w.depth > 0
ORDER BY w.depth;
실전 주의점 두 가지입니다.
- 깊이는 2~3에서 끊으세요. 잘 연결된 그래프에서 4홉이면 거의 모든 문서가 걸립니다. "관련 있음"이 "전부"가 되는 순간 검색은 무의미해집니다
- 홉이 멀수록 점수를 깎으세요. 1홉은 1.0, 2홉은 0.5 같은 감쇠를 주면, 직접 관련 문서가 자연스럽게 위로 올라옵니다
UNION(UNION ALL이 아니라)을 쓴 것도 의도적입니다. 순환 링크가 있어도 이미 방문한 노드가 중복되지 않아 무한 루프에 빠지지 않습니다.
5. 컨텍스트에 넣을 때 — 전부 넣지 말 것
검색이 잘 된다고 결과를 통째로 프롬프트에 넣으면 원점입니다. 단계적으로 주는 편이 낫습니다.
- L0 — 제목과 한 줄 요약만. 항상 주입. 몇십 토큰
- L1 — 문단 단위 발췌. 관련도 높은 상위 몇 개만
- L2 — 전문. 에이전트가
read_file로 직접 요청했을 때만
이러면 에이전트가 "뭐가 있는지"는 항상 알되, 필요한 것만 펼쳐 봅니다. 사람이 목차를 훑고 필요한 장만 펴는 것과 같습니다.
정리
- 본문은 파일시스템에, DB에는 메타데이터와 색인만 — 원본이 살아 있어야 복구가 쉽다
content_hash비교로 바뀐 문서만 재임베딩 — 비용의 대부분이 여기서 절약된다- 관계는
[[위키링크]]로 본문에 — 별도 UI로 만들면 아무도 안 쓴다 to_doc_id인덱스 하나로 역링크가 공짜,context컬럼으로 "왜 연결됐는지" 보존- 검색은 키워드 + 임베딩을 각각 0~1 정규화 후 가중 합산 — 정규화가 먼저다
- 멀티홉은 재귀 쿼리 + 깊이 2~3 제한 + 홉당 점수 감쇠
- 컨텍스트 주입은 L0/L1/L2 단계적으로 — 검색 결과를 통째로 넣으면 원점
에이전트 기억의 어려움은 저장이 아니라 회수입니다. 무엇을 적을지보다, 3주 뒤에 그걸 어떻게 다시 찾아낼지를 먼저 설계하세요. 그 관점에서 보면 그래프는 화려한 기능이 아니라 가장 값싼 회수 경로입니다.
댓글
댓글 쓰기