블로그로 돌아가기

pgvector 하이브리드 검색과 RRF 로 만든 AI 캐릭터 장기 기억

pgvectorRAGGeminiRedisPostgreSQLSemantic Search

2026-10 개정: 이 글은 지금의 Plit 에 맞게 다시 썼다. 임베딩(Gemini 768차원), 기억의 소유 단위(사용자×캐릭터), 감쇠 축(실제 시간에서 대화 순번으로), 키워드 색인(요약 평문 대신 추출 키워드), 중요도 예약 슬롯, 압축 폐지를 현재 코드 기준으로 고쳤다.

컨텍스트 윈도우의 한계

AI 캐릭터 채팅 서비스를 만들면 가장 먼저 컨텍스트 윈도우에 걸린다. LLM에 보낼 수 있는 토큰 수에 상한이 있으니, 대화가 길어지면 초반 내용부터 잘려 나간다. Plit은 프롬프트에 최근 6턴(사용자 메시지 기준)의 대화만 그대로 싣는다. 그 바깥에서 사용자가 캐릭터에게 한 고백이나 약속은 다른 경로로 데려오지 않으면 흔적도 없이 사라진다.

AI 캐릭터가 "어제 네가 해준 말 기억나?" 라는 질문에 "무슨 말이었는데?" 라고 답한다면, 몰입은 깨진다.

Plit은 이 문제를 선택적 기억 회상(Selective Memory Recall)으로 푼다. 대화를 주기적으로 요약해 저장해 두고, 지금 맥락과 가장 가까운 기억만 골라 프롬프트에 넣는 구조다. 다만 "모든 대화를 기억한다"는 구조는 아니다. 요약은 사용자 메시지 5건마다 시도하고 남길 것이 없으면 건너뛰며, 한 턴에 들어가는 기억은 최대 12개다. 아래에 그 구현을 코드와 함께 정리했다.

AI 캐릭터 장기 기억 시스템 아키텍처: 사용자×캐릭터 누적 메시지 5건마다 기억을 추출해 Gemini 768차원 임베딩과 searchTerms 색인으로 저장하고, 벡터·키워드 검색을 시퀀스 감쇠로 재랭킹한 뒤 RRF 로 병합해 중요도 예약 슬롯 2칸과 함께 최대 12개를 프롬프트에 주입하는 흐름


1. 주기적 대화 요약 생성

사용자×캐릭터 누적 메시지 5건이 트리거다

트리거는 단순하다. 마지막 기억이 덮은 지점 이후로 사용자 메시지가 5건 이상 쌓이면 생성에 들어간다. 중요한 것은 무엇을 기준으로 세는가다.

// backend/src/ai-pipeline/services/memory.service.ts
async shouldGenerateMemory(
  sessionId: string,
  characterIds: string[],
  userId?: string,
): Promise<boolean> {
  const { sessionIds, since } = await this.resolveGenerationWindow(
    sessionId,
    characterIds,
    userId,
  );
  const userMsgCount = await this.userMemory.countUserMessagesSince(
    sessionIds,
    since,
  );
  return userMsgCount >= MEMORY_GENERATION_THRESHOLD; // 5
}

resolveGenerationWindow() 는 같은 캐릭터 챗(character_chat_id)에 속한 세션을 전부 모으고, "마지막 기억이 덮은 끝"과 "남길 게 없어 건너뛴 창의 끝" 중 나중 시점부터 센다. 초기에는 세션마다 따로 셌는데, 그러면 짧게 들어왔다 나가는 사용자는 몇 번을 와도 문턱에 닿지 못한다. 실제로 prod 에서 사용자가 말을 한 세션 64개 중 30개가 기억 0건으로 끝났고, 그래서 세는 단위를 세션에서 사용자×캐릭터로 옮겼다. 시스템 메시지는 세지 않는다. 기준은 사용자가 직접 보낸 메시지다.

건너뛴 창을 따로 적는 이유도 같은 종류의 함정이다. 추출이 "남길 것 없음"으로 끝났는데 그 사실을 어디에도 적지 않으면, 창이 문턱을 넘은 채로 남아 다음 턴에도, 그 다음 턴에도 다시 요약된다.

Redis 분산 락: 토큰으로 소유를 확인한다

