쌓인 노트를 스스로 잇게 — 임베딩 유사도로 자동연결
Synap은 노트끼리 자동으로 이어지는 메모 앱입니다. 노트를 저장하면 의미가 가까운 다른 노트를 찾아 링크를 걸고, 그 연결을 그래프로 보여줍니다. 이 글은 그 자동연결을 어떻게 만들었는지 — 임베딩 유사도부터 그래프·군집화까지 — 정리한 것입니다.
👉 이 블로그의 글들로 돌려본 축소판을 여기서 볼 수 있습니다 (내 글들이 주제별로 어떻게 이어지는지).
왜 자동이어야 했나
노트를 오래 쓰다 보면, 서로 관련된 노트가 분명히 있는데 연결이 안 돼 있습니다. 관련 노트를 손으로 링크할 수도 있지만, 저는 그걸 거의 안 하게 되었습니다. 쓸 땐 바쁘고, 나중엔 그런 노트가 있었는지도 잊습니다.
그래서 “쓰기만 하면 알아서 이어지면 좋겠다”가 출발점이었습니다. 내가 잊은 연결을 앱이 상기시켜주는 그림입니다.
키워드로는 부족했습니다 — 그래서 임베딩
처음 떠오른 건 키워드였습니다. 같은 단어가 겹치면 잇는 식이었습니다. 그런데 이건 금방 한계가 왔습니다. “아침 러닝 기록”과 “스쿼트 자세”는 같은 운동 얘기인데 겹치는 단어가 없습니다. 키워드로는 못 잇습니다.
그래서 임베딩으로 갔습니다. 문장을 의미 벡터로 바꾸면, 단어가 안 겹쳐도 의미가 가까우면 벡터가 가깝습니다. 노트의 제목 + 본문을 임베딩해서 저장해두고, 그 벡터끼리의 거리로 유사도를 재는 방식입니다.
pgvector로 kNN
벡터는 Postgres의 pgvector에 담았습니다. 코사인 거리 연산자 <=>로 가장 가까운 노트 k개를 바로 뽑을 수 있습니다.
SELECT id, 1 - (embedding <=> CAST(:vec AS vector)) AS similarity
FROM note
WHERE user_id = :userId AND embedding IS NOT NULL
ORDER BY embedding <=> CAST(:vec AS vector)
LIMIT :k
embedding <=> vec가 코사인 거리라서, 1 - 거리를 유사도로 썼습니다. ORDER BY 거리 LIMIT k가 곧 kNN입니다.
한 가지 걸렸던 건, vector 타입은 JPA가 모른다는 점이었습니다. 그래서 임베딩 갱신도, 위 조회도 전부 네이티브 SQL로 하고, 벡터는 CAST(:vec AS vector)로 문자열 리터럴을 캐스팅해서 넘겼습니다.
저장할 때마다 다시 잇기
연결은 노트를 저장하는 순간 다시 계산합니다. 흐름은 이렇습니다.
private void embedAndRelink(Note note) {
float[] vector = embeddingClient.embed(note.getTitle() + "\n" + note.getBody());
noteRepository.updateEmbedding(note.getId(), VectorLiterals.toLiteral(vector));
// 이 노트에 걸린 자동 링크를 지우고 다시 계산
noteLinkRepository.deleteAllInvolving(note.getId());
var neighbors = noteRepository.findNearestForUser(literal, note.getUserId(), note.getId(), linking.topK());
for (var n : neighbors) {
double sim = clamp(n.getSimilarity());
if (sim < linking.threshold()) continue; // 임계값 미달은 버림
noteLinkRepository.upsert(note.getId(), n.getId(), (float) sim); // 양방향
noteLinkRepository.upsert(n.getId(), note.getId(), (float) sim);
}
}
포인트가 몇 개 있습니다.
- topK로 후보를 자르고, threshold로 거른다 — 가까운 순 k개만 보고, 그중에서도 유사도가 임계값 이상인 것만 링크합니다. topK는 상한(한 노트가 너무 많이 안 이어지게), threshold는 하한(억지 연결 방지)입니다. 둘 중 하나만으론 부족했습니다.
- 대칭 링크 + 유사도 가중 — A→B를 걸면 B→A도 같이 걸고, 그 링크에 유사도 값을 실어둡니다. 나중에 그래프에서 강한 연결일수록 굵고 가깝게 그리는 데 씁니다.
- 자동은 매번 갈아엎는다 — 저장할 때마다 이 노트의 자동 링크를 전부 지우고 다시 계산합니다. 노트를 고치면 의미가 바뀌기 때문입니다.
자동연결 옆에는 수동 링크도 따로 두었습니다. 자동이 놓친 연결은 손으로 걸 수 있게 한 것입니다 — 둘은 별개로 공존합니다.
연결을 눈으로 — 그래프
링크가 쌓이면 그건 사실 그래프입니다. 노트가 노드, 링크가 엣지입니다. 그래서 화면에도 그래프로 보여주었습니다.
렌더는 Canvas 2D로 갔습니다. 처음엔 SVG로 그렸는데, 노드가 수십 개만 넘어가도 DOM 노드가 그만큼 생겨서 버벅였습니다. Canvas는 한 장에 다 그리니까 훨씬 가벼웠습니다. 배치는 d3-force로 물리 시뮬레이션을 돌리고 — 가까운 노드는 끌어당기고, 전체는 밀어내고 — 좌표만 받아서 Canvas에 그리는 구조입니다.
이어져 있어도 “덩어리”는 다르다 — 군집화
그런데 링크만 그리면, 얼추 다 이어져 있어서 어디가 한 묶음인지가 안 보입니다. 그래서 군집화를 얹었습니다.
모듈러리티 기반 Louvain으로 밀집한 하위 그룹을 나눕니다. 연결돼 있어도 안에서 더 촘촘한 덩어리가 있으면 갈라주는 것입니다. 그리고 각 군집엔 TF-IDF로 대표 키워드를 뽑아 라벨을 붙였습니다 — 군집끼리 겹치지 않는 distinct한 단어를 골랐습니다.
핵심은 이걸 수동 분류 없이 했다는 점입니다. 카테고리를 미리 정해두는 게 아니라, 연결 구조 자체에서 의미 덩어리가 떠오르게 한 것입니다.
돌아보면
- 자동연결의 값어치는 “상기”에 있는 것 같습니다. 검색은 내가 찾을 때 도와주지만, 자동연결은 내가 안 찾아도 “이거랑 이거 관련 있어요”를 옆에 띄워줍니다. 잊고 있던 노트가 딸려 나올 때가 제일 좋았습니다.
- 임계값은 정답이 없었습니다. 낮추면 다 이어져서 hairball이 되고, 높이면 다 흩어집니다. 데이터가 얼마나 촘촘한지에 따라 매번 다른 것 같아서, topK와 threshold를 둘 다 두고 조절하는 쪽으로 갔습니다.
- 의미로 잇는 것과 보여주는 건 다른 문제였습니다. 임베딩으로 잘 이어도, 군집화·라벨·레이아웃이 없으면 사람 눈엔 그냥 실뭉치입니다. “잇기”만큼 “보여주기”에 시간이 들었습니다.
궁금하시면 이 블로그의 글들로 같은 걸 돌려본 데모를 만져 보세요. 거긴 임베딩 대신 태그 기반 간이 유사도로 잇지만, 군집화(Louvain)·force 레이아웃·라벨링은 Synap과 같은 로직입니다. 임계값을 올려보면 약한 연결이 끊기면서 군집이 쪼개지는 게 눈에 보입니다.