졸업 프로젝트

LangChain + FAISS + Redis 기반 RAG 챗봇 구현

jiheechoi 2026. 5. 25. 00:47

1. 들어가며

이 글은 CrossAlpha를 개발하면서 RAG 기반 챗봇을 구현한 경험을 정리한 글이다.

CrossAlpha는 투자자가 "왜 이 거래를 했는가"를 기록하고, 행동 패턴을 분석하여 투자 습관을 개선할 수 있도록 돕는 트레이딩 저널 및 AI 기반 투자 행동 분석 서비스이다. CrossAlpha의 종목 상세 페이지에는 차트 분석 AI 어시스턴트 기능이 있는데, 사용자가 현재 보유 종목의 기술적 지표(RSI, SMA, 볼린저 밴드 등)에 대해 자연어로 질문하면 AI가 답변해주는 기능이다.

단순히 LLM을 호출하는 것이 아니라, 기술적 지표 문서를 기반으로 검색하고 현재 종목의 실시간 데이터를 결합하여 답변하는 RAG(Retrieval-Augmented Generation) 구조로 구현했다. 여기에 Redis를 활용한 대화 히스토리 관리를 추가하여 이전 대화 맥락을 기억하는 챗봇을 만들었다.


2. 왜 RAG인가

단순히 LLM에 질문만 던지는 방식으로는 두 가지 문제가 있었다.

 

문제 1 — LLM이 기술적 지표를 틀리게 설명할 수 있다

RSI, 볼린저 밴드 같은 기술적 지표는 정확한 해석 기준이 있다. 하지만 LLM은 hallucination으로 잘못된 해석을 줄 수 있다. 예를 들어 RSI 70 이상이 과매수 구간이라는 기준을 틀리게 설명하거나, 볼린저 밴드 해석을 임의로 만들어낼 수 있다. 신뢰할 수 있는 문서를 기반으로 답변하도록 RAG 구조를 도입한 이유가 여기에 있다.

 

문제 2 — LLM은 현재 종목의 실시간 데이터를 모른다

"지금 NVDA RSI가 어때?"라고 물어봐도 LLM은 실시간 데이터를 알 수 없다. 이건 RAG로 해결할 수 있는 문제가 아니라, 매 요청마다 현재 종목의 기술적 지표 스냅샷을 직접 context로 주입하는 방식으로 해결했다.

 

이러한 문제들로 인해 결국 이 두 접근이 결합된 구조를 선택하였다. RAG로 문서 기반의 정확한 해석을 제공하고, 스냅샷 주입으로 실시간 종목 데이터를 반영하는 방식이다.


3. 개발 환경 및 설정

- 전체 스택

CrossAlpha의 백엔드는 다음과 같은 환경으로 구성되어 있다.

 

백엔드 FastAPI (Docker 컨테이너)
배포 Railway
DB PostgreSQL (Railway)
대화 히스토리 Redis (Railway)
LLM GPT-4o-mini (OpenAI)
벡터스토어 FAISS (로컬 파일)

 

Railway 위에 FastAPI Docker 컨테이너, PostgreSQL, Redis가 같은 프로젝트 안에서 내부 네트워크로 연결되어 있다.

- Railway에 Redis 서비스 추가하기

Railway 대시보드에서 아래 순서로 Redis를 추가했다.

 

1. 프로젝트 대시보드 → Add 클릭

 

2. Database Redis 선택

대시보드에 Redis 추가 완료!

 

3. 추가 완료 후 FastAPI 서비스 → Variables 탭 → Add Variable로 REDIS_URL 추가

Redis variables에서 REDIS_URL값 복붙하기

 

Railway 내부에서 서비스끼리 통신할 때는 private endpoint를 사용한다. Redis를 추가하면 두 가지 URL이 생성된다.

 
📌 변수 용도

 

REDIS_URL Railway 내부 통신 (배포 환경) — 무료
REDIS_PUBLIC_URL 외부 접근 (로컬 개발 환경) — 소량 비용 발생

 

배포 환경에서는 Railway Variables에 설정한 REDIS_URL이 자동으로 주입되고, 로컬 개발 환경에서는 .env 파일에 REDIS_PUBLIC_URL 값을 REDIS_URL로 넣어서 사용했다.

 

- 패키지 설치

requirements.txt에 아래 패키지를 추가했다.

redis
langchain-redis

 

로컬에서도 설치했다.

pip install redis langchain-redis

4. 전체 구조

사용자 질문
    ↓
FAISS에서 관련 기술적 지표 문서 검색 (Retrieval)
    +
현재 종목 기술적 지표 스냅샷 주입 (RSI, SMA, 볼린저 밴드 등)
    +
Redis에서 이전 대화 히스토리 로드
    ↓
LLM (GPT-4o-mini)
    ↓
답변 생성 + Redis에 대화 저장

5. 기술적 지표 문서 벡터스토어 구성하기

RAG의 핵심은 검색할 문서를 벡터로 변환해서 저장하는 벡터스토어다. CrossAlpha에서는 RSI, SMA, 볼린저 밴드, 복합 신호 해석 문서를 마크다운 형태로 작성하고 FAISS에 저장했다.