생성은 응답 전송이 끝난 뒤로 미뤄서 돌리므로 사용자 응답 지연에는 손대지 않는다. 대신 여러 턴이 겹치면 같은 창을 동시에 요약하러 들어올 수 있다. Redis SET NX 로 락을 잡되, 값에 무작위 토큰을 넣는다.

// backend/src/ai-pipeline/services/memory.service.ts (발췌)
const LOCK_TTL_SECONDS = 60;
const LOCK_HEARTBEAT_MS = 20_000;

const LOCK_RELEASE_SCRIPT =
  'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end';

const lockKey = generationLockKey(sessionId); // `memory:gen:${sessionId}`
const lockToken = randomUUID();
const acquired = await this.redis.set(lockKey, lockToken, {
  ex: LOCK_TTL_SECONDS,
  nx: true,
});
if (!acquired) return { created: false };

const heartbeat = setInterval(() => {
  void this.renewLock(lockKey, lockToken);
}, LOCK_HEARTBEAT_MS);

try {
  // ... 추출, 저장, 임베딩, 색인
} finally {
  clearInterval(heartbeat);
  await this.releaseLock(lockKey, lockToken); // LOCK_RELEASE_SCRIPT 실행
}

처음에는 값 '1' 에 30초 TTL 을 걸고 finally 에서 DEL 했다. 이 방식에는 구멍이 하나 있다. 작업이 TTL 보다 오래 걸리면 락이 먼저 만료되고, 그 사이 다른 요청이 락을 잡는다. 늦게 끝난 첫 작업이 DEL 을 부르면 남이 잡은 락을 풀어 버린다. 그래서 지금은 해제와 연장을 모두 Lua 스크립트로 하고, 값이 내 토큰일 때만 지우거나 연장한다. TTL 을 크게 잡는 대신 20초마다 연장(heartbeat)하고, 임계구간 전체의 상한은 GENERATION_TIMEOUT_MS(45초) 데드라인이 따로 건다.

추출은 무엇을 남기는가

요약은 LlmTask.MEMORY_EXTRACTION 용도로 호출한다. 어느 모델이 이 용도를 맡을지는 코드가 아니라 llm_task_assignments 테이블(어드민 화면)이 정하므로, 모델을 바꿔도 배포가 필요 없다. 프롬프트 본문은 Langfuse 의 pipeline/memory-summarize 가 서빙하고, 저장소에는 같은 이름의 폴백이 들어 있다. 출력은 줄글 하나가 아니라 구조화된 JSON 이다.

추출 항목설명
skip남길 것이 없으면 {"skip": true} 만 낸다. 그래서 5건이 쌓여도 기억이 생긴다는 보장은 없다
importance이 대화가 관계에 남긴 것을 1~10 으로 매긴다. 랭킹과 예약 슬롯에만 쓰고 프롬프트에는 노출하지 않는다
summary3~5문장 한국어 요약. 사건, 감정 변화, 관계 역학 포함
keyDetails인상적인 대사(quote)와 행동(action) 최대 10개를 원문 그대로 보존
searchTerms나중에 이 기억을 다시 찾을 키워드 5~10개. 키워드 검색 색인의 유일한 입력이다
userFacts사용자가 스스로 밝힌 정보. 근거가 된 사용자 발화 원문(evidence)을 함께 낸다
relationshipState호칭, 말투, 관계의 현재 상태, 지키지 않은 약속, 넘지 말아야 할 선
activeTopics아직 답을 듣지 못한 열린 화제 최대 5개

keyDetails에는 양보 없는 규칙을 하나 걸어 뒀다. 사용자("나")가 각 캐릭터에게 한 대사와 행동을 캐릭터마다 최소 1개씩 반드시 넣는다. "내가 그 캐릭터에게 뭐라고 했지?" 같은 질문에 정확히 답하려면 이게 있어야 한다.


2. Gemini 임베딩과 pgvector 저장

벡터 임베딩 생성

요약 텍스트는 Gemini gemini-embedding-2-preview 모델을 거쳐 768차원 벡터가 된다. 초기에는 Voyage AI 의 512차원 모델을 썼다가 지금의 Gemini 768차원으로 옮겼다.

// backend/src/common/embedding/embedding.service.ts (발췌)
private readonly model = EMBEDDING_MODEL; // 'gemini-embedding-2-preview'
private readonly dimensions = 768;

const response = await this.gemini!.models.embedContent({
  model: this.model,
  contents: inputs,
  config: {
    taskType, // 저장할 때 'RETRIEVAL_DOCUMENT', 검색할 때 'RETRIEVAL_QUERY'
    outputDimensionality: this.dimensions,
  },
});

