자연어에서 검색 조건을 추출하면 편의성은 높아지지만 조건 해석과 검색 품질이라는 두 문제가 생긴다. 필터 추출, 하이브리드 검색, 리랭킹을 연결하되 사용자의 명시적 제약이 어느 단계에서도 조용히 사라지지 않는지 살펴본다. 추천 결과는 순위뿐 아니라 포함·제외 이유도 설명할 수 있어야 한다.
목차
- Smart Filtered Search (LLM 필터 추출)
- 하이브리드 검색 (벡터 + BM25)
- RAG 검색 성능 평가 (Hit Rate, MRR)
- MMR 검색 (다양성 확보)
- LLM 리랭킹
- Content-based Filtering (CBF) 추천
- 아이템 유사도 행렬
- 자주 나오는 실수 / 주의사항
- [보충] 협업 필터링 개념 (Collaborative Filtering)
- 순위보다 먼저 지켜야 할 검색 조건
1. Smart Filtered Search (LLM 필터 추출)
필터 추출이 실패한 경우와 사용자가 조건을 주지 않은 경우는 다르다. 필터 없이 재검색하는 fallback은 결과 범위를 넓히므로, 명시적 조건을 지키지 못한 결과를 정상 결과처럼 반환하지 않도록 실패 상태를 구분한다.
사용자의 자연어 쿼리에서 LLM이 자동으로 필터 조건을 추출.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| def smart_filtered_search(query):
prompt = f"""사용자 쿼리에서 ETF 검색 필터를 추출하세요.
쿼리: {query}
아래 JSON 형식으로만 답하세요:
{{
"category": "국내주식|해외주식|채권|원자재|부동산|테마|혼합|null",
"expense_ratio": {{"less_than": 수수료_상한값_or_null}},
"dividend_yield": {{"greater_than": 배당수익률_하한값_or_null}},
"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 방식 사용.
10. 순위보다 먼저 지켜야 할 검색 조건
검색 결과를 판단할 때는 조건 준수, 관련성, 다양성의 순서를 구분한다. 사용자 제약을 만족하지 못한 결과는 순위가 높아도 같은 기준의 후보로 비교하기 어렵다.
로그에는 원문 질문과 추출한 필터, 실제 적용한 조건을 함께 남긴다. 잘못된 결과가 나왔을 때 추출 오류인지 검색 로직의 문제인지 나눌 수 있어야, 프롬프트와 검색기 중 수정할 부분이 명확해진다.