목차
- Smart Filtered Search (LLM 필터 추출)
- 하이브리드 검색 (벡터 + BM25)
- RAG 검색 성능 평가 (Hit Rate, MRR)
- MMR 검색 (다양성 확보)
- LLM 리랭킹
- Content-based Filtering (CBF) 추천
- 아이템 유사도 행렬
- 자주 나오는 실수 / 주의사항
- [보충] 협업 필터링 개념 (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 방식 사용.
끝