return inputs.map((_, i) => {
  const values = embeddings[i]?.values;
  return values ? this.normalize(values) : null; // L2 정규화
});

저장용과 검색용 임베딩은 taskType 을 다르게 준다. 같은 문장이라도 "찾아질 문서"와 "찾는 질의"는 다른 공간에 놓이도록 모델이 학습되어 있기 때문이다. 768차원으로 차원을 줄여 받으므로 결과는 직접 L2 정규화한다. 레이트 리밋에 걸리면 2초, 4초, 8초 간격으로 최대 3번 다시 시도하고, 그래도 실패하면 null 을 돌려준다.

pgvector 컬럼과 tsvector 컬럼

기억은 memory 스키마의 user_memories 테이블에 산다. 검색용 컬럼이 두 개 있고, 어느 캐릭터의 기억인지는 별도 조인 테이블이 정한다.

// backend/prisma/schema.prisma (발췌)
model UserMemory {
  id                String      @id @default(uuid())
  userId            String      @map("user_id")
  summary           String      @db.Text            // 암호화되어 저장된다
  keyDetails        Json?       @map("key_details") @db.JsonB
  sourceSessionIds  String[]    @map("source_session_ids")
  importance        Int         @default(5)
  embedding         Unsupported("vector(768)")?     // pgvector 시맨틱 검색
  searchVector      Unsupported("tsvector")?    @map("search_vector") // 키워드 검색
  characters        UserMemoryCharacter[]
  // ...
}

model UserMemoryCharacter {
  memoryId    String @map("memory_id")
  characterId String @map("character_id")
  seq         Int    // (userId, characterId) 안에서 1부터 증가하는 기억 순번
  // ...
}

Prisma의 Unsupported는 ORM이 직접 다루지 못하는 PostgreSQL 네이티브 타입을 선언만 해 두는 자리다. 읽고 쓰는 일은 raw SQL이 맡는다. embedding 에는 HNSW 인덱스, search_vector 에는 GIN 인덱스가 걸려 있다.

캐릭터를 기억 행의 단일 FK 로 두지 않은 이유는 한 요약이 여러 캐릭터에 걸릴 수 있어서다. 순번 seq 가 기억 행이 아니라 조인 행에 붙는 것도 같은 이유다. 기억 한 건이 두 캐릭터에 걸리면 각 캐릭터와의 대화 순번이 서로 다르다. 이 순번은 뒤에서 감쇠의 축으로 쓰인다.

tsvector 는 요약이 아니라 searchTerms 로 만든다

요약과 keyDetails 는 AES-256-GCM 으로 암호화되어 저장된다. 그런데 to_tsvector('simple', summary) 로 색인을 만들면 평문 단어가 search_vector 컬럼에 그대로 남는다. 암호화한 의미가 없어진다. 그래서 색인은 추출이 따로 뽑은 searchTerms 로만 만든다.

// backend/src/ai-pipeline/services/memory.service.ts (발췌)
const embedding = await this.withDeadline(
  this.embedding.embed(parsed.summary),
  deadline.signal,
  sessionId,
).catch(() => null);
if (embedding) {
  await this.userMemory.updateEmbedding(memoryId, `[${embedding.join(',')}]`);
}

// indexSearchTerms(): searchTerms 가 비면 색인을 비워 두고 경고만 남긴다
await this.userMemory.updateSearchVector(memoryId, terms.join(' '));
-- backend/src/ai-pipeline/repositories/user-memory.repository.ts: updateSearchVector()
UPDATE memory."user_memories"
SET "search_vector" = to_tsvector('simple', $1)
WHERE "id" = $2

추출 프롬프트는 키워드를 뽑을 때 민감한 내용을 상위 개념어로 바꾸라고 지시한다(구체적 병명은 "건강", 신체 접촉 묘사는 "친밀"). searchTerms 가 비어 있으면 그 행은 키워드 검색에서만 빠진다. 평문으로 되돌려 색인하는 것보다 그쪽이 낫다고 판단했다.

Graceful Fallback

기억은 응답의 보강 정보라, 어느 단계가 실패해도 턴을 죽이지 않는다.

// backend/src/ai-pipeline/services/memory-retrieval.service.ts: searchMemories() 발췌
const queryEmbedding = this.embedding.enabled
  ? await this.embedding.embed(query, 'RETRIEVAL_QUERY')
  : null;

