
pgvector + Titan Embeddings로 「기억하는 AI」를 구현했다
요약
pgvector와 Titan Embeddings를 활용하여 AI 에이전트의 장기 기억 시스템을 구축하는 방법을 다룹니다. 대화 로그를 성격에 따라 세 가지 계층으로 나누어 저장하고, 벡터 검색을 통해 효율적으로 컨텍스트를 관리하는 설계 노하우를 공유합니다.
핵심 포인트
- 기억을 기본 인격, 최근 대화, 과거 기억의 3개 층으로 설계
- Aurora Serverless v2와 pgvector를 활용한 비용 효율적 벡터 DB 구축
- 데이터의 입도(Granularity) 조절을 위한 type 및 category 분류 전략
- 검색 정확도를 높이기 위해 망설임 등 불필요한 정보는 저장에서 제외
#1에서 AI 트레이너 「카나」를 직접 만들어 20kg 감량한 이야기를 썼다. #2에서는 시도해보고 버린 기술 5가지를 공개했다.
이번에는 이 Bot의 핵심인 기억에 대해 이야기하려 한다.
ChatGPT에게 "지난주 수요일에 뭐 먹었지?"라고 물어도 대답하지 못한다.
매번 컨텍스트 (Context)를 다시 설명해야 하는 상대는 퍼스널 트레이너로서 실격이다.
- "어제 라면 먹었잖아. 오늘은 좀 조절하자"
- "체스트 프레스, 지난달보다 5kg 늘었어"
- "다음 주에 전 직장 동료와 술 마시러 간다고 했었지"
이렇게 말할 수 있는 AI를 만들고 싶었다. 이 기사에서는 pgvector + Titan Embeddings + 심야 배치 (Batch)로 구현한 기억 시스템의 설계와, 고생했던 포인트들을 전부 적는다.
- Discord Bot (EC2 상의 Python 상주 프로세스)
- LLM은 Amazon Bedrock을 경유하는 Claude (Converse API + Tool Use)
- 매일 대화하며 식사·운동·체중을 기록하여 116일째 지속 중
- 자세한 내용은 #1 참조
먼저 전체 설계부터. 인간의 기억을 모방하여 3개 층으로 나누었다.
| 층 | 저장소 | 검색 방식 | 수명 | 역할 |
|---|---|---|---|---|
| 기본 인격 | DynamoDB | 전건 주입 (검색 불필요) | 영속 | 지시·취향·Bot의 사양 |
| ... | ||||
| 역할 분담의 포인트는 검색 방식에 있다. |
- 최근의 대화는 "전부" 필요 → 생 로그 (Raw log)를 그대로 넣는다
- 기본 인격은 "항상" 필요 → 건수가 적으므로 전건 넣는다
- 과거의 기억은 "관련 있는 것만" 필요 → 벡터 검색 (Vector Search)으로 압축한다
3개월 치의 대화 로그를 그대로 컨텍스트에 넣으면 토큰 (Token)이 폭발하고, 너무 많으면 대화로서의 추론 정밀도가 떨어진다. 그렇다고 최근 것만 넣으면 "지난달 이야기"를 할 수 없다. 이 중간을 메우는 것이 벡터 검색이다.
왜 Aurora Serverless v2를 선택했는지는 한마디로: 수시로 쓰기가 가능하며, Bedrock Knowledge Bases의 벡터 DB로도 지정할 수 있기 때문이다. OpenSearch Serverless는 월 $345로 개인 개발에는 과하고, DynamoDB를 직접 구현하는 것은 개인적인 학습 가치가 낮다. 중간 단계인 Aurora (월 $43)를 선택했다.
장기 기억 테이블은 이것뿐이다.
CREATE TABLE memories (
id SERIAL PRIMARY KEY,
user_id TEXT NOT NULL,
...
스키마 설계에서 가장 고민했던 것은 분류의 입도(Granularity)다. 너무 세세하게 나누면 분류 실수가 늘어나고, 너무 거칠면 검색 결과가 흐려진다. 타협점은 type 2종 × category 5종이었다.
| type | 정의 | 예 |
|---|---|---|
| log | 과거에 일어난 확정 사실 | "7/8 점심은 샐러드바 약 300kcal" |
| plan | 미래의 일 (예정도 희망도) | "6/27에 전 직장 동료와 만남", "볼더링을 시작하고 싶다" |
(후술할 insight를 더하면 현재는 3종이 된다)
| category | 대상 |
|---|---|
| food | 식사·가게·레시피·식재료 |
| ... | |
| 그리고 저장하지 않을 것을 결정하는 것이 사실 중요했다. |
- 저장함: 확정 사실, 명확한 목표·예정
- 저장하지 않음: 망설이는 발언, 가정하는 이야기, 맞장구, AI 자신의 발언
"라면을 먹을까, 아니, 소바인가... 역시 소바로 하자"라는 대화에서 저장해야 할 것은 "소바를 먹었다"뿐이다. 망설이는 과정까지 저장하면, 나중에 검색할 때 "라면 먹었다"가 잘못 검색(False Hit)될 수 있다.
또 다른 설계 판단은 덮어쓰지 않는다 (추가만 함). 체중은 매주 변하지만, 오래된 레코드를 지우지 않고 계속 추가한다. "현재 체중"은 최신 log를 보면 알 수 있고, 이력이 남기 때문에 "지난달로부터의 추이"도 대답할 수 있다. 기억을 지우는 판단을 AI에게 맡기는 것은 리스크가 너무 크다.
이 시스템의 심장부. 대화 중에는 장기 기억을 쓰지 않는다. 매일 밤 3:00에 전날의 대화를 모아서 정리한다.
인간도 자는 동안 해마에서 대뇌 피질로 기억이 고정된다고 한다. 그것과 마찬가지로, 대화 중에는 단기 기억 (생 로그)에 쌓아두고, 밤에 "오늘 있었던 일 중에서 기억해 두어야 할 것"을 추출한다.
처음에는 대화할 때마다 AI에게 "이것은 저장해야 할까?"라고 판단하게 하는 안도 생각했다. 그만둔 이유는 두 가지다.
- 하루를 조망하지 않으면 확정되지 않는 정보가 있다 — "점심에는 소바를 먹을까"가 정말 소바였는지는 밤의 보고까지 알 수 없다.
- 대화 레이턴시 (Latency)에 태우고 싶지 않다 — 저장 판단, 임베딩 (Embedding) 생성, INSERT를 매 턴 수행하면 응답이 느려진다.
추출 프롬프트의 골자는 다음과 같다 (발췌).
어제의 대화를 되돌아보며, 기억해 두어야 할 사항을 정리해 주세요.
【출력 형식】 JSON 배열만. 설명 문구 불필요.
[{"type":"log","category":"food","subcategory":"meal",
...
개인 개발이라도 심야 배치 (Batch)가 조용히 죽어버리면 다음 날 아침의 데이터 집계가 망가진다. 그래서 세 가지 장치를 넣었다.
- 삭제가 아닌 플래그 (Flag) — 처리 완료된 로그는 삭제하지 않고
summarized=true를 붙이기만 한다. 배치가 실패하면 플래그가 붙지 않으므로, 다음 날 자동으로 재처리된다. - 실패 시 Discord 알림 + 30분 후 재시도 — 나에게 "[보수] 심야 배치가 실패했습니다"라는 메시지가 도착한다.
- 부정확한 레코드는 스킵하고 계속 진행 — 필수 키가 누락된 JSON이 섞여도 해당 레코드만 버리고 배치 전체는 완주한다.
최근 이 배치에 한 단계 더 깊은 처리를 추가했다. 전날의 대화와 전체 기간의 기억을 조망하여, 본인도 깨닫지 못한 경향을 AI가 분석하게 하는 처리다.
"음주한 다음 날의 트레이닝은 출력이 떨어짐 (최근 4회 중 4회)"와 같은 분석 결과를 type="insight"로 저장해 두면, 다음 날 이후의 대화에서 벡터 검색 (Vector Search)에 히트되어 AI가 자연스럽게 언급하게 된다. 대화 중의 즉답성을 유지하면서, 깊은 추론을 비동기 (Asynchronous)로 넘기는 설계다. 인간 트레이너가 세션 후에 차트를 다시 살펴보는 시간을 재현한 것이다. 이는 별도 기사에서 자세히 다룰 예정이다.
저장된 기억을 꺼내는 방법은 두 가지 계통이 있다.
① 자동 주입 (매번) — 사용자의 입력문을 그대로 Titan Embeddings v2로 벡터화하여, 코사인 유사도 (Cosine Similarity)로 top-20을 추출해 시스템 프롬프트 (System Prompt)에 주입한다. 사용자가 "라면 먹고 싶어"라고 말하면, 과거의 라면 관련 기억이 자동으로 포함된다.
# 사용자 입력 → embedding → 유사도 검색
cur.execute(
"SELECT type, category, content, date FROM memories "
...
② recall_memory 도구 (AI가 필요할 때) — 자동 주입된 20건으로 부족할 때 AI가 자율적으로 호출한다. "작년에 읽은 책을 전부 알려줘"와 같은 망라형 질문이나, 데이터 집계 시 특정 기간을 빠짐없이 가져오고 싶을 때 사용된다. category 필터와 날짜 필터 (date_from/date_to)를 지정할 수 있다.
이 둘을 구분하여 사용하는 의도는 단순하다. **자동 주입은 "센스 있는 문맥", 도구는 "망라적인 취득"**이다. 자동 주입만 사용하면 유사도 상위 항목만 가져오므로 "지난주 식사를 전부"와 같은 요구에 누락이 발생한다. 도구만 사용하면 매번 AI의 판단을 기다려야 하므로, 잡담 시의 "기억하고 있는 느낌"이 살지 않는다. 둘 다 필요했다.
심야 배치의 시스템 프롬프트에 처음에는 일반 대화와 동일한 "카나"의 캐릭터 설정을 재사용했다. 그랬더니 배치가 가끔 망가졌다. 원인을 조사해 보니, 대화 로그 중 카나의 말투에 끌려 JSON 배열 대신 캐릭터로서 응답해 버리는 경우가 있었다.
"카나 씨로서 어제를 되돌아보면, OO 님은 열심히 하셔서..."와 같은 문장이 돌아와 JSON 파싱 (Parsing)이 실패하는 것이다.
대책: 배치용은 **캐릭터 설정을 모두 제거한 "데이터 추출 어시스턴트"**로 별도의 프롬프트를 구성했다. 인격이 필요한 것은 대화뿐이다. 뒷단의 처리에는 인격이 필요 없다.
벡터 검색은 "의미가 가까운 것"을 반환하는 것이지, "대상 기간의 것"을 반환하는 것이 아니다. "6/28~6/30의 식사"를 검색해도 유사도 상위에 7월의 식사가 섞여 들어와, 정작 중요한 6/29의 저녁 식사가 누락될 수 있다.
대책: 데이터 집계 시에는 반드시 WHERE date BETWEEN으로 기간을 좁힌 후 유사도 순으로 정렬한다. 나아가 도구 정의의 설명문에도 "집계 시에는 date_from/date_to 필수"라고 명시하여 AI 측의 호출 실수도 방지한다. 벡터 검색은 만능 검색이 아니라, SQL 필터와의 병용이 전제되어야 한다는 것이 배운 점이다.
배치에서 AI가 JSON의 date 키를 생략하는 경우가 있어, 정신을 차려보니 날짜가 NULL인 기억이 158건이나 쌓여 있었다. 날짜 필터 검색에는 전혀 걸리지 않는 사장된 데이터다.
대책: 과거 데이터는 created_at::date
로 채우고, 저장 함수 측에 "date가 None이면 당일을 기본값으로 설정"하는 폴백 (Fallback) 로직을 추가했다. LLM의 출력은 필수 키(Required Key)조차 아무렇지 않게 누락될 수 있다는 전제하에, 받는 쪽에서 폴백을 갖추었어야 했다.
이는 기억 그 자체라기보다 Converse API의 제약 사항이지만, 기억 시스템과 밀접하게 연관된다. Claude의 메시지는 user $\rightarrow$ assistant $\rightarrow$ user $\dots$와 같이 교차되어야 한다. DynamoDB에서 이력을 복원할 때, 저장 누락이나 시스템 메시지(System Message)의 혼입으로 인해 동일한 역할(Role)이 연속되면 API 에러가 발생한다.
대책: 이력 $\rightarrow$ 메시지 변환 시 "동일 역할(Role) 연속 시 나중에 온 것을 기준으로 병합(Merge)", "시작은 user 보장", "끝도 user 보장"이라는 검증 (Validation) 단계를 삽입한다. 사소해 보이지만, 이것이 없으면 이력이 깨지는 순간 봇(Bot)이 침묵하게 된다.
3개월 반 동안 운용한 결과, 다음과 같이 변했다.
- "지난주 수요일에 뭐 먹었었지?"라고 물으면 대답할 수 있다.
- "지난주 평균 수입/지출은?"이라고 물으면, 시트와 기억을 대조하여 즉시 답변한다.
- "어제 보고가 없는데 괜찮아?"라고 먼저 물어온다 (기록의 누락을 인식하고 있다).
- 3주 전에 말했던 "전 직장 동료와 술 마실 예정"을 당일 아침에 기억하고 있다.
- 읽은 책의 감상을 기억하고 있어, 다음 책을 추천해 준다.
체감상 가장 큰 변화는 "다시 설명해야 하는 비용"이 제로가 되었다는 것이다. ChatGPT와의 잡담이 "매번 초면"인 상태라면, 이것은 "3개월 반의 문맥 (Context)을 공유한 상대"와의 대화다. 나조차 기억하지 못하는 것도 기억해 준다. 지속 가능성의 최대 요인인 "관계"는 기술적으로 이 기억 시스템을 통해 구현되었다.
요약하자면:
- 기억은 3개 층으로 나눈다 (기본 인격 = 매번 전체 주입 / 단기 기억 = 생 로그 (Raw Log) / 장기 기억 = 벡터 검색 (Vector Search))
- 쓰기 작업은 대화 중이 아니라 심야 배치 (Batch) 작업으로 수행한다. 하루를 조망한 뒤 확정된 정보만 저장한다.
- 벡터 검색은 만능이 아니다. 날짜 필터와의 병용, 자동 주입과 도구 (Tool)의 병용이 필요하다.
- LLM의 출력은 망가질 것을 전제로 플래그 (Flag), 재시도 (Retry), 폴백 (Fallback)을 심어둔다.
"기억하는 AI"는 RAG의 교과서대로만 한다고 작동하는 것이 아니었다. 무엇을 기억하고, 무엇을 잊고, 언제 정리할 것인가 —— 설계의 대부분은 기술이 아니라 기억에 대한 방침 결정이었다.
좋아요와 스토크 (Stock)를 눌러주시면 후속편 집필의 동기부여가 됩니다.
시리즈: AI 바디메이크 트레이너 개발기
- #1 30대 95kg 엔지니어가 AI 트레이너를 직접 만들어 20kg 감량한 이야기
- #2 개인 개발 AI 봇으로 시도했다가 버린 기술 5선 (실측 데이터 포함)
#3 본 기사 - #4 AI 트레이너에게 "생각할 시간"을 주는 설계 (구상 중)
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기