포스트

2026-04-16 강의 정리

추천 시스템 - 상품 검색 시스템 구현

2026-04-16 강의 정리

목차

  1. 프로젝트 아키텍처 개요
  2. ETF 데이터 수집 & 성과 지표 계산
  3. LLM으로 ETF 설명 생성 (Document 구성)
  4. FAISS 벡터스토어 구축
  5. BM25 키워드 검색 (Kiwi 형태소 분석)
  6. 필터 기반 검색 (filtered_search)
  7. BM25 파라미터 튜닝
  8. 자주 나오는 실수 / 주의사항
  9. [보충] 금융 챗봇 전체 파이프라인

1. 프로젝트 아키텍처 개요

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[ETF 금융 챗봇 파이프라인]

Step 1. 데이터 수집
  FinanceDataReader → ETF 가격 데이터 (1년치)

Step 2. 성과 지표 계산
  수익률, 변동성, MDD → etf_data[]

Step 3. LLM 설명 생성
  GPT-4o-mini → ETF 한 줄 설명 → Document

Step 4. 벡터스토어 구축
  OpenAIEmbeddings → FAISS 인덱싱

Step 5. 검색 (RAG Retriever)
  벡터 검색 + BM25 키워드 검색

Step 6. 사용자 쿼리 응답
  검색 결과 → GPT-4o-mini → 답변

2. ETF 데이터 수집 & 성과 지표 계산

종목 목록 정의

1
2
3
4
5
6
7
8
9
10
11
tickers_info = {
    "069500": {"category": "국내주식", "expense_ratio": 0.15, "dividend_yield": 1.8,
               "keywords": ["코스피", "대형주", "인덱스", "분산투자", "국내주식"]},
    "379800": {"category": "해외주식", "expense_ratio": 0.05, "dividend_yield": 0.0,
               "keywords": ["미국", "S&P500", "대형주", "성장", "해외주식"]},
    "132030": {"category": "원자재",   "expense_ratio": 0.68, "dividend_yield": 0.0,
               "keywords": ["", "원자재", "인플레이션", "헤지", "안전자산"]},
    "214980": {"category": "채권",     "expense_ratio": 0.05, "dividend_yield": 3.2,
               "keywords": ["단기채", "안전", "예금대안", "저위험", "이자"]},
    # ... 총 12개 종목
}

가격 데이터 수집

1
2
3
4
5
def collect_etf_price(ticker, days=365):
    end_date   = datetime.now().strftime('%Y-%m-%d')
    start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
    df = fdr.DataReader(ticker, start_date, end_date)
    return {'status': 'real', 'data': df, 'ticker': ticker}

성과 지표 계산

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def compute_metrics(df):
    close   = df['Close'].ffill()
    ret     = close.pct_change().dropna()
    ann_ret = ((1 + ret.mean()) ** 252 - 1) * 100    # 연간 수익률 (%)
    ann_vol = ret.std() * np.sqrt(252) * 100           # 연간 변동성 (%)
    cum     = (1 + ret).cumprod()
    mdd     = ((cum - cum.cummax()) / cum.cummax()).min() * 100  # MDD (%)
    return {'return_1y': round(ann_ret, 2), 'volatility': round(ann_vol, 2), 'mdd': round(mdd, 2)}

def risk_from_vol(vol):
    if vol < 5:   return "낮음"
    if vol < 15:  return "약간 낮음"
    if vol < 25:  return "중간"
    return "높음"

ETF 데이터 통합

1
2
3
4
5
6
7
8
9
10
11
12
etf_data = []
for t, info in tickers_info.items():
    result  = collect_etf_price(t, days=365)
    metrics = compute_metrics(result['data'])
    name    = name_by_ticker.get(t, f"ETF_{t}")
    etf_data.append({
        "ticker": t, "name": name,
        **info,
        **metrics,
        'risk_level':   risk_from_vol(metrics['volatility']),
        'data_status':  result['status']
    })

실행 결과 예시:

1
2
3
4
{'ticker': '069500', 'name': 'KODEX 200', 'category': '국내주식',
 'expense_ratio': 0.15, 'dividend_yield': 1.8,
 'return_1y': 227.44, 'volatility': 36.58, 'mdd': -20.62,
 'risk_level': '높음', 'data_status': 'real'}

3. LLM으로 ETF 설명 생성 (Document 구성)

