좋습니다. 아래는 이전에 정리한 추천 시스템 상세 로직 문서를 전부 “~했습니다”체로 통일해서 다시 정리한 버전입니다. 노션 문서에 바로 붙여 넣을 수 있도록 제목, 구조, 문체를 모두 맞췄습니다.
통신 추천 시스템 상세 로직 문서
1. 문서 목적
본 문서는 Holliverse의 LLM 기반 RAG 추천 시스템이 어떤 데이터와 규칙을 바탕으로 동작했는지, 그리고 실제 서비스 환경에서 어떻게 안정성과 추천 품질을 확보했는지를 발표, 문서화, 인수인계 관점에서 한눈에 이해할 수 있도록 정리했습니다.
이 추천 시스템은 단순히 인기 상품을 보여주는 것이 아니라, 고객의 현재 상태를 반영한 개인화 추천을 제공하는 것을 목표로 했습니다. 이를 위해 고객 컨텍스트, 상품 벡터 검색, 세그먼트 전략, 하드 필터, LLM 기반 추천 이유 생성, 캐시 및 fallback 전략까지 하나의 흐름으로 설계했습니다.
2. 추천 시스템 한 줄 정의
고객 컨텍스트를 바탕으로 후보 상품을 검색(Retrieval)하고, 세그먼트·페르소나를 반영한 LLM이 추천 이유를 생성(Generation)하는 RAG 기반 개인화 추천 시스템으로 설계했습니다.
3. 시스템 설계 목표
본 추천 시스템은 다음 네 가지 목표를 중심으로 설계했습니다.
3.1 고객 컨텍스트 기반 개인화
고객의 성향, 현재 상품, 최근 관심 태그, 상담 맥락, 이탈 위험도 등을 함께 반영하여 추천을 개인화했습니다.
3.2 모바일 편향 완화
벡터 검색을 사용할 경우 휴대폰 요금제에 후보가 과도하게 몰릴 수 있었기 때문에, 쿼리 단계, 검색 단계, 후처리 단계에서 다단계 제어를 적용하여 다양한 상품 유형이 추천 후보에 포함되도록 했습니다.
3.3 안전한 룰 기반 필터링
연령 태그나 특정 세그먼트의 가격 정책처럼 서비스 운영상 반드시 지켜야 하는 조건은 하드 룰로 적용하여 잘못된 추천을 원천 차단했습니다.
3.4 운영 가능성과 비용 최적화
LLM 호출이 항상 발생하지 않도록 최근 7일 추천 결과를 재사용했고, 컨텍스트 누락, 임베딩 실패, LLM 실패 상황에서도 fallback이 가능하도록 설계했습니다.
4. 전체 흐름 요약
추천 시스템의 전체 흐름은 아래와 같이 구성했습니다.
memberId로 추천 요청을 받았습니다.- 최근 7일 이내 생성된 추천 결과가 있는지 먼저 확인했습니다.
- 캐시가 있으면 기존 추천 결과를 그대로 반환했습니다.
- 캐시가 없으면
member_llm_context를 로딩했습니다. - 고객 컨텍스트를 바탕으로 Retrieval 쿼리를 생성했습니다.
- 쿼리를 임베딩하고
pgvector로 상품 후보를 검색했습니다. - 연령 필터, 타입 분산, 세그먼트별 룰을 적용해 후보를 재정렬했습니다.
- 세그먼트/페르소나 프롬프트를 적용해 LLM이 추천 이유를 생성하도록 했습니다.
- 최종 추천 결과를 저장하고 응답했습니다.
- 실패 시에는 fallback 로직으로 기본 추천을 반환했습니다.
5. 핵심 데이터 구조
5.1 member_llm_context 테이블
이 테이블은 고객 한 명당 LLM이 이해해야 할 핵심 정보를 정규화해서 모아 둔 컨텍스트 테이블로 설계했습니다. 추천 시스템의 출발점이 되는 핵심 데이터였으며, Retrieval과 LLM 프롬프트 양쪽에서 모두 활용했습니다.
5.1.1 기본 정보
member_idmembershipage_groupjoin_monthsfamily_group_numfamily_role
이 정보는 고객의 기본 상태와 가족 구조를 이해하는 데 사용했습니다.
5.1.2 세분화 / 페르소나
segment:CHURN_RISK,UPSELL,NORMALpersona_code:SPACE_SHERLOCK,SPACE_SURFER,SPACE_GUARDIAN등
이 정보는 추천의 전략 방향과 설명 톤을 결정하는 데 활용했습니다.
5.1.3 현재 이용 / 관심 신호
current_subscriptions: 현재 가입 상품 목록(JSON)current_product_types:{ "MOBILE_PLAN": true, "INTERNET": false, ... }product_type_clicks:{ "MOBILE_PLAN": 5, "INTERNET": 3, ... }recent_viewed_tags_top_3:["데이터무제한", "가족결합메인", ...]
이 정보는 고객이 현재 무엇을 사용하고 있고, 무엇에 관심을 보이고 있는지를 반영하는 데 사용했습니다.
5.1.4 이용 패턴 / 이탈 위험
current_data_usage_ratiodata_usage_patternchurn_scorechurn_tierrecent_counselingcontract_expiry_within_3m
이 정보는 추천의 방향성을 바꾸는 핵심 신호로 사용했습니다. 예를 들어 churn 위험 고객에게는 방어적 추천이, upsell 고객에게는 확장형 추천이 필요하다고 판단했습니다.
5.2 product 테이블
이 테이블은 추천 후보가 되는 상품 마스터와 임베딩 정보를 포함하도록 설계했습니다.
5.2.1 기본 정보
product_idproduct_nameproduct_typepricesale_pricetags
5.2.2 벡터 검색 관련
embedding_textembedding_vector
embedding_vector 는 OpenAI 임베딩을 사용해 생성했으며, pgvector 기반 유사도 검색에 사용했습니다.
6. 추천 파이프라인 상세
6.1 API 입력
추천 요청은 POST /api/v1/recommendations 로 받도록 구현했습니다. 요청 바디는 다음과 같았습니다.
{
"memberId": 123
}
FastAPI 엔트리 포인트는 app/realtime/api/v1/recommendation.py 로 구성했습니다.
6.2 캐시 확인
추천 생성 전에 먼저 최근 7일 내 생성된 추천 결과가 있는지 확인하도록 설계했습니다.
목적
- 임베딩 재생성 비용을 줄였습니다.
- LLM 호출 비용을 줄였습니다.
- 응답 속도를 개선했습니다.
처리 방식
- 최근 7일 이내
persona_recommendation또는 캐시 테이블에 추천 결과가 있으면 재사용했습니다. - 이 경우 새로운 Retrieval이나 LLM 호출 없이 기존 결과를 그대로 반환했습니다.
설계 의도
추천은 고객 맥락이 자주 바뀌는 정보이지만, 매 요청마다 LLM을 호출하는 것은 비용과 지연 측면에서 비효율적이라고 판단했습니다. 따라서 “최근 7일 내 추천 결과가 있으면 reuse” 하는 캐시 전략을 적용했습니다.
6.3 컨텍스트 로딩
캐시가 없으면 member_llm_context 를 조회했습니다.
분기
- 있는 경우: 풀 컨텍스트 기반 추천 경로로 진입했습니다.
- 없는 경우: 컨텍스트 없는 fallback 경로로 이동했습니다.
컨텍스트가 있는 경우에는 단순 인기 기반이 아니라, 고객 상태를 반영한 개인화 경로로 추천을 진행했습니다.
6.4 Retrieval 쿼리 텍스트 생성
Retrieval 쿼리 생성의 목적은 고객이 실제로 관심 가질 만한 상품 유형이 벡터 검색 후보에 고르게 포함되도록 하는 것이었습니다.
기본적으로 다음 세 가지 입력 신호를 사용했습니다.
6.4.1 current_product_types
현재 사용 중인 타입에는 가장 강한 기본 가중치를 부여했습니다.
6.4.2 product_type_clicks
최근 클릭이 많은 타입은 현재 관심도가 높다고 보고 추가 가중치를 부여했습니다.
6.4.3 recent_viewed_tags_top_3
최근 본 태그를 상품 타입과 매핑하여, 해당 타입 가중치를 강화했습니다.
6.4.4 타입 라벨 변환
내부 enum 타입은 사람이 이해할 수 있는 텍스트로 변환해 사용했습니다.
예를 들어,
MOBILE_PLAN은 휴대폰 요금제로,INTERNET는 인터넷 상품으로,IPTV는 IPTV 상품으로,TAB_WATCH_PLAN은 태블릿/워치 요금제로,ADDON은 부가서비스로 변환했습니다.
6.4.5 쿼리 텍스트 설계 의도
모바일 상품만 과도하게 검색되지 않도록, 쿼리 마지막에 다음과 같은 문장을 추가했습니다.
“특히 휴대폰 요금제, 인터넷 상품, IPTV 상품 중에서 고객에게 어울리는 다양한 상품 유형을 함께 고려해 주세요.”
즉, 쿼리 생성 단계부터 검색 편향을 줄이기 위한 힌트를 주는 구조로 설계했습니다.
6.5 임베딩 및 벡터 검색
생성된 Retrieval 쿼리는 OpenAI 임베딩으로 변환했고, pgvector 기반 유사도 검색으로 최대 50개의 후보를 탐색했습니다.
6.5.1 검색 후보 수
RETRIEVAL_CANDIDATES_K = 50
6.5.2 타입 가중치 적용
compute_product_type_weights(ctx) 함수는 고객 상태를 바탕으로 타입별 boost 점수를 계산하도록 구현했습니다.
가중치 입력 신호는 다음 세 가지였습니다.
- 현재 사용 타입
- 타입별 클릭 수
- 최근 본 태그 매핑 결과
6.5.3 세그먼트별 차등 가중치
SEGMENT_WEIGHT_CONFIG 를 통해 세그먼트마다 current, click, tag 비중을 다르게 적용했습니다.
예를 들어,
CHURN_RISK는 현재 사용 타입 유지 쪽에 더 보수적으로,UPSELL은 확장 가능 타입에 더 공격적으로,NORMAL은 중립적 균형을 유지하도록 구성했습니다.
즉, 현재 사용 타입 > 클릭 수 > 최근 태그를 기본 축으로 보되, 세그먼트에 따라 비율이 달라지도록 설계했습니다.
7. 후보 후처리 로직
Retrieval 결과는 바로 LLM에 전달하지 않고, 후보 재정렬 및 필터링을 거쳤습니다.
7.1 데이터 사용 패턴 기반 재정렬
data_usage_pattern 값을 활용하여 일부 상품의 우선순위를 조정했습니다.
예를 들어,
- 데이터 과소 사용 고객에게는 더 가벼운 요금제를,
- 데이터 과다 사용 고객에게는 무제한 또는 고용량 상품을 우선하도록 조정했습니다.
이 단계는 고객의 실제 사용량 대비 더 적절한 상품이 상위로 오도록 조정하는 역할을 했습니다.
7.2 연령 태그 하드 필터
상품의 tags 에 연령 관련 태그가 있는 경우, 고객 연령대와 완전히 맞지 않으면 추천 후보에서 제외했습니다.
예를 들어,
- 키즈
- 자녀보호
- 청소년
- 20대청년
- 시니어
- 현역병사
- 복지혜택
등의 태그를 하드 필터 대상으로 처리했습니다.
설계 원칙
이 필터는 하드 룰로 적용했습니다. 추천 후보 수가 줄어들더라도 절대 완화하지 않았습니다.
목적
성인에게 키즈 요금제나 병사 전용 상품이 노출되는 것처럼 명백히 잘못된 추천을 원천 차단하기 위해서였습니다.
7.3 타입 분산 및 TOP-K 보장
벡터 검색 결과가 한 상품 타입에 과도하게 몰리는 문제를 막기 위해 타입 분산 로직을 적용했습니다.
규칙
- 타입별 최대 2개로 제한했습니다.
- 전체 후보는 최대 5개로 구성했습니다.
- 최종 추천은 최대 3개로 제한했습니다.
처리 방식
- 연령 필터를 통과한 후보에 대해 타입별 최대 2개씩 1차 후보를 구성했습니다.
- 상위 3개가 확보되면 그대로 사용했습니다.
- 3개보다 적으면 타입 분산 규칙은 일부 완화하되, 연령 필터는 유지한 채 추가 후보를 채워 넣었습니다.
목적
- 최소 3개 추천을 최대한 보장했습니다.
- 특정 타입 쏠림을 완화했습니다.
- LLM에 다양한 후보를 전달할 수 있도록 했습니다.
8. 세그먼트 전략
추천은 모든 고객에게 동일한 방식으로 동작하지 않도록 설계했습니다. 고객 세그먼트에 따라 무엇을 성공으로 볼지 자체가 다르다고 판단했기 때문입니다.
8.1 CHURN_RISK
목표
이탈 방지, 불만 완화, 부담 감소를 목표로 했습니다.
전략
- 현재 사용 타입과 가격 맥락을 유지하는 보수적 추천을 수행했습니다.
- 과도한 업셀링은 억제했습니다.
- 최근 상담과 불만 맥락을 reason에 반드시 반영하도록 했습니다.
- 가격 상한 룰을 적용했습니다.
가격 상한 룰
고객이 현재 사용 중인 타입별 최고 가격을 기준으로, 같은 타입 추천 상품이 현 최고가 × CHURN_MAX_PRICE_RATIO 를 초과하면 제거했습니다.
즉,
“지금보다 너무 비싼 상품은 이탈 위험 고객에게 보여주지 않는다”
는 원칙을 적용했습니다.
한 줄 요약
비싼 업셀보다, 이탈을 막는 현실적 대안을 제안했습니다.
8.2 UPSELL
목표
혜택 확장, ARPU 상승, 사용 경험 업그레이드를 목표로 했습니다.
전략
- 현재 지출과 관심 타입을 기준으로 조금 더 비싸지만 가치가 큰 후보에 가중치를 부여했습니다.
- 모바일 단일 업셀뿐 아니라 인터넷, IPTV, 부가서비스 결합까지 확장 가능하도록 설계했습니다.
- 프롬프트도 업그레이드와 확장 중심 비교형 설명으로 구성했습니다.
한 줄 요약
지출 대비 체감가치가 커지는 업그레이드 제안을 목표로 했습니다.
8.3 NORMAL
목표
균형 최적화, 만족도 유지, 가성비 중심 구성을 목표로 했습니다.
전략
- 과도한 방어도, 공격적 업셀도 지양했습니다.
- 관심 태그와 데이터 사용 패턴을 반영하되 안정적으로 추천했습니다.
- reason도 과장보다 이해 가능한 비교 문장 중심으로 설계했습니다.
한 줄 요약
무리 없는 개인화 최적안을 제안했습니다.
8.4 공통 규칙 vs 차별 포인트
공통 규칙
- 연령 태그 하드 필터를 적용했습니다.
- 타입 분산을 적용했습니다.
MAX_PRODUCTS_PER_TYPE = 2를 유지했습니다.- top3 보장을 목표로 했습니다.
- JSON reason 출력 규칙을 강제했습니다.
차별 포인트
- 세그먼트별 가중치 강도를 다르게 적용했습니다.
- 세그먼트별 프롬프트 톤을 다르게 설계했습니다.
- 목표 자체를 다르게 정의했습니다.
정리하면,
- churn 은 유지,
- upsell 은 확장,
- normal 은 균형
을 목표로 운영했습니다.
9. LLM 추천 단계
9.1 LLM 입력 구조
LLM에는 두 종류의 정보를 함께 전달했습니다.
고객 컨텍스트
segmentpersona_codecurrent_product_typesproduct_type_clicksrecent_viewed_tags_top_3data_usage_patternrecent_counseling- 가족 및 결합 정보
후보 상품 리스트
product_idproduct_nameproduct_typepricesale_pricetags
즉, LLM은 “아무 상품이나 추천하는 역할”이 아니라, 이미 정제된 후보 상품 중 어떤 것을 어떤 이유로 추천할지 설명하는 역할을 맡도록 했습니다.
9.2 프롬프트 설계
추천 시스템에서 LLM은 자유 생성기가 아니라, 톤과 구조가 강하게 통제된 생성기로 사용했습니다.
9.2.1 세그먼트 프롬프트
세그먼트별 system prompt는 다음과 같이 차이를 두었습니다.
CHURN_RISK: 불안과 불편 감소, 비용 부담 완화에 초점을 맞췄습니다.UPSELL: 혜택 확장, 라이프스타일 업그레이드에 초점을 맞췄습니다.NORMAL: 균형적 최적 조합, 가성비와 편의성 중심으로 설계했습니다.
공통 규칙
- 반드시 JSON으로만 응답하도록 했습니다.
- 추천 상품은 최대 3개로 제한했습니다.
reason은 마케팅 카피 톤의 2~3문장으로 생성하도록 했습니다.- 현재 요금제와 지출 수준 대비 비교 설명을 반드시 포함하도록 했습니다.
- 최근 상담, 데이터 이용 패턴, 가족 구성 중 최소 1개 이상을 구체적으로 언급하도록 했습니다.
- 일반적인 홍보 문구나 카탈로그 설명은 금지했습니다.
9.2.2 페르소나 스타일 프롬프트
세그먼트가 “무엇을 추천할지”를 정했다면, 페르소나는 “어떻게 말할지”를 정하도록 설계했습니다.
예를 들어,
SPACE_SHERLOCK은 절약, 효율, 최저가 조합을 강조했습니다.SPACE_GUARDIAN은 안정성, 자녀 보호, 안심 요금제를 강조했습니다.SPACE_SURFER는 혜택, 트렌드, 구독 확장을 강조했습니다.
즉, 세그먼트와 페르소나 2축으로 LLM reason의 방향과 말투를 동시에 제어했습니다.
9.3 LLM 출력 처리
LLM은 반드시 JSON으로 응답하도록 했습니다.
예시는 다음과 같았습니다.
{
"products": [
{ "productId": 1, "reason": "..." }
]
}
후처리 규칙
- LLM이 선택한
productId순서를 최대한 유지했습니다. - reason이 비어 있으면 기본 텍스트로 대체했습니다.
- LLM이 반환하지 않은
productId는 후보군에서 보완했습니다. - 최종 응답 단계에서도 타입별 최대 2개 제한을 다시 한 번 적용했습니다.
10. Fallback 전략
10.1 컨텍스트 없음
member_llm_context 가 없는 경우에는,
- 기본적인 쿼리로 임베딩과 검색을 수행했거나,
- 미리 정의된 기본 상품 위주로 추천했습니다.
10.2 임베딩 / OpenAI 실패
- 기본 상품 목록을 조회했습니다.
- fallback 메시지를 사용했습니다.
- 이때도 타입 편향 방지를 위한 최대 2개 룰은 유지했습니다.
10.3 조건 미충족
연령 필터 등으로 추천 가능한 상품이 없으면,
"조건에 맞는 추천 상품이 없습니다."라는 메시지를 반환하고,- 빈 배열을 응답했습니다.
설계 의도
실제 서비스에서는 “추천이 실패해서 아무 응답도 못 주는 것”이 가장 위험하다고 판단했습니다. 따라서 항상 동작하는 경로를 확보하는 것이 중요하다고 보았습니다.
11. 응답 구조
루트 응답
segmentcached_llm_recommendationrecommended_productssourceupdated_at
상품 항목
product_idproduct_nameproduct_typeproduct_pricesale_pricetagsreason
응답은 외부 API 기준으로 일관된 JSON 스키마를 유지하도록 설계했습니다.
12. 평가 구조
추천 품질은 오프라인 스크립트로 평가했습니다.
입력
member_idexpected_products또는recommended_products
지표
Hit@kRecall@k
목적
- 추천 결과가 기대 상품과 얼마나 겹치는지 정량 평가했습니다.
- reason 내용까지 CSV로 저장하여 정성 평가도 가능하도록 했습니다.
즉, 추천 시스템은 단순히 “LLM을 붙였다”에서 끝난 것이 아니라, 오프라인 평가와 튜닝이 가능한 구조로 설계했습니다.
13. 핵심 설계 포인트 요약
13.1 모바일 편향 완화
단일 단계가 아닌 다단계 제어를 사용했습니다.
- 쿼리 텍스트에서 다양한 타입을 명시했습니다.
- 검색 단계에서 타입 가중치를 부여했습니다.
- 후보 단계에서 타입 분산을 적용했습니다.
- 최종 응답에서 최대 2개 제한을 다시 적용했습니다.
13.2 안전한 룰 기반 필터
- 연령 태그 하드 필터를 적용했습니다.
- 세그먼트별 가격 및 룰 전략을 반영했습니다.
- 조건 미충족 시 안전하게 종료하도록 했습니다.
13.3 LLM 통제형 사용
- 세그먼트와 페르소나 2축 프롬프트를 적용했습니다.
- JSON 형식을 강제했습니다.
- reason의 톤과 내용 구조를 통제했습니다.
13.4 운영성과 비용 최적화
- 최근 7일 추천 캐시를 재사용했습니다.
- fallback 경로를 보장했습니다.
- 오프라인 평가가 가능하도록 설계했습니다.
14. 한 줄 정리
Holliverse 추천 시스템은 고객 상태를 이해한 뒤 후보를 검색하고, 세그먼트와 페르소나에 맞는 설명까지 생성하는 운영형 RAG 추천 시스템으로 설계하고 구현했습니다.