if (!queryEmbedding) {
  const rows = await this.userMemory.findRecentMemories(
    userId,
    characterIds,
    sourceModes,
    limit,
    sessionFilter,
  );
  return this.decrypt(rows);
}
  • GEMINI_API_KEY 가 없거나 질의 임베딩이 실패하면 최신순으로 물러선다.
  • 키워드 검색이 0행이면 RRF 를 건너뛰고 벡터 결과만 쓴다.
  • 검색 전체가 예외를 던지면 기억 없이 턴을 이어 가고, 예외는 GlitchTip 으로 보낸다.
  • 복호화에 실패한 기억은 버린다. "(복호화 실패)" 같은 폴백 문구를 넣으면 프롬프트가 그것을 기억 내용으로 읽는다.

최신순은 시맨틱 검색보다 정확도가 떨어진다. 그래도 아무 기억도 못 꺼내는 것보다는 낫다.


3. 하이브리드 검색, 벡터와 키워드

검색은 두 경로를 동시에 돌린다. 범위는 사용자와 캐릭터로 좁히고, 세션으로는 좁히지 않는다.

경로 A: pgvector 코사인 유사도

// backend/src/ai-pipeline/repositories/user-memory.repository.ts: findVectorResults()
return this.prisma.$queryRaw<UserMemoryRow[]>(Prisma.sql`
  SELECT ${this.selectColumns()},
         1 - (um."embedding" <=> ${vectorStr}::vector) AS "score"
  FROM memory."user_memories" um
  ${this.seqLateral(characterIds)}
  WHERE ${this.scopeClause(userId, sourceModes, sessionIdFilter)}
    AND um."embedding" IS NOT NULL
  ORDER BY um."embedding" <=> ${vectorStr}::vector ASC
  LIMIT ${limit}`);

<=>는 pgvector의 코사인 거리 연산자이고, 1 - 거리가 유사도다. 정렬은 순수 거리 연산자로만 한다. HNSW 인덱스는 거리 연산자 정렬에만 붙기 때문에, 감쇠식을 곱한 값으로 ORDER BY 하면 인덱스를 타지 못하고 전체를 훑는다. 그래서 SQL 은 후보를 20건(MEMORY_SEARCH_OVERFETCH) 넉넉히 가져오는 일만 하고, 감쇠는 애플리케이션에서 곱해 순서를 다시 매긴다.

감쇠의 축: 실제 시간이 아니라 대화 순번

초기에는 실제 경과 시간으로 감쇠를 걸었다. 반감기 1일, EXP(-0.693 * 경과초 / 86400) 을 유사도에 곱하는 식이었다. 이 식은 두 가지 이유로 버렸다.

첫째, 재는 대상이 틀렸다. 달력 시간으로 감쇠하면 이 식이 실제로 재는 것은 대화가 얼마나 진행됐는지가 아니라 사용자가 얼마나 오래 안 왔는지다. 오랜만에 돌아온 사용자일수록 캐릭터가 그를 더 많이 잊는다. 리텐션 관점에서는 정확히 반대로 작동한다. 둘째, 반감기가 짧으면 며칠 만에 계수가 0 에 붙어, 유사도가 서로 다른 기억이 모두 같은 점수가 된다. 순위가 유사도와 무관해진다.

지금은 그 캐릭터와의 기억 순번(seq) 거리로 감쇠하고, 바닥값을 둔다.

// backend/src/ai-pipeline/services/memory-retrieval.service.ts
static sequenceDecay(
  memory: Pick<MemoryResult, 'seq' | 'pinnedAt'>,
  maxSeq: number,
): number {
  if (memory.pinnedAt) return 1;
  const distance = Math.max(0, maxSeq - memory.seq);
  return Math.max(SEQ_DECAY_FLOOR, 2 ** (-distance / SEQ_DECAY_HALF_LIFE)); // 0.3, 40
}

static rerankBySequence(rows: MemoryResult[], maxSeq: number): MemoryResult[] {
  return [...rows]
    .map((row) => ({
      row,
      score: row.score * MemoryRetrievalService.sequenceDecay(row, maxSeq),
    }))
    .sort((a, b) => b.score - a.score)
    .map((e) => e.row);
}