ETF 설명 생성

1
2
3
4
5
6
7
8
9
def generate_description(etf):
    prompt = f"""다음 ETF의 특징을 한국어 1~2문장으로 간결히 설명하세요.
    이름: {etf['name']}
    카테고리: {etf['category']}
    키워드: {','.join(etf['keywords'])}
    수수료: {etf['expense_ratio']}% / 배당수익률: {etf['dividend_yield']}%
    1년수익률: {etf['return_1y']}% / 변동성: {etf['volatility']}% / MDD: {etf['mdd']}%
    """
    return llm.invoke([{'role': 'user', 'content': prompt}]).content.strip()

Document 구성

RAG에서 검색 단위가 되는 Document 객체 생성.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from langchain_core.documents import Document

documents = []
for etf in etf_data:
    desc = generate_description(etf)

    # page_content: 검색에 사용될 텍스트
    text = (f"{etf['name']} ({etf['category']}): {desc} "
            f"키워드: {', '.join(etf['keywords'])}"
            f"수수료 {etf['expense_ratio']}%, 배당수익률: {etf['dividend_yield']}%"
            f"수익률: {etf['return_1y']}% / 변동성: {etf['volatility']}% / MDD: {etf['mdd']}%")

    # metadata: 필터링에 사용될 구조화 정보
    metadata = {k: v for k, v in etf.items() if k != "keywords"}
    metadata['keywords'] = ', '.join(etf['keywords'])

    documents.append(Document(page_content=text, metadata=metadata))

Document 구조:

1
2
page_content: "KODEX 200 (국내주식): KODEX 200은 코스피 대형주에 투자하는 국내주식 인덱스 ETF로 ..."
metadata:     {'ticker': '069500', 'category': '국내주식', 'expense_ratio': 0.15, ...}

page_content는 임베딩 검색/BM25 대상, metadata는 필터 조건으로 활용


4. FAISS 벡터스토어 구축

1
2
3
4
5
6
7
8
9
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import FAISS as LangchainFAISS

embeddings = OpenAIEmbeddings(model='text-embedding-3-small')

# 문서 임베딩 + 인덱스 생성 (원샷)
vectorstore = LangchainFAISS.from_documents(documents, embeddings)

vectorstore.index.ntotal  # → 12 (총 12개 ETF 문서)

벡터 검색

1
2
3
4
5
query = '미국 기술주에 투자하고 싶어요'
results = vectorstore.similarity_search_with_score(query, k=3)

# 결과: [(Document, score), ...]
# score: L2 거리 (낮을수록 유사)

실행 결과:

1
2
3
1. TIGER 미국필라델피아반도체나스닥  (score: 1.18) ← 가장 관련
2. KODEX 미국나스닥100             (score: 1.25)
3. ACE KRX금현물                  (score: 1.37) ← 배당 ETF이지만 "미국" 키워드 매칭

5. BM25 키워드 검색 (Kiwi 형태소 분석)

왜 한국어 형태소 분석이 필요한가?

단순 공백 분리로는 “배당수익률”, “배당” “수익” “률”로 분리 불가. Kiwi가 형태소 단위로 분리.

1
2
3
4
5
6
7
8
9
10
11
12
13
from kiwipiepy import Kiwi
from rank_bm25 import BM25Okapi

kiwi = Kiwi()

def kiwi_tokenize(text):
    tokens = kiwi.tokenize(text)
    result = []
    for token in tokens:
        # SL: 외래어/영어, SN: 숫자, NNG: 일반명사, NNP: 고유명사
        if token.tag in ("SL", "SN", "NNG", "NNP"):
            result.append(token.form.lower())
    return result

예시:

1
2
3
4
5
kiwi_tokenize("KODEX 200 ETF는 어떤 종류의 주식에 투자하나요?")
# → ['kodex', '200', 'etf', '종류', '주식', '투자']

kiwi_tokenize("배당수익률 3% 이상인 미국 ETF를 추천해주세요")
# → ['배당', '수익', '3', '이상', '미국', 'etf', '추천']

BM25 인덱스 구축

1
2
3
4
5
6
7
8
corpus = [kiwi_tokenize(doc.page_content) for doc in documents]
bm25   = BM25Okapi(corpus)

def bm25_search(query, k=5):
    tokens = kiwi_tokenize(query)
    scores = bm25.get_scores(tokens)
    top_idx = np.argsort(scores)[::-1][:k]
    return [(documents[i], scores[i]) for i in top_idx if scores[i] > 0]

