Skip to content

✨ ASK 세션 도입 및 중복 질문 처리 구현 #23

Merged
vcz-Chan merged 21 commits intodevfrom
feat/chat-sessions
Nov 16, 2025
Merged

✨ ASK 세션 도입 및 중복 질문 처리 구현 #23
vcz-Chan merged 21 commits intodevfrom
feat/chat-sessions

Conversation

@vcz-Chan
Copy link
Member

요약

  • /ai/(v1|v2)/ask에 세션 개념을 도입해 질문/답변 히스토리를 DB에 영속화하고, LLM 호출 시 최근 대화 맥락을 재사용할 수 있게 합니다.
  • 세션/메시지/질문 캐시를 공통 스키마로 통합하고, /ai/v2/sessions 계열 REST API와 무한 스크롤 메시지 API로 대화 목록/이력을 조회할 수 있게 합니다.
  • 질문 임베딩 기반 중복 질문 캐시와 말투 ID(speech_tone_id)를 도입해, 동일한 맥락·톤의 반복 질문은 캐시를 재사용하고, 말투가 다를 경우 tone 전용 LLM으로 재작성하여 응답 정합성을 유지합니다.

Context

  • 기존 /ai/ask, /ai/v2/ask는 stateless 구조라 세션/대화 이력이 없고, 클라이언트가 질문마다 새 요청을 보내는 단발형 UX만 가능했습니다.
  • user_id를 본문에서 받아 세션/소유자/요청자 개념이 섞여 있었고, JWT 기반 권한/접근제어와 캐시 무효화(블로그 소유자 기준)를 동시에 처리하기 어려운 구조였습니다.
  • 중복 질문에 대한 임베딩 캐시는 존재하지만, 단일 질문만 기준으로 삼아 실제 프롬프트에 주입되는 “최근 2턴 히스토리”와 비교 기준이 달라 follow-up 질문 재사용 정확도가 떨어집니다.
  • 말투 옵션(speech_tone)을 지원하지만, 캐시 재사용 시 tone 정보를 고려하지 않아 캐시 응답의 말투가 요청 말투와 어긋날 수 있습니다.
  • 이 PR은
    • 세션/메시지/질문 캐시를 명시적인 DB 스키마로 분리하고,
    • JWT user_id(requester)와 owner_user_id(챗봇/블로그 주인)를 엄격히 구분하며,
    • 세션 전용 REST/SSE 계약과 중복 질문/말투 일관성 캐시 플로우를 정의/구현하는 것이 목적입니다.

