1 왜 세그멘테이션이 필요한가
세그멘테이션은 단독 기능이 아니라 세 시스템이 공통으로 의존하는 토대다.
| 의존 시스템 | 세그먼트 사용처 |
|---|---|
| C15 A/B 심화 | Simpson’s paradox 방어 — 세그먼트별 분석으로 전체 평균에 가려진 효과 발견 |
| C16 Contextual Bandit | LinUCB·Logistic TS의 context feature — 세그먼트 one-hot 또는 임베딩 |
| C18 개인화 전략 | 세그먼트별 프롬프트·응답 스타일·지식 범위 분기 |
세그멘테이션이 부정확하면 세 시스템이 모두 손실을 본다 — Bandit이 잘못된 context로 학습, A/B가 가짜 세그먼트별 효과를 보고, 개인화가 엉뚱한 사용자에 적용된다. 본 편은 그래서 분류 자체의 품질에 집중한다.
2 4가지 세그멘테이션 차원
세그멘테이션 차원
├── 명시적 속성 (Attribute-based)
│ └── 부서·역할·직급·지역 — DB에서 직접
├── 행동 기반 (Behavior-based)
│ └── 사용 빈도·세션 길이·기능 사용 패턴 — 로그 집계
├── 토픽 기반 (Topic-based)
│ └── 질의 토픽 분포·도메인 키워드 — 임베딩 + 클러스터링
└── 시간 기반 (Lifecycle-based)
└── 신규/활성/이탈 위험/이탈 — RFM 또는 cohort
차원이 직교하지 않다 — 한 사용자에 여러 세그먼트 라벨이 동시 부착된다 (예: “Sales 부서 + heavy user + 영업제안 토픽 + 활성”). 따라서 단일 세그먼트 ID가 아니라 feature 벡터로 다루는 것이 운영 유연성에 좋다.
3 차원 1 — 명시적 속성
가장 단순하고 안정적. DB의 사용자 메타데이터를 그대로 사용.
# app/segments/explicit.py
from pydantic import BaseModel
from enum import Enum
class Department(str, Enum):
sales = "sales"
rnd = "rnd"
support = "support"
finance = "finance"
class ExplicitSegment(BaseModel):
user_id: str
department: Department
role: str # "manager"·"engineer"·"analyst"
seniority: int # 0~5 (junior~executive)
region: str # "kr-seoul"·"us-east"
def derive_explicit_segment(user_record: dict) -> ExplicitSegment:
return ExplicitSegment(
user_id=user_record["id"],
department=user_record["department"],
role=user_record["role"],
seniority=user_record.get("seniority_level", 0),
region=user_record.get("region", "unknown"),
)강점: 즉시 사용 가능, 라벨 명확, 콜드 사용자도 즉시 분류. 약점: 같은 부서 안에서도 행동이 천차만별 — 세분화 부족.
4 차원 2 — 행동 기반
로그를 집계해 RFM(Recency·Frequency·Monetary) 같은 행동 feature 추출.
# app/segments/behavior.py
from datetime import datetime, timedelta
import numpy as np
def behavior_features(user_id: str, sessions: list[dict],
lookback: timedelta = timedelta(days=90)) -> dict:
cutoff = datetime.utcnow() - lookback
recent = [s for s in sessions if s["ended_at"] > cutoff]
if not recent:
return {"is_cold": True}
return {
"queries_per_week": len(recent) / (lookback.days / 7),
"avg_session_length_min": np.mean([s["duration_sec"] / 60 for s in recent]),
"thumbs_up_rate": np.mean([s.get("thumbs_up", 0) for s in recent]),
"feedback_rate": np.mean([1 if s.get("has_feedback") else 0 for s in recent]),
"p95_latency_tolerance": np.percentile([s["latency_ms"] for s in recent], 95),
"is_cold": False,
}이 feature를 클러스터링하거나 직접 buckets:
def bucket_frequency(qpw: float) -> str:
if qpw < 1: return "occasional"
if qpw < 5: return "regular"
if qpw < 20: return "active"
return "heavy"운영에서는 단순 bucket이 클러스터링보다 안정적이다 — 라벨이 명확하고 변동성 낮음.
5 차원 3 — 토픽 기반
질의 임베딩을 평균/모드 → 사용자별 “관심 토픽” 벡터.
# app/segments/topic.py
import numpy as np
from sentence_transformers import SentenceTransformer
def user_topic_centroid(queries: list[str], model: SentenceTransformer) -> np.ndarray:
if not queries:
return None
emb = model.encode(queries, normalize_embeddings=True)
return emb.mean(axis=0) # 정규화 후 평균
# 모든 사용자의 centroid를 K-means로 클러스터
from sklearn.cluster import KMeans
centroids = np.stack([user_topic_centroid(q, model) for q in user_queries])
km = KMeans(n_clusters=8, random_state=42).fit(centroids)
topic_segment = km.labels_ # 사용자별 토픽 클러스터 ID각 클러스터의 대표 키워드로 라벨링 — TF-IDF 상위 또는 LLM 요약:
def label_topic_cluster(cluster_queries: list[str]) -> str:
sample = "\n".join(cluster_queries[:50])
prompt = f"다음 질의들의 공통 주제를 한 단어 라벨로 요약: {sample}"
return llm_summarize(prompt)강점: 명시적 부서·역할이 못 잡는 미세 그룹 발견. 약점: - 임베딩 모델 의존 — 모델 교체 시 라벨 안정성 깨짐 - 사용자 신규/이탈 시 centroid 갱신 필요 - 라벨이 모호 (“technical issues” vs “product question” 경계)
6 차원 4 — 시간 기반 (Lifecycle)
# app/segments/lifecycle.py
def lifecycle_stage(user: dict) -> str:
days_since_signup = (datetime.utcnow() - user["created_at"]).days
days_since_last = (datetime.utcnow() - user["last_active"]).days
if days_since_signup < 14: return "onboarding"
if days_since_last > 60: return "churned"
if days_since_last > 21: return "at_risk"
if user["queries_per_week"] >= 20: return "power"
return "active"LLM 도구의 onboarding은 특히 중요 — 신규 사용자에게 templates·예시 질의를 더 많이 보여주는 개인화의 토대.
7 클러스터링 알고리즘 선택
| 알고리즘 | 적합 |
|---|---|
| K-means | 토픽 임베딩 (구형 클러스터, 차원 100~1000) |
| Hierarchical | 작은 N (<10K), 덴드로그램으로 적정 K 시각 결정 |
| GMM | 클러스터 크기·밀도 다른 경우, soft assignment |
| HDBSCAN | 클러스터 수 자동, 노이즈 탐지 |
| Gaussian Mixture + EM | 확률적 멤버십이 필요한 경우 (Bandit weighting) |
# 일반적 패턴 — K-means + silhouette로 K 결정
from sklearn.metrics import silhouette_score
scores = {}
for k in range(2, 15):
km = KMeans(n_clusters=k, random_state=42, n_init=10).fit(X)
scores[k] = silhouette_score(X, km.labels_)
best_k = max(scores, key=scores.get)대부분의 운영에서는 8~12개 세그먼트가 라벨링·해석·라우팅 운영에 적당하다. 너무 많으면 small-segment overfitting (다음 함정 절).
8 세그먼트 안정성 평가
세그먼트는 시간이 지나도 같은 사용자가 같은 클러스터에 머물러야 신뢰할 수 있다.
# app/segments/stability.py
from sklearn.metrics import adjusted_rand_score
def cohort_stability(labels_t1: dict, labels_t2: dict) -> float:
"""t1·t2 두 시점의 라벨링 일치도 (Adjusted Rand Index)."""
common = set(labels_t1) & set(labels_t2)
a = [labels_t1[u] for u in common]
b = [labels_t2[u] for u in common]
return adjusted_rand_score(a, b)
# 매 분기 점검 — ARI > 0.7이면 안정적
ari = cohort_stability(snapshot_q3, snapshot_q4)
assert ari > 0.7, f"Segment drift detected: ARI={ari:.2f}"ARI < 0.7이면: - 클러스터링 시드를 고정해도 갱신 시점마다 라벨이 다르게 부착됨 - 운영 시스템(라우팅·개인화)이 매 갱신마다 사용자 처치를 바꿔 신규 노이즈 발생
해법: - Anchored clustering — 이전 시점 centroid를 새 K-means 초기값으로 - Hungarian matching — 새 클러스터를 이전 라벨로 매칭 - 고정 라벨 정의 — 데이터에서 발견하지 않고 비즈니스가 정의 (부서·역할 같은 명시적 차원 우선)
9 세그먼트 운영 — 라벨링·갱신·콜드 사용자
9.1 라벨 스키마
-- segments 테이블
CREATE TABLE segments (
user_id VARCHAR(64) PRIMARY KEY,
department VARCHAR(32),
role VARCHAR(64),
behavior_bucket VARCHAR(32), -- occasional/regular/active/heavy
topic_cluster INT,
topic_label VARCHAR(64),
lifecycle_stage VARCHAR(32),
feature_vector VECTOR(64), -- Bandit context용 임베딩
updated_at TIMESTAMP DEFAULT NOW()
);명시적 차원과 자동 추론 차원을 모두 한 행에 보관. Bandit은 feature_vector 또는 one-hot 결합을 사용.
9.2 갱신 주기
| 차원 | 갱신 주기 | 이유 |
|---|---|---|
| 명시적 (부서·역할) | DB 변경 시 즉시 | HR 시스템 webhook 또는 일일 sync |
| 행동 (queries_per_week) | 일일 | 7~30일 rolling window |
| 토픽 클러스터 | 주간 | 임베딩 재계산 비용 |
| Lifecycle (active·at_risk) | 일일 | churn 예측의 즉시성 |
| Centroid 자체 | 분기 | 안정성 확보 |
매 갱신 시 이전 세그먼트와 비교해 차이가 큰 사용자만 라우팅 시스템에 통보 (incremental update).
9.3 콜드 사용자
가입 후 N일 미만 또는 질의 수 < threshold:
def assign_segment(user: dict, history: list[dict]) -> dict:
explicit = derive_explicit_segment(user)
if len(history) < 5 or user["age_days"] < 7:
return {**explicit.dict(), "is_cold": True, "lifecycle_stage": "onboarding"}
behavior = behavior_features(user["id"], history)
return {**explicit.dict(), **behavior, "is_cold": False}콜드 사용자는: - Bandit context에서 명시적 feature만 사용 (행동·토픽은 0 또는 default) - 개인화는 부서·역할 기반 fallback - A/B 분석 시 별도 세그먼트로 — 일반 분석에 섞이면 noise
10 MINERVA 적용 — 세그먼트와 Bandit 통합
# app/routing/contextual_bandit.py
import numpy as np
def context_vector(segment: dict) -> np.ndarray:
"""세그먼트 dict → Bandit context 벡터."""
return np.concatenate([
one_hot(segment["department"], DEPARTMENTS), # 4 dim
one_hot(segment["role"], ROLES), # 8 dim
one_hot(segment["behavior_bucket"], BUCKETS), # 4 dim
one_hot(segment["topic_cluster"], range(8)), # 8 dim
[segment.get("queries_per_week", 0) / 50], # 1 dim normalized
[int(segment.get("is_cold", False))], # 1 dim
]) # 총 26 dim
# C16의 LinUCB와 결합
from app.routing.bandit_router import LinUCB
router = LinUCB(n_arms=4, n_features=26)
def route_query(user_id: str, query: str) -> str:
segment = load_segment(user_id)
ctx = context_vector(segment)
arm = router.select(ctx)
return ARMS[arm] # reranker_idLinUCB가 자동으로 어떤 차원이 어느 arm에 영향력이 큰지 학습. “Sales 부서 + heavy user → reranker A 선호” 같은 패턴이 자동 발견된다.
11 자주 발생하는 함정
11.1 Small-segment overfitting
20명짜리 세그먼트에서 reranker A가 thumbs_up_rate 80% — 통계적 노이즈. 22편의 power analysis가 여기서도 적용된다.
def is_segment_significant(n_users: int, mde: float = 0.05) -> bool:
from app.experiments.sample_size import required_sample_per_arm
required = required_sample_per_arm(p_baseline=0.55, mde=mde)
return n_users >= required
# 운영
for seg_id, users in segments.items():
if not is_segment_significant(len(users)):
# 작은 세그먼트는 상위 세그먼트로 fallback
...11.2 Label leakage
세그먼트 라벨을 만들 때 target 메트릭(thumbs_up)을 feature로 쓰면 라우팅 평가가 자기 참조가 된다.
# WRONG
features = [..., thumbs_up_rate] # target leakage
clusters = KMeans(...).fit(features)
# 이 segment로 다시 thumbs_up rate를 평가 → 부풀려진 효과해법: target 메트릭은 segment feature에서 제외하거나 사용 시점을 사전·사후로 명시 분리.
11.3 Shifting cohort
매 갱신 시 사용자가 다른 세그먼트로 이동 → A/B 분석에서 “treatment 그룹의 평균이 실은 다른 사람들”이 됨.
해법: A/B 실험 동안 세그먼트를 freeze — 실험 시작 시점의 라벨을 사용자 ID에 할당하고 실험 종료까지 유지.
11.4 Naming drift
토픽 클러스터의 LLM 라벨이 갱신마다 약간 달라짐 (“billing question” → “payment inquiry”). 운영팀·대시보드 혼란.
해법: - 라벨은 사람이 한번 결정한 후 고정 - 클러스터 재훈련해도 라벨 사전 매핑 유지 - Hungarian matching으로 새 클러스터 → 기존 라벨
11.5 차원 폭발
세그먼트를 너무 많이 (“부서×역할×토픽×lifecycle = 4×8×8×4 = 1024 셀”). 셀당 사용자 수 → 통계 무의미.
해법: Bandit context 벡터에는 모든 차원을 넣되 세그먼트 ID는 고수준 8~12개만. 미세 분기는 Bandit이 학습.
12 정리
| 영역 | 핵심 |
|---|---|
| 차원 4가지 | 명시적·행동·토픽·시간 — 직교 아님, feature 벡터로 통합 |
| 명시적 | DB 메타 — 안정·즉시 사용 |
| 행동 | RFM bucket — 단순 bucket이 클러스터링보다 안정 |
| 토픽 | 임베딩 + K-means + LLM 라벨링 |
| Lifecycle | onboarding·active·at_risk·churned (RFM 일종) |
| 클러스터링 | K-means + silhouette → 8~12개 적정 |
| 안정성 | ARI > 0.7 점검, anchored clustering·Hungarian matching |
| 운영 | 갱신 주기 차원별 차등, 콜드 사용자 fallback |
| Bandit 통합 | context 벡터로 변환, LinUCB가 차원 가중 학습 |
| 함정 | Small-segment·Label leakage·Shifting cohort·Naming drift·차원 폭발 |
13 응용 분야
| 시나리오 | 차원 조합 |
|---|---|
| Contextual Bandit context | 명시적 + 행동 + lifecycle (3차원 결합) |
| A/B 세그먼트별 분석 | 명시적 + 행동 (Simpson’s paradox 방어) |
| 개인화 프롬프트 분기 | 명시적 + 토픽 (부서별 응답 스타일 + 토픽별 지식) |
| Onboarding 흐름 | lifecycle (onboarding·active 분기) |
| Churn 예측 | 행동 + lifecycle (at_risk 자동 추출) |
| 신규 모델 도입 시 작은 그룹 시험 | 명시적 (R&D 부서로 우선 expose) |
14 관련 주제
선행 학습 (선수)
- 22편 A/B 심화 — Simpson’s paradox·세그먼트별 power
- 23편 지능형 라우팅 (Contextual Bandit) — 세그먼트가 context로 들어감
- 03편 RAG 파이프라인 — 토픽 임베딩 같은 모델 활용
18-LangGraph 시리즈 cross-reference
- #10 프롬프트 분류와 라우팅 — 토픽 분류 결과를 세그먼트로 사용
- #18 스킬 라우팅 확장성 — 세그먼트별 스킬 풀 분리
후속 (Phase C-4)
- C18 개인화 전략 — 세그먼트별 프롬프트·스타일·지식 범위
- C19 실험 파이프라인 자동화 — 세그먼트 정의→실험→사후 검증 루프