# build_vectorstore.py
from pathlib import Path
from langchain_community.vectorstores import FAISS
from langchain_core.documents import Document
from langchain_openai import OpenAIEmbeddings

DOCS_DIR = Path("rag/docs")
SAVE_DIR = "rag/vectorstore"

def build_vectorstore():
    docs = []
    for path in DOCS_DIR.glob("*.md"):
        content = path.read_text(encoding="utf-8")
        docs.append(
            Document(
                page_content=content,
                metadata={"source": path.name},
            )
        )

    embeddings = OpenAIEmbeddings(api_key="YOUR_API_KEY")
    vectorstore = FAISS.from_documents(docs, embeddings)
    vectorstore.save_local(SAVE_DIR)

if __name__ == "__main__":
    build_vectorstore()

 

 문서를 추가하거나 수정할 때마다 이 스크립트를 실행해서 벡터스토어를 다시 빌드했다.


6. 종목 스냅샷 + RAG 결합한 체인 만들기

LangChain의 RunnableParallel을 활용해서 세 가지 context를 병렬로 준비하고 LLM에 넘겼다.

chain = (
    RunnableParallel(
        {
            # 현재 종목 기술적 지표 스냅샷
            "snapshot_context": RunnableLambda(_build_snapshot_context),

            # FAISS에서 관련 문서 검색
            "retrieved_context": (
                RunnableLambda(lambda x: x["question"])
                | retriever
                | RunnableLambda(_format_docs)
            ),

            # 사용자 질문
            "question": RunnableLambda(lambda x: x["question"]),

            # Redis에서 대화 히스토리 로드
            "chat_history": RunnableLambda(lambda x: history.messages),
        }
    )
    | prompt
    | llm
    | StrOutputParser()
)

 

→ RunnableParallel을 쓰면 스냅샷 가져오기와 문서 검색이 동시에 실행되어 응답 속도가 빨라진다.

 

스냅샷은 이런 형태로 구성했다.

def _build_snapshot_context(inputs: dict) -> str:
    snapshot = get_technical_summary_with_llm(ticker=ticker, rng=range_)
    return f"""
ticker: {snapshot.ticker}
rsi14: {snapshot.indicators.rsi14}
sma20: {snapshot.indicators.sma20}
sma50: {snapshot.indicators.sma50}
bollinger_upper: {snapshot.indicators.bollinger.upper}
bollinger_middle: {snapshot.indicators.bollinger.middle}
bollinger_lower: {snapshot.indicators.bollinger.lower}
""".strip()

7. Redis로 대화 히스토리 관리하기

단발성 질답이 아니라 이전 대화 맥락을 기억하는 챗봇을 만들기 위해 Redis를 도입했다.

LangChain의 RedisChatMessageHistory를 사용하면 히스토리 저장/로드를 자동으로 처리해준다.

from langchain_redis import RedisChatMessageHistory

history = RedisChatMessageHistory(
    session_id=session_id,
    redis_url=REDIS_URL,
    ttl=60 * 60 * 24 * 7,  # 7일
)

# 히스토리 로드 (자동으로 Redis에서 가져옴)
print(history.messages)

# 답변 후 저장
history.add_user_message(question)
history.add_ai_message(answer)

 

 TTL을 7일로 설정하면 7일간 대화가 없으면 자동으로 만료된다.


8. session_id로 사용자/종목별 대화 분리하기

여러 사용자가 여러 종목을 동시에 사용하기 때문에 대화가 섞이지 않도록 session_id로 분리했다. CrossAlpha에서는 {user_id}:{ticker} 조합으로 session_id를 구성했다.

 
📌 session_id의미

 

42:NVDA user 42번의 NVDA 대화
42:TSLA user 42번의 TSLA 대화
99:NVDA user 99번의 NVDA 대화

 

프론트엔드에서 이렇게 생성해서 매 요청에 포함시킨다.

 
const sessionId = `${userId}:${ticker}`

await fetch(`/api/tickers/${ticker}/assistant`, {
    method: "POST",
    body: JSON.stringify({
        question: question,
        range: "3M",
        session_id: sessionId,
    })
})

10. 테스트

멀티턴이 정상 작동하는지 확인하기 위해 스냅샷에 없는 정보를 첫 번째 대화에서 언급하고, 두 번째에서 기억하는지 확인해보았다. 

 

📌 테스트용 request body 작성

// 첫 번째 질문
{
    "question": "현재 RSI 기준으로 과매수 구간까지 몇 포인트 남았어?",
    "range": "3M",
    "session_id": "test:NVDA"
}

// 두 번째 질문 — 히스토리 없이는 답할 수 없음
{
    "question": "방금 말한 포인트 수치가 정확히 얼마였지?",
    "range": "3M",
    "session_id": "test:NVDA"
}

 

📌 테스트 결과

첫 번째 요청에 대한 실행 결과
두 번째 요청에 대한 실행 결과

 

두 번째 질문에서 첫 번째 답변의 수치를 정확히 기억하고 답하는 것을 확인하였다!