바닥값이 이 식의 본체다. 곱셈 감쇠는 축이 무엇이든 충분히 멀어지면 0 으로 수렴하고, 그러면 실제 시간 축에서 났던 결함이 순번 축에서도 그대로 재현된다. 축만 바꿔서는 해결되지 않는다.

솔직히 적어 두면, 이 상수들은 아직 관측된 적이 없다. 2026-08 prod 실측에서 사용자×캐릭터 쌍당 기억이 최대 10건이라, 실제로 곱해지는 계수는 0.856~1.0 이었다. 유사도를 조금 깎을 뿐 순서를 바꾸지 못한다. 쌍당 기억이 상한(12)을 넘는 쌍이 생겨야 이 값의 효과를 잴 수 있다.

경로 B: tsvector 키워드 검색

// backend/src/ai-pipeline/repositories/user-memory.repository.ts: findTextSearchResults()
return this.prisma.$queryRaw<UserMemoryRow[]>(Prisma.sql`
  SELECT ${this.selectColumns()},
         ts_rank(um."search_vector", to_tsquery('simple', ${tsquery})) AS "score"
  FROM memory."user_memories" um
  ${this.seqLateral(characterIds)}
  WHERE ${this.scopeClause(userId, sourceModes, sessionIdFilter)}
    AND um."search_vector" @@ to_tsquery('simple', ${tsquery})
  ORDER BY ts_rank(um."search_vector", to_tsquery('simple', ${tsquery})) DESC, um."created_at" DESC
  LIMIT ${limit}`);

키워드 결과도 같은 순번 감쇠로 재랭킹한 뒤 병합에 들어간다.

simple 토크나이저는 한국어 조사를 떼어내지 못한다. "한시우가"와 "한시우를"이 서로 다른 토큰이 되어 버린다. 질의 쪽을 접두사 매칭으로 만들어 이 구멍을 메웠다.

// backend/src/common/search/prefix-tsquery.ts
// "한시우 위스키" → "한시우:* | 위스키:*"
export const MAX_TSQUERY_TERMS = 20;

