포스트

2026-04-17 강의 정리

추천 시스템 - 추천 로직 구현

2026-04-17 강의 정리

목차

  1. Smart Filtered Search (LLM 필터 추출)
  2. 하이브리드 검색 (벡터 + BM25)
  3. RAG 검색 성능 평가 (Hit Rate, MRR)
  4. MMR 검색 (다양성 확보)
  5. LLM 리랭킹
  6. Content-based Filtering (CBF) 추천
  7. 아이템 유사도 행렬
  8. 자주 나오는 실수 / 주의사항
  9. [보충] 협업 필터링 개념 (Collaborative Filtering)

1. Smart Filtered Search (LLM 필터 추출)

사용자의 자연어 쿼리에서 LLM이 자동으로 필터 조건을 추출.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def smart_filtered_search(query):
    prompt = f"""사용자 쿼리에서 ETF 검색 필터를 추출하세요.

    쿼리: {query}

    아래 JSON 형식으로만 답하세요:
    category,
        "dividend_yield": greater_than,
        "risk_level": "낮음|약간 낮음|중간|높음|null"
    }}
    null은 해당 조건 없음을 의미합니다."""

    response = llm.invoke(prompt)
    filters = json.loads(response.content)
    # None 값은 필터에서 제거
    return {k: v for k, v in filters.items() if v is not None}

실행 예시:

1
2
3
test_query = "수수료 0.1% 이하이면서 배당 3% 이상인 ETF"
filters = smart_filtered_search(test_query)
# → {'expense_ratio': {'less_than': 0.1}, 'dividend_yield': {'greater_than': 3.0}}

통합 검색 함수

1
2
3
4
5
6
7
8
def smart_document_search(query, k=3):
    filters = smart_filtered_search(query)
    print(f"쿼리: {query}")
    print(f"추출된 필터: {filters}")
    results = filtered_search(vectorstore, query, filters=filters, k=k)
    return results

smart_document_search("위험 낮고 배당 2% 이상인 ETF")

2. 하이브리드 검색 (벡터 + BM25)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
def hybrid_search(query, alpha=0.5, k=5, filters=None):
    """
    alpha: 벡터 검색 가중치
           alpha=0.0 → BM25만 사용
           alpha=0.5 → 50:50 혼합
           alpha=1.0 → 벡터만 사용
    """
    # 벡터 검색 결과 (정규화)
    vec_results = vectorstore.similarity_search_with_score(query, k * 4)
    vec_scores = {}
    if vec_results:
        scores = [1.0 / (1.0 + s) for _, s in vec_results]  # 거리 → 유사도
        min_s, max_s = min(scores), max(scores)
        for (doc, _), s in zip(vec_results, scores):
            vec_scores[doc.metadata['name']] = (s - min_s) / (max_s - min_s + 1e-9)

    # BM25 검색 결과 (정규화)
    bm25_raw = bm25_search(query, k=k * 4)
    bm25_scores = {}
    if bm25_raw:
        scores = [s for _, s in bm25_raw]
        min_s, max_s = min(scores), max(scores)
        for doc, s in bm25_raw:
            bm25_scores[doc.metadata['name']] = (s - min_s) / (max_s - min_s + 1e-9)

    # 점수 합산
    all_names = set(vec_scores) | set(bm25_scores)
    combined = {}
    for name in all_names:
        v = vec_scores.get(name, 0)
        b = bm25_scores.get(name, 0)
        combined[name] = alpha * v + (1 - alpha) * b

    # 정렬 후 상위 k개 반환
    sorted_names = sorted(combined, key=combined.get, reverse=True)[:k]
    ...

alpha 값에 따른 차이

1
2
3
4
query = "미국 기술 성장 ETF"
for alpha in [0.0, 0.5, 1.0]:
    results = hybrid_search(query, alpha=alpha, k=3)
    print(f"alpha={alpha}: {[r.metadata['name'] for r in results]}")
1
2
3
alpha=0.0 (BM25만):  키워드 "미국", "기술" 직접 매칭 결과
alpha=0.5 (혼합):    두 방식의 장점 결합
alpha=1.0 (벡터만):  의미적으로 가까운 ETF

3. RAG 검색 성능 평가 (Hit Rate, MRR)

평가 데이터 구성

1
2
3
4
5
6
7
eval_queries = [
    {"query": "안전한 배당 ETF",
     "relevant": ["ACE 미국배당다우존스", "TIGER 국고채10년", "KODEX 단기채권PLUS"]},
    {"query": "미국 기술주 고성장",
     "relevant": ["TIGER 미국필라델피아반도체나스닥", "KODEX 미국나스닥100"]},
    ...
]