Changes

  • DB 스키마 및 마이그레이션

    • ask_session 테이블 추가
      • 컬럼: id, requester_user_id, owner_user_id, title, metadata, last_question_at, created_at, updated_at.
      • 인덱스: (requester_user_id, created_at DESC), (owner_user_id, created_at DESC), last_question_at DESC 등 세션 리스트/정렬 최적화.
    • ask_message 테이블 추가
      • 컬럼: id, session_id(FK), role(user|assistant), content, search_plan, retrieval_meta, created_at.
      • 인덱스: (session_id, created_at DESC, id DESC)로 세션별 페이지네이션 지원.
    • 기존 ask_message_embeddingask_question_cache로 리네임
      • 중복 질문/캐시 전용임을 명시하고, speech_tone_id integer NOT NULL DEFAULT -1 컬럼 추가.
      • 컬럼: message_id(PK/FK), owner_user_id, requester_user_id, category_id, post_id, answer_message_id, embedding(vector(1536)), speech_tone_id, created_at, updated_at.
      • 인덱스: owner_user_id, (owner_user_id, category_id), (owner_user_id, post_id), requester_user_id, IVFFlat(embedding vector_cosine_ops) 등 + 필요 시 (owner_user_id, requester_user_id, speech_tone_id).
      • 기존 캐시 데이터는 tone 정보 없이 신뢰도가 낮아 TRUNCATE/DELETE로 초기화.
    • pgvector 확장 설치를 전제하고, 마이그레이션/롤백 절차를 docs/migrations에 문서화.
  • 세션/메시지/질문 캐시 레포지토리 및 서비스

    • ask-session.repository.ts
      • 세션 생성/조회/삭제, requester 기반 세션 리스트, owner 필터, 커서 기반 페이징, 소유권 검증 로직 추가.
    • ask-message.repository.ts
      • 세션별 메시지 삽입/조회, created_at + id 커서 기반 페이지네이션 쿼리 구현.
    • ask-question-cache.repository.ts (구 ask-message-embedding.repository.ts)
      • 인터페이스에 speechToneId 추가, upsertEmbedding가 tone ID를 함께 저장.
      • findSimilarEmbeddings에서 유사도 + speechToneId를 반환해 tone-aware 캐시 선택이 가능하도록 변경.
    • session-history.service.ts
      • persistConversationspeechTone?: number 인자를 추가하고, 세션/메시지/질문 캐시를 한 번에 영속화.
      • findCachedAnswerspeechToneId를 포함한 후보 배열을 반환하도록 확장.
  • ASK 엔드포인트 세션/권한 처리

    • /ai/v1/ask, /ai/v2/ask 공통 Request Body 정리
      • question, user_id(= owner_user_id), session_id, category_id, post_id, speech_tone, llm 구조 정리.
    • 컨트롤러에서
      • JWT의 user_idrequester_user_id로 사용하고, Body의 user_idowner_user_id로 매핑.
      • session_id가 없거나 null인 경우에만 새 세션 생성(이때 owner_user_id 필수).
      • session_id가 있는 경우 DB의 owner_user_id와 Body의 user_id가 불일치하면 400/409로 즉시 거부.
    • 세션 생성/사용 흐름
      • 새 세션 생성 시 ask_session 레코드 생성 + 첫 질문 텍스트 기반 기본 title 설정.
      • SSE로 event: session을 보내 { session_id, owner_user_id, requester_user_id } 전달, 응답 헤더 session-id도 세팅.
      • 스트림 완료 시 event: session_saved(성공) 또는 event: session_error(실패)를 보내 히스토리 영속화 상태를 클라이언트가 알 수 있게 함.
  • 히스토리 로딩 및 프롬프트 구성

    • answerStream/answerStreamV2에서 세션 최근 2턴(user ↔ assistant)을 로드해 LLM 메시지 배열에 prepend.
    • RAG 컨텍스트 뒤에 대화 히스토리를 붙이는 구조로 통일하고, 토큰 한도가 필요해지면 tokenizer 기반 절단 로직 추가 가능하도록 설계.
    • buildSearchPlanPrompt 등에 “이전 대화” 섹션을 옵션으로 추가해 follow-up 판단에 활용 가능하도록 확장.
  • 무한 스크롤 메시지 API

    • GET /ai/v2/sessions/:sessionId/messages
      • 쿼리 파라미터: cursor(선택, base64 'created_at|id'), direction(backward|forward, 기본 backward), limit(기본 20, 최대 50).
      • 응답: session_id, messages[{ id, role, content, search_plan, retrieval_meta, created_at }], paging{ direction, has_more, next_cursor }.
      • Postgres 커서 쿼리: created_at/id 조합으로 앞/뒤 페이지 모두 지원, API 레이어에서 시간순 정렬을 정규화.
    • 서버 로직: authMiddleware에서 requester와 세션 소유권 비교, 세션에 속하지 않으면 404 처리.
  • 세션 REST API

    • GET /ai/v2/sessions
      • requester 기준 세션 목록, owner_user_id 필터, 커서 기반 페이징(cursor, limit).
    • GET /ai/v2/sessions/:id
      • 단일 세션 메타 조회. requester와 소유 관계가 없으면 404.
    • GET /ai/v2/sessions/:id/messages
      • 위 무한 스크롤 스펙 사용.
    • PATCH /ai/v2/sessions/:id
      • Body: title, metadata(JSON object) 수정. owner_user_id는 변경 불가.
    • DELETE /ai/v2/sessions/:id
      • 세션 삭제 시 ask_message/ask_question_cache는 ON DELETE CASCADE로 함께 제거.
  • 중복 질문 판별 개선

    • 캐시 기준을 “현재 질문 단독”에서 “최근 2턴 질문 + 현재 질문을 합친 텍스트 블록”으로 변경.
      • 예: [Q-2]\n[Q-1]\n[Q-now]를 하나의 문자열로 임베딩.
      • 이전 턴이 부족하면 있는 만큼만 사용, 너무 길면 앞 턴부터 줄이는 헬퍼 로직 추가.
    • 프롬프트에 주입하는 히스토리와 캐시 비교 기준을 일치시켜 follow-up 재질문 시 캐시 적중 정확도 개선.
    • 임베딩 비용은 질문당 1회 추가 수준이라 영향이 미미함.
  • 캐시 응답 말투 정합성

    • ask_question_cachespeech_tone_id를 영속화해 캐시 후보마다 tone 정보를 보존.
    • 캐시 조회 시
      • 캐시 후보 중 speechToneId === 요청 speech_tone인 항목이 있으면 이를 그대로 재생.
      • 동일 tone이 없으면 유사도 1순위 후보를 선택해 tone 전용 LLM(replace-tone.service.ts)으로 재작성 후 응답.
      • tone 정보가 없는 기존 레코드(-1)는 tone 일치 후보에서 제외하고, 필요 시 재작성 대상만으로 사용.
    • replace-tone.service.ts
      • 시그니처: rewriteTone(answer, { speechToneId, speechTonePrompt, llm? }).
      • System: “의미/사실/구조는 유지하고 tone 지시만 반영하라” 형태의 편집자 프롬프트.
      • 응답이 비정상적이면 실패로 간주하고 RAG/LLM 경로로 폴백.
    • SSE
      • 캐시 재생 시 기존 search_plan/context 이벤트를 그대로 재생하고, answer 이벤트 payload만 tone 반영 후 텍스트 사용.
      • session_saved 이벤트에 cached, tone_rewritten 등의 플래그를 추가해 프론트에서 구분 가능하도록 확장 가능.
  • 임베딩/캐시 파이프라인

    • 새 질문 수신 시 질문+히스토리 블록을 임베딩 생성 → 같은 벡터를
      • 중복 질문 KNN 검색,
      • 최종 캐시 저장(ask_question_cache)에 재사용.
    • KNN 조건
      • owner_user_id, requester_user_id, post_id/category_id가 현재 요청과 일치하는 레코드만 후보로 제한.
      • similarity 0.92~0.95 이상일 때 동일 질문으로 간주, 저장된 search_plan/retrieval_meta/answer를 그대로 재생.
    • 스트림 종료 시점에만 단일 트랜잭션으로 ask_message(user) → ask_message(assistant) → ask_question_cache 순서로 INSERT/UPDATE.
    • 스트림 중단/에러 시에는 트랜잭션을 열지 않고 불완전 대화는 저장하지 않음.
  • 블로그 임베딩 재계산 시 캐시 무효화

    • 임베딩 워커(queue-consumer.ts)에서 특정 owner_user_id의 포스트를 재임베딩하면, 같은 owner의 ask_question_cache 레코드를 일괄 삭제.
    • 삭제 실패는 로그/메트릭으로만 남기고 본 작업은 계속 진행.

