쌓인 노트를 스스로 잇게 — 임베딩 유사도로 자동연결
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과 같은 로직이에요. 임계값을 올려보면 약한 연결이 끊기면서 군집이 쪼개지는 게 눈에 보여요.