Hit Rate (적중률)

1
2
3
4
5
6
7
8
def hit_rate(eval_data, search_fn, k=5):
    hits = 0
    for item in eval_data:
        results = search_fn(item['query'], k=k)
        names = [r.metadata['name'] for r in results]
        if any(rel in names for rel in item['relevant']):
            hits += 1
    return hits / len(eval_data)

MRR (Mean Reciprocal Rank)

첫 번째 정답 문서의 순위가 높을수록 좋은 지표.

1
2
3
4
5
6
7
8
9
10
def mrr(eval_data, search_fn, k=5):
    rr_sum = 0
    for item in eval_data:
        results = search_fn(item['query'], k=k)
        names = [r.metadata['name'] for r in results]
        for rank, name in enumerate(names, 1):
            if name in item['relevant']:
                rr_sum += 1 / rank  # Reciprocal Rank
                break
    return rr_sum / len(eval_data)

예시: 정답이 3위에 있으면 RR = 1/3 ≈ 0.33

검색 방법별 비교

1
2
3
4
5
6
7
8
9
10
11
12
13
def vec_fn(q, k=5):
    return vectorstore.similarity_search(q, k=k)

def bm25_fn(q, k=5):
    return [doc for doc, _ in bm25_search(q, k=k)]

def hybrid_fn(q, k=5):
    return hybrid_search(q, alpha=0.5, k=k)

for name, fn in [("vec", vec_fn), ("bm25", bm25_fn), ("hybrid", hybrid_fn)]:
    hr3 = hit_rate(eval_queries, fn, k=3)
    mr  = mrr(eval_queries, fn, k=5)
    print(f"{name:8s} | hit@3: {hr3:.2f} | MRR: {mr:.2f}")

4. MMR 검색 (다양성 확보)

MMR = Maximum Marginal Relevance

1
2
3
MMR = λ * Relevance(doc, query) - (1-λ) * max(Similarity(doc, selected))

목적: 관련성 높은 문서를 가져오되, 이미 선택된 문서와 중복되지 않도록 다양성 확보
1
2
3
4
5
6
7
# 일반 유사도 검색 (중복 가능)
sim_results = vectorstore.similarity_search(query, k=5)

# MMR 검색 (다양성 확보)
mmr_results = vectorstore.max_marginal_relevance_search(
    query, k=5, fetch_k=20  # 20개 후보 중 MMR로 5개 선택
)

사용 케이스: 포트폴리오 추천 시 같은 카테고리 ETF만 추천되는 것을 방지


5. LLM 리랭킹

초기 검색 결과를 LLM이 재정렬.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def llm_rerank(query, candidates, k=5):
    name_to_doc = {d.metadata['name']: d for d in documents}
    names_list  = [d.metadata['name'] for d in candidates]

    prompt = f"""다음 ETF 목록을 쿼리 "{query}"에 대한 적합도 순으로 재정렬하세요.

    ETF 목록:
    {json.dumps(names_list, ensure_ascii=False)}

    JSON 배열로 ETF 이름만 순서대로 출력하세요. 예: ["ETF1", "ETF2", ...]"""

    response = llm.invoke(prompt)
    reranked_names = json.loads(response.content)
    return [name_to_doc[n] for n in reranked_names[:k] if n in name_to_doc]

실제 적용

1
2
3
query   = "초보자에게 안전한 ETF"
initial = hybrid_search(query, alpha=0.5, k=8)   # 초기 8개 검색
reranked = llm_rerank(query, initial, k=3)         # LLM이 3개로 재정렬

6. Content-based Filtering (CBF) 추천

“이 ETF와 비슷한 ETF 추천해줘” 기능.

ETF를 벡터로 표현

1
2
3
4
5
6
7
8
9
risk_map = {"낮음": 1, "중간": 2, "높음": 3}

def etf_to_vector(etf):
    return np.array([
        risk_map.get(etf['risk_level'], 2),  # 위험도
        etf['expense_ratio'] * 10,            # 수수료 (스케일 조정)
        etf['return_1y'] / 100,              # 수익률
        etf['dividend_yield'],               # 배당수익률
    ])

코사인 유사도 계산

1
2
3
4
from numpy.linalg import norm

def cosine_sim(a, b):
    return float(np.dot(a, b) / (norm(a) * norm(b)))