export function buildPrefixTsquery(query: string): string {
  const tokens = query
    .trim()
    .replace(/[()&|!<>:*\\."']/g, ' ')
    .split(/\s+/)
    .filter((w) => w.length > 0);

  const seen = new Set<string>();
  const deduped: string[] = [];
  for (const tok of tokens) {
    if (seen.has(tok)) continue;
    seen.add(tok);
    deduped.push(tok);
  }

  return deduped
    .slice(0, MAX_TSQUERY_TERMS)
    .map((w) => ([...w].length >= 2 ? `${w}:*` : w))
    .join(' | ');
}

한시우:*는 "한시우"로 시작하는 토큰을 전부 잡는다. 완벽하진 않아도, 형태소 분석기 없이 PostgreSQL 내장 기능만으로 갈 수 있는 현실적인 선이다. 이 함수는 초기 버전에서 세 군데가 바뀌었다.

  • AND 에서 OR 로. 질의는 사용자가 방금 친 문장 전체라, 단어를 전부 AND 로 묶으면 거의 아무것도 매칭되지 않았다. 후보를 OR 로 넓히고, 관련도는 ts_rank 와 RRF 가 책임진다.
  • 한 글자 토큰은 정확 매칭. 거:* 는 "거부", "거리", "거짓"에 다 걸린다.
  • 중복 제거 후 20개 상한. 후보를 20건 가져오는 검색에서 그 이상의 텀은 동점 구간만 넓힐 뿐 변별력을 더하지 않았다.

벡터 검색은 의미를 잡고, 키워드 검색은 고유명사를 잡는다. 캐릭터 이름이나 특정 장소명은 임베딩보다 정확한 토큰 매칭이 더 잘 찾는다.

짧은 발화는 질의를 넓힌다

사용자 발화는 짧다. "그거 뭐였지?", "응" 같은 발화는 임베딩에도 전문검색에도 걸릴 내용어가 없다. 그래서 buildRetrievalQuery() 는 발화가 조응 표현("그거", "아까", "걔" 등)이나 맞장구이고 20자 미만일 때만 직전 사용자 발화 2건을 이어 붙여 검색한다(최대 120자). 길이가 아니라 조응 여부로 가르는 이유는, 짧아도 새 화제를 이름으로 꺼낸 발화에 직전 맥락을 끌고 오면 엉뚱한 기억이 올라오기 때문이다. 캐릭터의 앞 턴 발화는 넣지 않는다. 모델이 지어낸 말을 검색 질의로 되먹이면 날조가 스스로 강화된다.


4. RRF (Reciprocal Rank Fusion) 병합

두 검색 결과를 어떻게 하나로 합치느냐가 남은 문제다. 점수를 그냥 더하면 스케일이 다른 둘(코사인 유사도와 ts_rank)을 한자리에 놓고 비교하게 된다. RRF는 점수를 버리고 순위만 쓰기 때문에 이 함정을 비껴간다.

// backend/src/ai-pipeline/services/memory-retrieval.service.ts
static mergeByRRF(
  vectorResults: MemoryResult[],
  textResults: MemoryResult[],
  limit: number,
  k = 60,
): MemoryResult[] {
  const scoreMap = new Map<string, { score: number; item: MemoryResult }>();

  for (let i = 0; i < vectorResults.length; i++) {
    const r = vectorResults[i];
    scoreMap.set(r.id, { score: 1 / (k + i), item: r });
  }

  // 양쪽에 다 있으면 점수가 합산된다
  for (let i = 0; i < textResults.length; i++) {
    const r = textResults[i];
    const existing = scoreMap.get(r.id);
    if (existing) {
      existing.score += 1 / (k + i);
    } else {
      scoreMap.set(r.id, { score: 1 / (k + i), item: r });
    }
  }

  return Array.from(scoreMap.values())
    .sort((a, b) => b.score - a.score)
    .slice(0, limit)
    .map((e) => e.item);
}

RRF 공식은 score = Σ 1/(k + rank)다. k = 60은 RRF 를 제안한 논문 이래 관행처럼 쓰는 기본값이고, 상위 순위에 가중치가 몰리지 않게 완충해 준다. 이 구현의 i 는 0부터 세므로 1위가 1/60, 2위가 1/61 이다.

RRF의 진짜 강점은 중복 처리가 공짜라는 것이다. 벡터 검색 1위(i = 0)이면서 키워드 검색 3위(i = 2)인 기억은 1/60 + 1/62 ≈ 0.0328을 받고, 벡터 검색 1위에만 있는 기억은 1/60 ≈ 0.0167에 그친다. 양쪽이 모두 관련 높다고 본 기억이 저절로 위로 올라온다.

RRF 병합 예시: 시퀀스 감쇠로 재랭킹한 벡터 검색 순위와 ts_rank 키워드 검색 순위를 0부터 세는 순위 i 로 1/(60+i) 를 합산해, 양쪽 상위인 Memory A 가 0.0331 로 1위가 되는 계산

RRF 를 쓰지 않는 자리: 중요도 예약 슬롯

RRF 가 성립하는 것은 같은 후보 집합을 두 랭커가 매긴 경우뿐이다. 서로 다른 후보 집합을 RRF 로 합치면 점수 융합이 아니라 계층 경계처럼 작동한다. 상한 12, k = 60 에서 한쪽 목록의 12번째 점수 1/71 ≈ 0.0141 이 다른 쪽 1위에 가중치 0.7 을 곱한 0.7/60 ≈ 0.0117 보다 크다. 앞선 목록이 상한을 채우는 순간 뒤쪽 목록은 한 건도 들어오지 못한다.

그런 두 목록을 합칠 자리가 실제로 있다. 고백한 날의 기억은 이번 질문과 안 닮았다는 이유로 검색에서 떨어질 수 있는데, 그러면 관계가 어디까지 왔는지가 프롬프트에서 사라진다. 그래서 중요도 상위 기억은 검색을 거치지 않고 따로 뽑아, RRF 가 아니라 슬롯을 나눠 갖는 방식으로 합친다.

// backend/src/ai-pipeline/services/memory-retrieval.service.ts
static mergeImportanceSlots(
  searched: MemoryResult[],
  important: MemoryResult[],
  limit: number, // 12
  slots: number, // IMPORTANCE_RESERVED_SLOTS = 2
): MemoryResult[] {
  const found = new Set(searched.map((m) => m.id));
  const fresh = important.filter((m) => !found.has(m.id)).slice(0, slots);
  if (fresh.length === 0) return searched.slice(0, limit);
  return [...fresh, ...searched.slice(0, Math.max(0, limit - fresh.length))];
}

중요도 6 이상(IMPORTANCE_SLOT_FLOOR)인 기억 중 상위 2건이 자격을 얻고, 이미 검색에 걸린 것은 슬롯을 쓰지 않는다. 최종 상한은 12개(MEMORY_LIMIT)다.


5. 기억의 소유 단위: 사용자×캐릭터

Plit 의 대화는 지금 1:1 캐릭터 챗 하나다. 그래서 기억이 답해야 할 질문도 "어느 대화의 기억을 어느 대화로 넘길 것인가"가 아니라 "이 캐릭터가 이 사용자에 대해 무엇을 들고 있어야 하는가" 하나다.

초기에는 대화 모드마다 기억 범위가 달랐다. 에피소드 단위로 끊는 모드와 같은 방의 세션을 모두 훑는 모드가 따로 있었고, 상한도 8개와 12개로 갈렸다. 모드가 캐릭터 챗 하나로 정리되면서 기억의 소유 단위를 사용자×캐릭터로 고정했다.

// backend/src/ai-pipeline/services/memory-retrieval.service.ts: search() 발췌
const limit = requestedLimit ?? MEMORY_LIMIT; // 12
const maxSeq = await this.userMemory.findMaxSeq(userId, characterIds);

const [searched, important] = await Promise.all([
  this.searchMemories(userId, characterIds, sourceModes, query, limit, maxSeq, undefined),
  this.userMemory.findTopImportanceMemories(
    userId,
    characterIds,
    sourceModes,
    IMPORTANCE_SLOT_FLOOR,
    IMPORTANCE_RESERVED_SLOTS,
    undefined,
  ),
]);

세션 id 는 검색 범위를 정하지 않는다. 기억 행의 source_session_ids 는 어느 세션이 이 기억에 메시지를 보탰는지 기록할 뿐이고, 세션별 기억 목록을 보여 주거나 되감기로 무효화할 대상을 고를 때만 쓰인다. 그래서 "새 대화"로 세션을 새로 열어도 같은 캐릭터는 같은 기억을 들고 온다. 기억까지 지우는 것은 "대화 완전 초기화" 하나이고, 이때 호감도와 관계 상태도 함께 초기화되며 그 캐릭터에 걸린 기억만 지운다.


6. 프롬프트에 기억 주입하기

검색된 기억은 시스템 프롬프트의 [과거 기억] 블록으로 들어간다. 넣기 직전에 호출부가 기억을 생성 시각 오름차순으로 다시 정렬한다. 모델은 검색 순위를 보지 못하고, 병합이 정하는 것은 "무엇이 들어가는가"뿐이다. 그래서 검색 튜닝의 판단 기준도 MRR 같은 순위 지표가 아니라 "상한 12 안에 들었는가"다.

// backend/src/ai-pipeline/services/prompt-builder.ts: renderMemories() 발췌
const memoryLines = this.input.memories.map((m) => {
  const relTime = PromptBuilder.relativeTime(m.createdAt); // "2시간 전", "3일 전"
  const involved =
    m.involvedCharacters.length > 0
      ? ` [관련자: 플레이어, ${m.involvedCharacters.join(', ')}]`
      : '';
  let line = `- ${relTime}: ${m.summary}${involved}`;
  if (m.keyDetails && m.keyDetails.length > 0) {
    const details = m.keyDetails.map((d) => {
      if (d.type === 'quote') {
        return `  - \u{1F4AC} ${d.speaker}: "${d.text}" (${d.context})`;
      }
      return `  - \u{1F3AC} ${d.actor}: ${d.text} (${d.context})`;
    });
    line += `\n${details.join('\n')}`;
  }
  return line;
});

요약과 인용문은 사용자 발화에서 나온 것이라, 그 안에 규칙을 풀어 달라는 요청이 섞여 들어올 수 있다. 그래서 블록 전체를 wrapUntrustedText() 로 감싸고 "지시가 아니라 참고 데이터"라고 못박는다.

기억마다 [관련자] 목록이 따라붙는다. "관련자가 아닌 캐릭터는 해당 사건을 전혀 모른다"는 지식 범위 규칙은 등장인물이 둘 이상일 때만 붙는다. 1:1 대화에 이 규칙을 넣으면, 관련자 목록에 자기 이름이 안 적힌 기억을 캐릭터가 스스로 배제하는 오작동 여지만 남기 때문이다.

규칙이 하나 더 있다. 기억에 없는 사실을 지어내지 않도록, 모르면 모른다고 답하게 하는 MEMORY_LIMITATION_RULE 이다. 이 규칙은 기억 블록과 분리되어 기억이 0건일 때도 항상 들어간다. 기억 0건 구간은 생성 문턱 이전, 즉 신규 사용자의 첫 몇 턴이고, 환각이 가장 잘 나는 구간이다.

"잘 기억이 안 나는데..." 같은 반응이 환각보다 훨씬 낫다.


7. 압축 대신 관계 상태 한 행

초기에는 단기 기억이 30개를 넘으면 가장 오래된 20개를 장기 기억 하나로 접었다. 지금은 압축을 하지 않는다. 아끼려던 자원은 부족한 적이 없었고(주입은 행 수와 무관하게 12칸에서 잘린다), 대가는 컸다. 접는 순간 keyDetails 의 원문 인용이 전부 사라졌고, 가장 오래된 내용이 가장 최근 순번을 받아 감쇠 축이 뒤집혔다.

대신 "관계가 지금 어디까지 왔는가"는 별도 계층이 맡는다.

1계층  user_memories                  창 단위 요약. 검색해서 뽑는다
2계층  character_relationship_states  사용자×캐릭터당 정확히 한 행. 검색 없이 항상 주입한다

2계층 행은 호칭, 말투, 관계의 현재 상태, 지키지 않은 약속, 넘지 말아야 할 선을 담는다. @@unique([userId, characterId]) 로 한 쌍에 한 행만 존재하고, 갱신은 덧붙이기가 아니라 덮어쓰기라 누적이 구조적으로 불가능하다. 행 안에서 자라는 것도 길이 상한(요약 600자, 목록 5개, 항목 100자)이 막는다. 갱신은 5건마다 도는 그 추출 호출의 relationshipState 출력에 얹혀 있어 추가 LLM 호출이 없다. 추출 프롬프트에 현재 상태를 함께 보여 주고 통째로 다시 쓰게 한다. 지금 값을 안 보여 주면 갱신이 아니라 대체가 된다.


핵심 설계 판단 일곱

  1. 컨텍스트 윈도우는 제약이 아니라 설계 조건이다. 모든 대화를 넣을 수 없다는 전제에서 출발하면, "무엇을 기억할 것인가"라는 좋은 질문이 따라 나온다. 그리고 그 답은 "전부"가 아니다.

  2. 문턱은 사용자가 체감하는 단위로 센다. 세션마다 세면 짧게 오가는 사용자는 영영 기억이 생기지 않는다. 사용자×캐릭터로 누적해 세고, 건너뛴 창도 기록해야 매 턴 같은 창을 다시 요약하지 않는다.

  3. 분산 락은 소유를 확인하고 푼다. SET NX 에 무작위 토큰을 넣고, 해제와 연장은 "값이 내 토큰일 때만" Lua 로 한다. 그렇지 않으면 만료 뒤 남이 잡은 락을 풀어 버린다.

  4. 벡터 검색과 키워드 검색은 상호 보완 관계이고, RRF 는 그 둘을 합칠 때만 쓴다. 같은 후보를 두 랭커가 매긴 것이라 순위끼리 비교된다. 서로 다른 후보 집합은 RRF 가 아니라 슬롯을 나눠 갖게 한다.

  5. 암호화한 데이터는 색인으로 새지 않게 한다. 요약을 암호화해 두고 같은 평문으로 tsvector 를 만들면 암호화가 무의미해진다. 키워드 색인은 추출이 따로 뽑은 searchTerms 로만 만든다.

  6. 감쇠의 축은 달력이 아니라 대화량이고, 바닥값이 본체다. 실제 시간으로 감쇠하면 오래 안 온 사용자를 더 많이 잊는다. 감쇠는 HNSW 인덱스를 살리도록 SQL 밖에서 곱한다.

  7. AI에게 "모르면 모른다고 해라"는 규칙은 기억이 0건일 때도 들어가야 한다. 기억 시스템이 아무리 좋아도 검색에 없는 내용을 지어내면 다 헛일이고, 환각은 기억이 아직 없는 첫 몇 턴에 가장 잘 난다.

관련 프로젝트

플릿(Plit) - AI 캐릭터 채팅 플랫폼

여성향·남성향 캐릭터를 골라 1:1로 대화하는 AI 캐릭터 채팅 플랫폼. 용도별 모델 배정 AI 파이프라인과 pgvector 장기 기억, 젬 결제를 1인 풀스택으로 구축·운영.

같은 프로젝트의 다른 글