벡터 검색 vs BM25 비교

1
2
3
4
5
쿼리              | 벡터 검색 결과        | BM25 결과
"KODEX 200"      | KODEX 200           | KODEX 200           ← 동일
"안전한 투자 상품"  | KODEX 단기채권PLUS   | KODEX 단기채권PLUS   ← 동일
"배당 ETF"        | ACE KRX금현물        | ACE KRX금현물        ← 동일
"인플레이션 방어"   | KIWOOM 국고채10년    | KODEX 골드선물(H)    ← 다름!

“인플레이션 방어”에서 벡터는 채권을 찾지만, 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
def filtered_search(vectorstore, query, filters=None, k=5, fetch_k=20):
    """
    filters 형식:
      {"category": "해외주식"}                           # 등호 조건
      {"expense_ratio": {"less_than": 0.5}}             # 범위 조건
      {"category": "해외주식", "expense_ratio": {...}}   # 복합 조건
    """
    results = vectorstore.similarity_search_with_score(query, fetch_k)
    if not filters:
        return results[:k]

    filtered = []
    for doc, score in results:
        match = True
        for key, condition in filters.items():
            val = doc.metadata.get(key)
            if isinstance(condition, dict):
                if "less_than"    in condition and val > condition["less_than"]:    match = False
                if "greater_than" in condition and val < condition["greater_than"]: match = False
            elif val != condition:
                match = False
        if match:
            filtered.append((doc, score))

    return filtered[:k]

사용 예

1
2
3
4
5
6
results = filtered_search(
    vectorstore,
    "저비용 해외 ETF",
    filters={"category": "해외주식", "expense_ratio": {"less_than": 0.5}},
    k=3
)

7. BM25 파라미터 튜닝 (k1, b)

1
2
3
4
5
6
7
8
9
10
11
query  = "배당 ETF"
tokens = kiwi_tokenize(query)
params = [(0.5, 0.25), (1.5, 0.75), (2.5, 0.9)]

for k1, b in params:
    bm25_temp = BM25Okapi(corpus, k1=k1, b=b)
    scores    = bm25_temp.get_scores(tokens)
    top3      = np.argsort(scores)[::-1][:3]
    print(f"k1={k1}, b={b}")
    for idx in top3:
        print(f"  [{scores[idx]:.3f}] {documents[idx].metadata['name']}")

결과: k1, b 값에 무관하게 상위 ETF 순서 동일 → 이 도메인에서는 기본값으로도 충분

파라미터 의미:

파라미터역할기본값
k1단어 빈도 포화점 (클수록 빈도 효과 강함)1.5
b문서 길이 정규화 (1.0 = 완전 정규화, 0 = 정규화 없음)0.75

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

  • generate_description을 루프에서 반복 호출: API 요금 누적, 속도 느림 → 결과를 캐싱하거나 배치 처리
  • Document metadata에 numpy float 타입 저장: FAISS 저장/로드 시 직렬화 오류 → float(metrics['return_1y']) 등 Python 기본 타입으로 변환
  • BM25 점수 0인 문서 포함: if scores[i] > 0 조건으로 관련 없는 문서 제거
  • similarity_search_with_score의 score: 벡터 거리(L2), 낮을수록 좋음 ↔ BM25 점수는 높을수록 좋음 → 두 점수 합산 시 정규화 필수

[보충] 금융 챗봇 전체 파이프라인

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
[ETF 금융 챗봇]

1. 데이터 수집
   └─ FinanceDataReader → 가격 데이터
   └─ tickers_info → 카테고리, 수수료, 키워드

2. 데이터 생성 (RAG용 Document)
   └─ compute_metrics() → 수익률, 변동성, MDD
   └─ generate_description() → LLM 설명 문구
   └─ Document(page_content, metadata)

3. 인덱싱
   └─ FAISS vectorstore (벡터 검색)
   └─ BM25Okapi (키워드 검색)

4. 검색 (사용자 쿼리)
   └─ filtered_search() → 조건 + 벡터 검색
   └─ bm25_search() → 키워드 검색
   └─ hybrid 결합 → 상위 문서 선별

5. 답변 생성
   └─ 상위 문서 컨텍스트 → GPT-4o-mini → 최종 답변

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

인기 태그