Breaking Changes

  • DB 스키마 변경
    • ask_session/ask_message/ask_question_cache 테이블 및 인덱스 추가, pgvector 확장 필수.
    • 기존 ask_message_embedding을 리네임/초기화하므로, 이전 캐시는 그대로 사용할 수 없습니다.
    • 배포 전/후 마이그레이션 순서와 롤백 절차를 반드시 확인해야 합니다.
  • /ai/(v1|v2)/ask 계약 변경
    • Body의 user_id는 이제 “챗봇/블로그 주인(owner_user_id)”로만 사용되며, 실제 요청자 식별은 JWT user_id 기준으로 동작합니다.
    • session_id + user_id 조합이 기존과 다르게 엄격히 검증되며, owner 불일치 시 400/409가 발생합니다.
  • SSE 이벤트 변경
    • 신규 이벤트: session, session_saved, session_error.
    • 클라이언트에서 세션 ID/상태를 추적하는 로직과 SSE 파서 업데이트가 필요합니다.
  • 캐시 동작 변경
    • 중복 질문 판단 기준이 “질문 단독”에서 “최근 2턴 히스토리 + 현재 질문 텍스트 블록”으로 변경됩니다.
    • 캐시 응답은 speech_tone_id를 기준으로 tone까지 포함해 정합성을 보장하므로, 과거 캐시와 응답 패턴이 일부 달라질 수 있습니다.

@vcz-Chan vcz-Chan self-assigned this Nov 16, 2025
@vcz-Chan vcz-Chan merged commit dacca01 into dev Nov 16, 2025
vcz-Chan added a commit that referenced this pull request Nov 16, 2025
* 📝 주석 추가 (#22)

* ✨ ASK 세션 도입 및 중복 질문 처리 구현  (#23)

* 📝 구현 계획 업데이트

* 📝 디비 마이그레이션 추가

* 🔧 db 헬퍼 추가

* ✨ 세션 관련 레포지토리 구현

* ✨ 세션 api 구현

* ✨ ask api 수정

* ✨ 맥락 전달 구현(2턴)

* ✨ 유사 질문 히트시 기존 답변 재사용

* ✨ 임베딩시 해당 유저의 모든 재사용용 관계 테이블 삭제

* 🐛 open ai 입력에 맞게 수정

* 🐛 db에 청크 단위로 파싱해 저장

* 📝 api 명세 문서

* 📝 개선 계획

* 🔨  마이그레이션 파일 생성

* ✨ 중복 질문 레포지토리로 수정

* 🚚 경로 수정

* ✨ 세션 히스토리 서비스 수정

* ✨ 중복 질문에 이전 맥락 추가

* ✨말투 대체 서비스 구현

* ⚡️디버그 유틸 고도화

* 📝 문서화
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant