AI 기억 시스템 개선기 — 벡터 검색 + 태그 확장으로 "저번에 뭐 들었더라?"를 찾는 법

지난 글에서 세션 태그, 토픽 묶음, 매칭 점수 세 가지로 AI가 대화를 기억하게 만들었습니다. 잘 되는 것 같았는데, 실제로 써보니 근본적인 문제가 있었습니다.
키워드 검색의 한계
이전 시스템은 키워드 매칭이 핵심이었습니다. "마이그레이션"으로 검색하면 "마이그레이션"이 들어간 대화만 찾습니다.
K: "저번에 무슨 노래 들었더라?" → 키워드 추출: ["노래", "음악"] → "노래" ILIKE 검색 → 0건 → 니콜: "못 찾겠어용 ㅠㅠ"
실제로는 "블루밍 들려줘"라는 대화가 있었는데, "노래"라는 글자가 없어서 못 찾습니다. 이걸 6번 연속 실패하는 걸 보고, 키워드 매칭으로는 안 되겠다 싶었습니다.
해결: 벡터 검색 + 태그 확장
pgvector가 이미 설치되어 있었으니, 임베딩 기반 검색을 도입했습니다. 하지만 단순 벡터 검색만으로는 부족합니다. "블루밍 들려줘"와 "무슨 노래 들었더라?"의 코사인 유사도는 0.76 정도로, 꽤 높지만 완벽하진 않습니다.
핵심 아이디어는 벡터 검색으로 가장 가까운 대화를 찾고, 그 대화의 태그로 관련 대화를 확장하는 것입니다.
K: "저번에 무슨 노래 들었더라?" ↓ 1단계: 벡터 유사도 → "블루밍 들려줘" 매칭 (0.76) ↓ 2단계: 이 대화의 태그 = [음악] ↓ 3단계: [음악] 태그가 있는 다른 대화도 가져옴 → "유튜브로 음악 들려줘" (3일 전) → "너드커넥션 그대만 있다면 들려줘" (2일 전) ↓ 4단계: 전부 프롬프트에 주입 ↓ 니콜: "블루밍이요! 그리고 너드커넥션도 들으셨었어용!"
벡터 하나만 맞으면 같은 태그의 관련 대화가 줄줄이 따라옵니다.
임베딩 대상: 질문+응답을 합친다
이전 시스템에서는 사용자 메시지만 임베딩했습니다. 니콜의 응답을 포함하면 잘못된 답변이 고착화될 수 있다는 우려 때문이었는데, 이 방식으로는 "블루밍"이라는 핵심 정보가 임베딩 데이터에서 누락됩니다.
// 이전: 사용자 메시지만 embed("블루밍 들려줘") // 개선: 사용자 + 니콜 응답 합침 embed("K: 블루밍 들려줘\n니콜: 블루밍 재생했어용~")
"블루밍"이 질문과 응답 양쪽에 있으니 벡터 공간에서 음악 관련 질문과 더 가깝게 배치됩니다. 그리고 틀린 답변 문제는 태그 시스템이 해결합니다 — 나중에 정정된 대화도 같은 태그로 검색되니까, 최신 정보가 시간순으로 함께 주입됩니다.
배치 태깅: 5쌍마다 LLM 1회
매 메시지마다 LLM을 호출하면 비용이 늘어나니까, 대화쌍 5개를 모아서 한 번에 태깅합니다.
[버퍼에 쌓임] 1. K: "안녕" / 니콜: "안녕하세용~" 2. K: "오늘 뭐 공부했어?" / 니콜: "Next.js 라우팅이요!" 3. K: "App Router 좋아?" / 니콜: "Pages보다 나아용!" 4. K: "삼성전자 어때?" / 니콜: "오늘 3% 올랐어용" 5. K: "비트코인은?" / 니콜: "9만8천 달러예용" ↓ 5쌍 도달 → Gemini Flash 1회 호출 ↓ { "1": null, ← 인사, 스킵 "2": ["공부", "Next.js"], "3": ["Next.js", "App Router"], "4": ["주식", "삼성전자"], "5": ["비트코인", "암호화폐"] }
null이 반환된 대화는 임베딩 대상에서 제외합니다. 인사, 감탄사, 단순 응답이 자연스럽게 걸러집니다.
5쌍이 채워지지 않으면? 마지막 메시지 후 5분이 지나면 그때까지 모인 것만 처리합니다.
5쌍 도달 → 즉시 처리 5분 경과 → 모인 것만 처리 (1쌍이든 4쌍이든) 서버 재시작 → 미처리분 자동 복구
태그 자동 진화
태그를 하드코딩하면 새로운 주제가 나올 때마다 수정해야 합니다. 대신 LLM에게 기존 태그 목록을 보여주고 선택하게 합니다.
기존 태그: ["다이어트", "운동", "음악", "주식", "Next.js"] 이 중에서 선택하거나, 적절한 게 없으면 새로 만들어.
처음에는 태그가 0개입니다. LLM이 자유롭게 생성합니다. 쌓일수록 기존 태그 재사용률이 올라갑니다. 100번째 배치쯤 되면 85개의 태그가 자연스럽게 형성되어 있었습니다.
-- 자동으로 쌓인 태그 레지스트리 SELECT name, usage_count FROM tag_registry ORDER BY usage_count DESC LIMIT 10; name | usage_count -------------+------------- 기억 | 84 시스템 | 53 이미지 | 41 AI 에이전트 | 41 주식 | 34 음악 | 19 운동 | 18 봇마당 | 10
"기억"이 84회로 가장 많은데, 이런 범용 태그가 문제가 됩니다.
범용 태그 필터링
"기억", "오류", "요청사항" 같은 태그는 거의 모든 대화에 붙습니다. 태그 확장 검색에서 이런 태그를 쓰면 맥락과 무관한 대화가 뒤섞여 나옵니다.
K: "비트코인 얘기 했었지?" → 벡터 매칭: [검색, 비트코인, 기억] → "기억" 태그로 확장 → 84건 중 아무거나 매칭 → 다이어트, 운동, 날씨... 노이즈
해결: 전체 대화의 15% 이상에서 사용된 태그는 확장 검색에서 제외합니다.
-- 범용 태그 자동 감지 SELECT name FROM tag_registry WHERE usage_count > (SELECT count(*) * 0.15 FROM conversation_pair_tags);
이러면 "비트코인"(34회, 8%)은 살아남고 "기억"(84회, 20%)은 걸러집니다. 대화가 쌓일수록 임계값이 자동으로 조정됩니다.
DB 스키마
-- 대화쌍 + 태그 + 임베딩 CREATE TABLE conversation_pair_tags ( id SERIAL PRIMARY KEY, activity_id TEXT NOT NULL, session_id TEXT, user_message TEXT NOT NULL, -- K 메시지 (500자) nicole_response TEXT, -- 니콜 응답 (300자) tags TEXT[], -- LLM이 붙인 태그 embed_content TEXT, -- 임베딩 대상 텍스트 embedding vector(768), -- Gemini 임베딩 batch_id TEXT, -- 배치 식별자 created_at TIMESTAMPTZ DEFAULT NOW() ); -- 태그 검색용 GIN 인덱스 CREATE INDEX idx_cpt_tags ON conversation_pair_tags USING GIN(tags); -- 벡터 검색용 HNSW 인덱스 CREATE INDEX idx_cpt_embedding ON conversation_pair_tags USING hnsw(embedding vector_cosine_ops);
-- 자동 진화하는 태그 목록 CREATE TABLE tag_registry ( id SERIAL PRIMARY KEY, name TEXT UNIQUE NOT NULL, usage_count INT DEFAULT 1, first_used_at TIMESTAMPTZ DEFAULT NOW(), last_used_at TIMESTAMPTZ DEFAULT NOW() );
기존 conversation_embeddings 테이블은 삭제하지 않고 전환 기간 동안 병행 운영합니다. 새 시스템에서 검색이 실패하면 기존 시스템으로 fallback합니다.
전체 흐름
[대화 발생] K: 메시지 → 니콜: 응답 ↓ addToBuffer() — 메모리 버퍼에 추가 ↓ [5쌍 도달 or 5분 경과] ↓ flushBuffer() ├─ SELECT name FROM tag_registry (기존 태그 조회) ├─ Gemini Flash 1회 호출 (배치 태깅) │ → [1]: ["Next.js", "App Router"] │ → [2]: null (스킵) │ → ... ├─ 태그 있는 쌍만: │ ├─ embed("K: ... \n니콜: ...") (Gemini Embedding) │ ├─ INSERT INTO conversation_pair_tags │ └─ UPSERT INTO tag_registry └─ 태그 null인 쌍: 스킵 [질문 시] K: "저번에 무슨 노래 들었더라?" ↓ findSimilarByTags() ├─ 1단계: embed(질문) → 벡터 유사도 검색 │ → "블루밍 들려줘" (0.76) ├─ 2단계: 태그 [음악] → 범용 태그 제외 ├─ 3단계: [음악] 태그의 다른 대화 검색 (시간순) │ → "유튜브로 음악 들려줘" │ → "너드커넥션 그대만 있다면" └─ 4단계: 프롬프트 주입 ## 관련 이전 대화 ## 같은 주제 대화 이력 (태그: 음악)
비용
| 작업 | 모델 | 호출 빈도 | 비용 |
|---|---|---|---|
| 배치 태깅 | Gemini 2.0 Flash | 5쌍당 1회 | 무료 티어 |
| 임베딩 | Gemini Embedding 001 | 태그 있는 쌍만 | 무료 티어 |
| 검색 시 임베딩 | Gemini Embedding 001 | 질문당 1회 | 무료 티어 |
하루 50~100개 메시지 기준, Gemini 무료 티어(분당 15회, 일 1,500회) 안에서 돌아갑니다. 추가 비용 0원.
실제 검증
438건의 기존 대화를 마이그레이션하고 테스트했습니다.
태깅 결과: 438건 중 430건 태그 부여, 8건 스킵 (인사, /clear 등). 85개 고유 태그 자동 생성.
테스트 1: "저번에 무슨 노래 들었더라?" 이전: "못 찾겠어용 ㅠㅠ" (6회 연속 실패) 개선: "블루밍이요! 유튜브 링크도 가져왔어용~" ✅ 테스트 2: "이전 프로젝트 어떻게 되고 있어?" 이전: 키워드 "프로젝트"로 검색 → 관련 없는 대화 매칭 개선: 벡터 유사도 → 태그 [Next.js, 마이그레이션] 확장 → 관련 대화 5건 시간순 주입 ✅
이전 시스템과의 비교
┌──────────────────┬──────────────────┬──────────────────┐ │ │ 이전 (키워드) │ 개선 (벡터+태그) │ ├──────────────────┼──────────────────┼──────────────────┤ │ 검색 방식 │ ILIKE 키워드 │ 코사인 유사도 │ │ 동의어 매칭 │ ✗ │ ✓ (임베딩) │ │ 응답 정보 포함 │ ✗ │ ✓ (K+니콜 합침) │ │ 잡담 필터링 │ ✗ │ ✓ (null 태깅) │ │ 주제 확장 │ 토픽 키워드만 │ 태그 기반 확장 │ │ LLM 호출/메시지 │ 1~2회 │ 0.2회 (5쌍 배치) │ │ 추가 비용 │ ~$0.006/일 │ 0원 (무료 티어) │ └──────────────────┴──────────────────┴──────────────────┘
남은 과제
완벽하진 않습니다.
태그 확장 노이즈: "비트코인" 질문에 "운동" 태그가 딸려오는 경우가 있습니다. 벡터 매칭된 대화 중 하나가 [비트코인, 운동, 요약] 태그를 갖고 있으면 "운동"까지 확장됩니다. 매칭 결과에서 2회 이상 등장하는 태그만 확장하는 식으로 개선할 수 있습니다.
시간 변화 추적: "이전 프로젝트가 지금 어떻게 됐어?"같은 질문에서, 동일 태그의 대화 이력이 시간순으로 제공되기는 하지만 상태의 "변화" 자체를 명시적으로 추적하지는 않습니다. Mem0의 사실 추출(Fact Extraction)이나 Zep의 시간 인식 Knowledge Graph를 경량화하여 도입하면 해결할 수 있을 것 같습니다.
마무리
이전 글에서 "pgvector가 이미 설치되어 있으니 임베딩 검색으로 확장하면 해결할 수 있을 것 같습니다"라고 끝냈는데, 실제로 해봤습니다. 벡터 검색 단독으로는 한계가 있고, 태그 확장을 결합해야 "같은 주제의 다른 표현"까지 검색해낼 수 있습니다.
결국 AI 기억 시스템은 하나의 알고리즘으로 해결되지 않습니다. 세션 태그는 "자주 나오는 주제"를, 토픽 묶음은 "대화 흐름"을, 벡터+태그는 "의미적으로 관련된 과거 대화"를 각각 담당합니다. 이 세 겹이 맞물려야 AI가 대화를 자연스럽게 이어가는 것처럼 느껴집니다.





댓글
댓글을 작성하려면 이 필요합니다.