아이템-아이템 유사도 행렬 구축

1
2
3
4
5
6
7
8
9
10
11
def build_item_similarity(vectors):
    n = len(vectors)
    sim = np.zeros((n, n))
    for i in range(n):
        for j in range(n):
            sim[i][j] = cosine_sim(vectors[i], vectors[j])
    return sim

item_vectors = np.array([etf_to_vector(e) for e in etf_data])
item_sim     = build_item_similarity(item_vectors)
item_names   = [e['name'] for e in etf_data]

유사 ETF 검색

1
2
3
4
5
6
7
8
def cbf_similar_items(target_name, top_k=5):
    idx    = item_names.index(target_name)
    scores = [(j, item_sim[idx][j]) for j in range(len(item_names)) if j != idx]
    scores.sort(key=lambda x: x[1], reverse=True)
    return [(item_names[j], score) for j, score in scores[:top_k]]

for name, score in cbf_similar_items('KODEX 200', top_k=5):
    print(f" {score:.3f} | {name}")

다양성 확보 CBF (MMR 방식)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def cbf_diverse(target_name, top_k=5):
    idx      = item_names.index(target_name)
    scores   = [(j, item_sim[idx][j]) for j in range(len(item_names)) if j != idx]
    selected = []

    while len(selected) < top_k and scores:
        # MMR: 관련성 높고, 이미 선택된 항목과는 다른 ETF 선택
        best = max(
            scores,
            key=lambda x: x[1] - max(
                [item_sim[x[0]][s] for s in selected], default=0
            )
        )
        selected.append(best[0])
        scores = [(j, s) for j, s in scores if j != best[0]]

    return [(item_names[j], item_sim[idx][j]) for j in selected]

7. 아이템 유사도 행렬 요약

1
2
3
4
5
6
7
8
9
[item_sim 행렬 구조]

             KODEX 200  TIGER S&P500  ...
KODEX 200      1.000       0.732     ...
TIGER S&P500   0.732       1.000     ...
...

→ 대각선 = 1.0 (자기 자신)
→ 높은 값 = 유사한 ETF (같은 카테고리, 비슷한 위험도)

활용:

  • 현재 보유 ETF와 유사한 ETF 추천
  • 포트폴리오 중복 제거 (유사도 높은 ETF 중복 보유 경고)

자주 나오는 실수 / 주의사항

  • LLM 필터 추출 실패: json.loads() 에러 → try-except + 파싱 실패 시 필터 없이 검색하는 fallback 처리 필수
  • alpha=0.5 하이브리드 고정 사용: 도메인, 쿼리 특성에 따라 최적 alpha 다름 → 평가 데이터로 실험 후 결정
  • cbf_similar_items 자기 자신 포함: j != idx 조건 없으면 항상 자기 자신이 1위 → 반드시 제외
  • item_vectors 스케일 미조정: expense_ratio는 0.05~0.68, return_1y는 -12~227 → 단위 차이가 크면 수익률만 반영됨. 스케일 조정 필수
  • hit_rate와 MRR 해석: hit_rate는 관련 문서가 k개 안에 있는지만 확인, MRR은 순위도 고려 → 순위가 중요하면 MRR 우선

[보충] 협업 필터링 개념 (Collaborative Filtering)

CBF(Content-based Filtering)와 달리, 사용자 행동 패턴 기반 추천.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[Content-based Filtering (CBF)]
  - "이 ETF와 비슷한 특성의 ETF"
  - 아이템 특성(수수료, 수익률 등) → 벡터화 → 유사도 계산
  - 콜드 스타트 문제 없음 (새 아이템도 추천 가능)

[Collaborative Filtering (CF)]
  - "이 ETF를 산 사람들이 같이 산 ETF"
  - 사용자 × 아이템 행렬 → 패턴 학습
  - 사용자 행동 데이터 필요 (로그인, 구매 이력)

                 ETF_A  ETF_B  ETF_C  ETF_D
사용자 1 (공격적)    ★★★    ★★★    ○      ○
사용자 2 (안정형)    ○      ○      ★★★    ★★★
사용자 3 (?)        ★★★    ?      ○      ?

→ 사용자 3이 ETF_A를 좋아한다면, 사용자 1과 유사 → ETF_B 추천

현재 ETF 챗봇에서 CF를 쓰려면: 사용자별 ETF 조회/검색 로그가 필요하므로, 이번 프로젝트는 CBF 방식 사용.


이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

인기 태그