[AI] LLM 04


HuggingFace

LLM 04 - RAG (Retrieval-Augmented Generation)

Created: August 27, 2026 4:06 PM Class: LLM

RAG (Retrieval-Augmented Generation)

1. RAG 원리

정의: LLM이 답변을 생성하기 전에 외부 지식 소스에서 관련 정보를 검색(Retrieval)하여 프롬프트에 포함시키는 방식.

필요성

문제RAG의 해결
LLM의 지식 컷오프최신/외부 문서를 실시간 주입
환각(Hallucination)검색된 근거 문서 기반 답변
도메인 특화 지식 부족사내 문서, 전문 자료 검색
Fine-tuning 비용파라미터 재학습 없이 지식 확장

기본 파이프라인

[문서] → 청크 분할 → 임베딩 → 벡터DB 저장
                                    ↓
[질문] → 임베딩 → 유사도 검색 → 관련 청크 반환 → 프롬프트 조합 → LLM → 답변

단계 구분

  • 인덱싱(Indexing): 문서 로드 → 청크 분할 → 임베딩 → 벡터DB 저장 (사전 작업, 1회성)
  • 검색·생성(Retrieval + Generation): 질문 임베딩 → 유사 청크 검색 → 프롬프트 구성 → LLM 호출 (매 질의마다)

2. 청크(Chunk) 만들기

목적: 긴 문서를 LLM 컨텍스트/임베딩 모델 입력 크기에 맞게 분할하고, 검색 정확도를 높임.

분할 전략 비교

방법특징적합한 경우
CharacterTextSplitter고정 길이(글자 수)로 단순 분할구조 없는 텍스트
RecursiveCharacterTextSplitter문단→문장→단어 순으로 재귀 분할, 의미 보존 우선대부분의 일반 문서 (기본값)
TokenTextSplitter토큰 수 기준 분할임베딩/LLM 토큰 제한 정밀 대응
Markdown/HTML Splitter헤더·구조 기반 분할구조화 문서(노션, 위키)

핵심 파라미터

  • chunk_size: 청크 하나의 최대 길이
  • chunk_overlap: 인접 청크 간 중복 길이 (문맥 단절 방지)
from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,
    chunk_overlap=50,
)
chunks = splitter.split_documents(docs)

튜닝 원칙

  • chunk_size가 너무 크면 → 검색 정밀도 하락, 불필요한 정보 포함
  • chunk_size가 너무 작으면 → 문맥 손실, 검색 결과 파편화
  • chunk_overlap은 보통 chunk_size의 10~20%

3. 벡터DB 다루기 (ChromaDB)

역할: 임베딩 벡터를 저장하고, 질의 벡터와의 유사도로 근접 벡터를 빠르게 검색.

ChromaDB 기본 사용 (LangChain 연동)

from langchain_chroma import Chroma
from langchain_ollama import OllamaEmbeddings
# 또는 from langchain_openai import OpenAIEmbeddings

embeddings = OllamaEmbeddings(model="bge-m3")  # 로컬 임베딩 모델 예시

vectorstore = Chroma.from_documents(
    documents=chunks,
    embedding=embeddings,
    persist_directory="./chroma_db",   # 로컬 영속 저장
    collection_name="my_docs",
)

주요 개념

개념설명
Collection벡터DB 내 문서 그룹 단위 (테이블과 유사)
persist_directory디스크에 영속 저장 (재시작 시 재사용)
add_documents()기존 컬렉션에 문서 추가
delete()특정 문서/조건 삭제
metadata filterwhere={"source": "파일명"} 등으로 검색 범위 제한

Ollama 임베딩 모델 (RTX 4070 로컬 환경)

  • bge-m3, nomic-embed-text 등 경량 임베딩 모델이 실용적
  • OpenAI 임베딩(text-embedding-3-small) 대비 로컬은 속도/비용 이점, 품질은 상대적으로 낮을 수 있음

4. 의미기반 검색(Semantic Search) 방법

원리: 텍스트를 임베딩 벡터로 변환 후, 벡터 간 거리(유사도)로 의미적 유사성을 계산. 키워드 일치가 아닌 “뜻”의 유사성 기반.

유사도 계산 방식

방식설명
Cosine Similarity벡터 방향 유사도 (가장 일반적)
Euclidean Distance (L2)벡터 간 직선 거리
Dot Product내적 기반 (정규화된 벡터에서 코사인과 동치)

LangChain 검색 방법

# 1) 단순 유사도 검색
results = vectorstore.similarity_search(query, k=4)

# 2) 점수 포함 검색
results_with_score = vectorstore.similarity_search_with_score(query, k=4)

# 3) Retriever로 변환 (체인에 연결하기 위한 표준 인터페이스)
retriever = vectorstore.as_retriever(
    search_type="similarity",       # "mmr" 등도 가능
    search_kwargs={"k": 4},
)

검색 방식 비교

search_type특징
similarity단순 최근접 이웃 검색
mmr (Maximal Marginal Relevance)유사도 + 다양성 고려, 중복 청크 방지
similarity_score_threshold임계값 이상 결과만 반환

키워드 검색과의 차이

구분키워드 검색 (BM25 등)의미기반 검색
매칭 기준단어 일치벡터 유사도
동의어/문맥처리 못함처리 가능
정확한 고유명사강함약할 수 있음
하이브리드BM25 + 벡터 검색 결합해 보완 가능 

5. LLM 프롬프트에 LangChain으로 합치기 (RAG 체인 구성)

LCEL(LangChain Expression Language)로 RAG 체인 구성

from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
from langchain_ollama import ChatOllama

def format_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs)

prompt = ChatPromptTemplate.from_template("""
다음 컨텍스트를 바탕으로 질문에 답하세요. 컨텍스트에 없는 내용은 모른다고 답하세요.

[컨텍스트]
{context}

[질문]
{question}
""")

llm = ChatOllama(model="exaone3.5:7.8b", temperature=0)

rag_chain = (
    {"context": retriever | format_docs, "question": RunnablePassthrough()}
    | prompt
    | llm
    | StrOutputParser()
)

answer = rag_chain.invoke("질문 내용")

체인 흐름

질문 → retriever(검색) → format_docs(문자열화) ─┐
질문 → RunnablePassthrough (그대로 통과)  ───────┤→ prompt → llm → 문자열 출력

핵심 포인트

  • | 연산자로 각 단계를 파이프라인처럼 연결 (모델 종류와 무관하게 동일한 문법)
  • RunnablePassthrough: 입력값을 변형 없이 다음 단계로 전달할 때 사용
  • 딕셔너리 형태 {"context": ..., "question": ...}는 병렬로 실행되어 prompt의 변수로 매핑됨

6. 검색하기 (Retrieval 심화)

Retriever를 체인에서 활용하는 패턴

retriever = vectorstore.as_retriever(search_kwargs={"k": 4})

# 검색만 단독 실행
retrieved_docs = retriever.invoke("질문")

검색 품질 개선 기법

기법설명
Query 재작성LLM으로 질문을 검색에 유리한 형태로 변환 후 검색
MMR유사하지만 중복되는 문서 대신 다양한 문서 확보
Metadata Filtering특정 파일/날짜/카테고리로 검색 범위 제한
Re-ranking1차 검색 결과를 별도 모델로 재정렬해 정밀도 향상
Multi-Query Retriever질문을 여러 버전으로 변형해 검색 후 결과 합치기

k값(반환 문서 수) 튜닝

  • k가 작으면 → 정보 누락 가능성
  • k가 크면 → 프롬프트 길이 증가, 노이즈 포함, 비용/지연 증가

7. 채팅으로 구현하기 (대화형 RAG)

요구사항: 이전 대화 맥락을 반영해 후속 질문(“그건 왜?”)도 올바르게 검색·응답해야 함.

대화 이력 반영 구조

1) 이전 대화 + 현재 질문 → 독립형 질문으로 재구성 (Contextualize)
2) 재구성된 질문 → 검색 (Retrieval)
3) 검색 결과 + 대화 이력 + 질문 → LLM 답변 생성

LangChain 구현 예시 (개념 흐름)

from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain.chains import create_history_aware_retriever, create_retrieval_chain
from langchain.chains.combine_documents import create_stuff_documents_chain

# 1) 대화 맥락 반영해 질문 재구성하는 retriever
contextualize_prompt = ChatPromptTemplate.from_messages([
    ("system", "이전 대화를 참고하여 질문을 독립적으로 이해 가능한 형태로 재작성하세요."),
    MessagesPlaceholder("chat_history"),
    ("human", "{input}"),
])
history_aware_retriever = create_history_aware_retriever(llm, retriever, contextualize_prompt)

# 2) 답변 생성 체인
qa_prompt = ChatPromptTemplate.from_messages([
    ("system", "다음 컨텍스트를 바탕으로 답하세요.\n\n{context}"),
    MessagesPlaceholder("chat_history"),
    ("human", "{input}"),
])
qa_chain = create_stuff_documents_chain(llm, qa_prompt)

rag_chat_chain = create_retrieval_chain(history_aware_retriever, qa_chain)

대화 이력 관리

  • chat_history: HumanMessage/AIMessage 리스트로 누적 관리
  • 이력이 길어지면 토큰 초과 → 최근 N턴만 유지하거나 요약(Summary Memory) 필요

체인 유형 비교

구성 요소역할
create_history_aware_retriever대화 맥락 반영해 검색용 질문 재구성 후 검색
create_stuff_documents_chain검색된 문서를 프롬프트에 “그대로 채워(stuff)” LLM 호출
create_retrieval_chain위 두 체인을 연결해 최종 RAG 체인 완성

요약 흐름도

문서 → 청크 분할 → 임베딩 → ChromaDB 저장          (인덱싱, 1회)
                                    ↓
질문(+대화이력) → 질문 재구성 → 의미기반 검색(Retriever)
                                    ↓
                검색 결과 + 질문 → 프롬프트 조합(LCEL) → LLM → 답변

RAG 실습

웹페이지 가져오기

# 웹페이지 로더 임포트
from langchain_community.document_loaders import WebBaseLoader

# 가져올 웹페이지 URL 지정
url = "https://namu.wiki/w/%EC%9E%84%ED%8E%98%EB%A6%AC%EC%96%BC%20%EC%B9%BC%EB%A6%AC%EC%A7%80%20%EB%9F%B0%EB%8D%98"
loader = WebBaseLoader(url)

# 웹페이지를 문서(Document) 객체 리스트로 로드
docs = loader.load()

# 첫 번째 문서의 본문 텍스트 출력
print(docs[0].page_content)
  • WebBaseLoader는 URL을 받아 HTML을 요청하고, BeautifulSoup으로 파싱해서 텍스트를 추출하는 로더입니다.
  • loader.load()Document 객체의 리스트를 반환합니다. 각 Documentpage_content(본문 텍스트)와 metadata(source URL 등)를 가집니다.
  • 페이지가 하나면 보통 docs의 길이는 1이라, docs[0].page_content로 전체 본문을 확인합니다.
  • 실제 RAG 파이프라인에서는 이 docs를 다음 단계인 RecursiveCharacterTextSplitter로 청크 분할 → 임베딩 → ChromaDB 저장으로 이어가면 됩니다.

참고: 나무위키처럼 HTML 구조가 복잡한 페이지는 광고, 네비게이션, 각주 등 불필요한 텍스트가 섞여 들어올 수 있어서, 필요하면 bs_kwargs로 특정 태그만 파싱하도록 제한하는 것도 방법입니다.


웹페이지 가져온 것 청크로 나누기

# 웹페이지 로더와 텍스트 스플리터 임포트
from langchain_community.document_loaders import WebBaseLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter

# 가져올 웹페이지 URL 지정
url = "https://namu.wiki/w/%EC%9E%84%ED%8E%98%EB%A6%AC%EC%96%BC%20%EC%B9%BC%EB%A6%AC%EC%A7%80%20%EB%9F%B0%EB%8D%98"
loader = WebBaseLoader(url)

# 웹페이지를 문서(Document) 객체 리스트로 로드
docs = loader.load()

# 청크 크기 500, 겹치는 구간 200으로 스플리터 설정
text_splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=200)
print(text_splitter)

# 로드한 문서를 청크 단위로 분할
splited_docs = text_splitter.split_documents(docs)

# 전체 청크 개수 확인
print(len(splited_docs))

# 11번째 청크(인덱스 10)의 내용 출력
print(splited_docs[10].page_content)
  • RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=200): 문단 → 문장 → 단어 순으로 재귀적으로 나누되, 청크 하나당 최대 500자, 인접 청크끼리 200자를 겹쳐서 문맥 단절을 줄입니다.
  • chunk_overlapchunk_size의 40%(200/500)로 꽤 큰 편이라, 청크 수가 늘고 저장 용량·검색 시 중복도 늘어난다는 점은 감안해야 합니다. (일반적으로는 10~20% 정도를 권장)
  • split_documents(docs)docs 리스트의 각 Document를 순회하며 텍스트를 분할하고, 원본 metadata(source URL 등)를 각 청크에 그대로 복사해 붙입니다.
  • splited_docs는 분할된 Document 객체 리스트이며, len()으로 총 청크 수, [10].page_content로 특정 청크의 텍스트를 확인할 수 있습니다.

의미기반 검색 해보기

"""
Context language : Korean
Embedding Model : Ollama / bge-m3 (한국어 특화)
"""
# 웹페이지 가져오기 -> 청크 나누기 -> 임베딩하여 ChromaDB에 저장 -> 의미기반 검색하기
from langchain_community.document_loaders import WebBaseLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.vectorstores import Chroma

# 뉴스 기사 URL 지정
url = "https://n.news.naver.com/mnews/article/016/0002688935"
loader = WebBaseLoader(url)

# 웹페이지를 문서(Document) 객체 리스트로 로드
docs = loader.load()

# 청크 크기 1000, 겹치는 구간 200으로 분할
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
splited_docs = text_splitter.split_documents(docs)

# 청크를 임베딩하여 벡터화 후 ChromaDB에 저장
from langchain_ollama import OllamaEmbeddings
vectorstore = Chroma.from_documents(
    documents=splited_docs,
    embedding=OllamaEmbeddings(
        model="bge-m3",
        base_url="http://127.0.0.1:11434"
    ),
    collection_name='ollama-kr'
)

# 질문과 의미적으로 유사한 청크 검색
docs = vectorstore.similarity_search("엔비디아 올해 실적 찾아줘")

# 질문과 관련된 chunk를 반환
# Chroma는 k=4가 디폴트 값이니 4개의 chunk를 반환
print(len(docs))
for i in range(len(docs)):
    print(docs[i].page_content)
    print("=" * 80)

설명

  • 전체 흐름은 앞서 만든 웹 로더 + 스플리터에 임베딩·검색 단계가 붙은 형태입니다: 문서 로드 → 청크 분할 → 임베딩·저장 → 검색.
  • OllamaEmbeddings(model="bge-m3", base_url="http://127.0.0.1:11434"): 로컬에서 실행 중인 Ollama 서버의 bge-m3 모델로 각 청크를 벡터로 변환합니다. bge-m3는 다국어(한국어 포함) 임베딩에 강한 모델입니다.
  • Chroma.from_documents(...): 청크 리스트를 임베딩과 함께 ChromaDB에 저장하면서 동시에 벡터스토어 객체를 생성합니다. persist_directory를 지정하지 않았으므로 이 인스턴스는 메모리 기반이며, 프로세스 종료 시 사라집니다.
  • collection_name='ollama_kr': 같은 DB 안에서도 컬렉션 단위로 데이터를 구분해 저장/조회할 수 있습니다.
  • vectorstore.similarity_search(query): 질문 문자열을 같은 임베딩 모델로 벡터화한 뒤, 코사인 유사도 기준으로 가장 가까운 청크들을 반환합니다. k 파라미터를 안 주면 기본값 4개가 반환됩니다.
  • 마지막 반복문은 검색된 4개 청크의 본문을 순서대로 출력해, 질문(“엔비디아 올해 실적”)과 실제로 의미적으로 연관된 내용이 뽑혔는지 눈으로 확인하는 용도입니다.

참고: langchain_community.vectorstores.Chroma는 최근 langchain_chroma.Chroma로 이전되는 추세라, 마이그레이션 경고가 뜰 수 있습니다. 지금 코드는 그대로 동작하지만 참고해두시면 좋습니다.


RAG처리와 아닌 것과 비교

"""
OpenAI는 임베딩과 채팅 모델의 성능 자체가 워낙 우수해서 RAG 없이도 어느정도 답변을 잘 하기 때문에
Ollama로 연습하는 것을 추천. 하지만 OpenAI의 자원들의 사용법 정도만 익히고 가면 좋을 것 같음.
"""
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
from langchain_community.document_loaders import WebBaseLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.vectorstores import Chroma
from langchain_ollama import ChatOllama, OllamaEmbeddings

# 테스트할 네이버 뉴스 기사 URL 지정
url = "https://n.news.naver.com/mnews/article/008/0005405374"

# 웹페이지를 문서(Document) 객체 리스트로 로드
loader = WebBaseLoader(url)
docs = loader.load()

# 청크 크기 1000, 겹치는 구간 200으로 분할
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
splited_docs = text_splitter.split_documents(docs)

# 청크를 임베딩하여 벡터화 후 ChromaDB에 저장
vectorstore = Chroma.from_documents(
    documents=splited_docs,
    embedding=OllamaEmbeddings(
        model="bge-m3",
        base_url='http://localhost:11434'
    ),
    collection_name='ollama-kr'
)

# 검색된 컨텍스트와 질문을 삽입할 프롬프트 템플릿 정의
prompt_template = """
질문은 다음 내용을 기본으로 하고 있어 : {context}

질문 : {question}
"""
prompt = ChatPromptTemplate.from_template(prompt_template)

# 답변 생성에 사용할 로컬 LLM 설정
llm = ChatOllama(
    model="exaone3.5:7.8b",
    base_url="http://localhost:11434",
    temperature=0,
    repeat_penalty=1.3
)

# 벡터스토어를 검색기(retriever)로 변환
retriever = vectorstore.as_retriever()

# 검색된 청크들을 하나의 문자열로 합치는 함수
def format_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs)

# RAG 체인 구성: 질문 -> 검색(retriever) -> 프롬프트 구성 -> LLM 답변 -> 문자열 파싱
chain = (
    {"context": retriever | format_docs, "question": RunnablePassthrough()}
    | prompt
    | llm
    | StrOutputParser()
)

# 테스트 질문
question = "엔비디아에 HBM을 공급하는 메모리 회사는?"

print("===============================")
print("RAG 없이 LLM 단독 답변===>")
chain2 = llm | StrOutputParser()
print(chain2.invoke(question))  # RAG 없이 LLM에 바로 질문

print("===============================")
print("RAG 적용 후 답변==>")
print(chain.invoke(question))  # RAG로 컨텍스트 넣고 질문
===============================
RAG 없이 LLM 단독 답변===>
현재 엔비디아(NVIDIA)의 고성능 그래픽 및 데이터 센터 솔루션에서 주로 사용되고 있으며 직접적으로 언급된 주요 공급자로 **SK하이닉스**가 알려져 있습니다. 특히, SK하이닉스사의 **[HBM (High Bandwidth Memory), 예를 들어 HBM2E 또는 최신 세대 모델]**은 엔비디아의 고성능 GPU에 공급됩니다.*

그러나 업계 동향과 계약 상황이 빠르게 변화할 수 있으므로 가장 정확한 정보는 공식 발표나 각 기업의 최근 보고서 확인을 권장드립니다. 추가적인 업데이트가 필요하시다면 관련 뉴스 출처를 참고해 보시길 바랍니다!
===============================
RAG 적용 후 답변==>
엔비디아에게 고대역폭메모리(HBM)를 공급하여 협력하고 있으며 긍정적인 영향이 예상되고 있다고 언급된 주요 기업들은 다음과 같습니다:

1. **삼성전자** - 한국의 대표 메모리 회사 중 하나입니다 (사진에서는 명시적으로 삼성전자만 나열했지만, 문맥상 포함됩니다).
2. **SK하이닉스**  - 엔비디아와 직접적인 협력 관계를 언급하며 차세대 메모리 공급에 대해 이야기되었습니다 ("글로벌 AI 팩토리 확대 필요한 차세대 메모리"). 
3. **마이크론 (Micron)** - 미국 기반의 또 다른 주요 메모리 제조업체로, 문맥에서도 함께 HBM 수요 증가 혜택을 받는 기업으로 나열됩니다."
  • 앞선 코드와 인덱싱(로드→청크→임베딩→ChromaDB 저장) 부분은 동일하고, 여기서는 그 위에 프롬프트·LLM·비교 로직이 추가됩니다.
  • retriever = vectorstore.as_retriever(): k를 따로 지정하지 않았으므로 기본값 4개 청크를 반환하는 검색기로 변환합니다.
  • chain: LCEL로 구성한 RAG 체인입니다. {"context": retriever | format_docs, "question": RunnablePassthrough()}가 질문을 입력받아 두 갈래로 처리합니다 — 한쪽은 retriever로 검색 후 format_docs로 하나의 문자열로 합치고, 다른 한쪽은 질문 원문을 그대로 통과시킵니다. 이 두 결과가 prompt{context}, {question}에 매핑됩니다.
  • chain2 = llm | StrOutputParser(): 검색 단계 없이 LLM에 질문만 바로 넣는 비교용 체인입니다. 모델이 이 뉴스 기사를 학습 데이터로 알고 있지 않다면 정확히 답하지 못하거나 모른다고 답할 가능성이 높습니다.
  • 두 체인을 같은 질문으로 실행해 출력을 나란히 비교하면, RAG가 실제로 답변 정확도에 기여하는지를 직접 확인할 수 있습니다 — 이게 이 코드의 핵심 목적입니다.
  • repeat_penalty=1.3: 같은 단어·구문 반복 생성을 억제하는 파라미터로, temperature=0(결정적 출력)과 함께 반복 답변을 방지하기 위해 추가된 것으로 보입니다.

RAG처리

text 파일 기반

./data/test.txt
우주,지구,대한민국에서 제일 잘생긴 사람은 이재용 회장님이다. 명심해라.
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
from langchain_community.document_loaders import WebBaseLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings
from langchain_community.document_loaders import TextLoader

# txt 파일을 로드
loader = TextLoader("./test.txt", encoding="utf-8")
docs = loader.load()

# 로드한 텍스트를 청크 단위로 분할 (1000자 기준, 200자 겹침)
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
splited_docs = text_splitter.split_documents(docs)

# vectorstore = Chroma.from_documents(documents = splited_docs, embedding = OpenAIEmbeddings())
# 청크를 임베딩하여 벡터화 후 ChromaDB에 저장 (한글이라 bge-m3 사용, Ollama라 무료)
from langchain_ollama import OllamaEmbeddings
vectorstore = Chroma.from_documents(
    documents=splited_docs,
    embedding=OllamaEmbeddings(
        model="bge-m3",
        base_url="http://127.0.0.1:11434"
    ),
    collection_name='ollama-kr'
)

# 컨텍스트 안의 내용만 사실로 간주하고, 부연설명 없이 단정적으로 답하도록 지시하는 프롬프트
prompt_template = """
너는 주어진 context만을 유일한 사실로 간주하고 답변하는 챗봇이다.
당위성, 객관성, 다양성에 대한 언급이나 추가 설명, 반박, 주의사항을 절대 덧붙이지 마라.
context에 있는 내용을 있는 그대로, 짧고 단정적으로 답하라.

context: {context}

질문: {question}

답변:
"""
prompt = ChatPromptTemplate.from_template(prompt_template)

# 모델 지정 : openAI / lmstudio / ollama
# llm= ChatOpenAI(model="gpt-3.5-turbo",temperature=0.7, frequency_penalty=0.5,presence_penalty=0.5)
# llm = ChatOpenAI(base_url="http://localhost:1234/v1",api_key="lm-studio",temperature=0.7, frequency_penalty=0.5,presence_penalty=0.5)
from langchain_ollama import ChatOllama
llm = ChatOllama(
    model="exaone3.5:2.4b",
    base_url="http://127.0.0.1:11434",  # 서버 IP로 변경
    temperature=0.7,
    repeat_penalty=1.3
)

# 벡터저장소를 검색기(retriever)로 변환
retriever = vectorstore.as_retriever()

# 검색된 청크들을 청크 사이 빈 줄 하나씩 두고 하나의 문자열로 합치는 함수
def format_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs)

# RAG 체인: 질문 -> 검색(retriever) -> 프롬프트 구성 -> LLM 답변 -> 문자열 파싱
chain = (
    {"context": retriever | format_docs, "question": RunnablePassthrough()}
    | prompt
    | llm
    | StrOutputParser()
)

question = "한국에서 가장 잘 생긴 사람은?"

print("===============================")
print("LLM이 상상의 나래를 편다. 대충 뭔가 대답한다===>")
chain2 = llm | StrOutputParser()
print(chain2.invoke(question))  # chain2는 그냥 LLM으로 직접 질문

print("===============================")
print("RAG 수행후==>")
print(chain.invoke(question))  # chain은 RAG 후 LLM으로 질문
  • 앞선 예제들과 달리 WebBaseLoader 대신 TextLoader("./test.txt", encoding="utf-8")로 로컬 텍스트 파일을 직접 로드합니다. 인코딩을 명시하지 않으면 한글 파일에서 깨짐이 발생할 수 있어 encoding="utf-8"이 중요합니다.
  • 청크 분할·임베딩·ChromaDB 저장 로직은 웹페이지 버전과 동일한 구조를 그대로 사용합니다 — 로더만 바뀌면 이후 파이프라인은 재사용 가능하다는 걸 보여줍니다.
  • 이번 프롬프트는 이전 예제보다 훨씬 강한 제약을 겁니다: “context만을 사실로 간주”, “부연설명·반박·주의사항 금지”, “짧고 단정적으로” — 모델이 컨텍스트 밖 지식으로 답을 부풀리거나 안전 경고를 덧붙이는 걸 억제하려는 의도입니다.
  • 질문 "한국에서 가장 잘 생긴 사람은?"은 일반적으로 LLM이 답하기 꺼리거나 모호하게 답할 소재라, chain2(RAG 없음)와 chain(RAG 있음)의 답변 차이를 비교하는 게 이 코드의 핵심입니다 — test.txt에 특정 인물이 언급돼 있다면 RAG 버전은 그 내용을 근거로 단정적으로 답하고, RAG 없는 버전은 답을 회피하거나 일반론을 늘어놓을 가능성이 높습니다.
  • frequency_penalty/presence_penalty는 OpenAI 모델 전용 파라미터라 주석 처리된 ChatOpenAI 코드에서만 의미가 있고, 실제 사용 중인 ChatOllama에는 해당 파라미터가 없어 repeat_penalty로 대체되어 있습니다.
  • import는 되어 있지만 실제로 쓰이지 않는 것들이 있습니다: ChatOpenAI, WebBaseLoader, OpenAIEmbeddings. 실행에는 문제없지만 정리하면 코드가 더 깔끔해집니다.

디렉토리 내 모든 파일 처리

from langchain_community.document_loaders import DirectoryLoader

# 지정한 폴더 안의 파일들을 한 번에 로드
loader = DirectoryLoader(path="./data/rag/", glob="*.txt")
docs = loader.load()

# 첫 번째 문서의 본문 텍스트 출력
print(docs[0].page_content)
  • DirectoryLoader는 폴더 경로(path)를 받아, glob 패턴에 맞는 파일들을 한 번에 순회하며 로드하는 로더입니다.
  • glob="*.txt": ./data/rag/ 폴더 바로 아래에 있는 .txt 파일만 대상으로 합니다 (하위 폴더는 기본적으로 포함 안 됨. 포함하려면 glob="**/*.txt" 필요).
  • TextLoader를 파일 하나마다 직접 호출하는 대신, 폴더 단위로 여러 문서를 자동으로 순회·로드할 때 유용합니다.
  • 반환값 docsDocument 객체 리스트이며, 파일 개수만큼 원소가 생깁니다. 각 Documentmetadata["source"]에 원본 파일 경로가 담깁니다.
  • 내부적으로 파일마다 기본 로더(텍스트는 TextLoader)를 사용하므로, 인코딩 문제가 있다면 loader_kwargs={"encoding": "utf-8"} 같은 옵션을 추가로 넘길 수 있습니다.

PDF 파일 기반

import pymupdf
from langchain_core.documents import Document

pdf_filepath = './assets/docs/ISTQB Certified Tester.pdf'

# PDF를 페이지 단위로 읽어 각 페이지를 Document 객체로 변환
def load_pdf(path):
    doc = pymupdf.open(path)
    docs = []
    for i, page in enumerate(doc):  # type: ignore
        text = page.get_text()
        if text.strip():
            docs.append(Document(page_content=text, metadata={"source": path, "page": i}))
    doc.close()
    return docs

pages = load_pdf(pdf_filepath)

len(pages)
  • pymupdf.open(path)로 PDF를 열고, for i, page in enumerate(doc)로 페이지를 하나씩 순회합니다. page.get_text()가 해당 페이지의 텍스트를 추출합니다.
  • if text.strip(): 텍스트가 없는 빈 페이지(이미지만 있는 페이지 등)는 건너뛰고, 내용이 있는 페이지만 Document로 만듭니다.
  • Document(page_content=text, metadata={"source": path, "page": i}): LangChain 표준 Document 형식에 맞춰 본문과 메타데이터(파일 경로, 페이지 번호)를 담습니다. 이렇게 하면 이후 RecursiveCharacterTextSplitter, Chroma.from_documents() 등 LangChain 파이프라인에 그대로 이어붙일 수 있습니다.
  • metadatapage 번호를 넣어두면, 나중에 검색 결과에서 “몇 페이지 내용인지”를 추적할 수 있어 출처 표시에 유용합니다.
  • doc.close(): 파일 핸들을 명시적으로 닫아 리소스를 해제합니다.
  • langchain_communityPyMuPDFLoader를 쓰지 않고 pymupdf를 직접 호출해 Document를 수동으로 만드는 방식이라, 페이지별 필터링(빈 페이지 제외)이나 메타데이터 구성을 자유롭게 커스터마이징할 수 있다는 장점이 있습니다.
  • 원본 코드에는 Document를 import하는 줄이 빠져 있어서 그대로 실행하면 NameError가 납니다 — 위 코드처럼 from langchain_core.documents import Document를 추가해야 정상 동작합니다.

실전 RAG 챗봇

from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
from langchain_core.documents import Document
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.vectorstores import Chroma  # 벡터DB
from langchain_openai import OpenAIEmbeddings
from langchain_ollama import ChatOllama

import pymupdf
import glob

# 지정 폴더의 모든 PDF 파일 경로를 수집
pdf_paths = glob.glob("./assets/docs/kopodoc/*.pdf")

# PyMuPDF로 PDF 페이지별 텍스트를 추출해 Document로 변환
def load_pdf(path):
    doc = pymupdf.open(path)
    docs = []
    for i, page in enumerate(doc):  # type: ignore
        text = page.get_text()
        if text.strip():
            docs.append(Document(page_content=text, metadata={"source": path, "page": i}))
    doc.close()
    return docs

# 폴더 내 모든 PDF를 읽어 하나의 문서 리스트로 합침
documents = []
for path in pdf_paths:
    print("===>", path)
    documents.extend(load_pdf(path))

# 청크 단위로 분할 (1000자 기준, 200자 겹침)
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
splited_docs = text_splitter.split_documents(documents)

# 청크를 임베딩해 벡터DB(Chroma)에 저장 (OpenAI 임베딩 사용)
vectorstore = Chroma.from_documents(
    documents=splited_docs,
    embedding=OpenAIEmbeddings(model="text-embedding-3-large"),
    collection_name='openai'
)

# 검색된 컨텍스트와 질문을 넣을 프롬프트 템플릿
prompt_template = """
질문은 다음 내용을 기본으로 하고 있어 : {context}

질문 : {question}
"""
prompt = ChatPromptTemplate.from_template(prompt_template)

# 답변 생성용 모델 (규정 기반 답변이라 temperature 0으로 고정)
llm = ChatOllama(
    model="exaone3.5:7.8b",
    temperature=0
)

# 벡터저장소를 검색기(retriever)로 변환
retriever = vectorstore.as_retriever()

# 검색된 청크들을 하나의 문자열로 합치는 함수
def format_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs)

# RAG 체인: 질문 -> 검색(retriever) -> 프롬프트 구성 -> LLM 답변 -> 문자열 파싱
chain = (
    {"context": retriever | format_docs, "question": RunnablePassthrough()}
    | prompt | llm | StrOutputParser()
)

print("===============================")
print(" 나는 한국폴리텍대학의 다음을 학습한 인공지능이야 \n 1.학사운영규칙 \n 2.학칙 \n 3.대학요람")
print("===============================")

# 빈 입력이면 종료, 그 외에는 질문마다 RAG 체인으로 답변 반복
while True:
    question = input("나 => ")
    if question == "":
        print("=== 종료 ===")
        break
    print("AI =>", chain.invoke(question))  # chain은 RAG 후 LLM으로 질문
    print("===============================")

전체 구조 개요

이 코드는 지금까지 배운 요소들(PDF 로더, 청크 분할, 임베딩, 벡터DB, LCEL 체인)을 전부 합쳐서 실제로 계속 대화 가능한 “터미널 챗봇”으로 완성한 버전입니다. 크게 4단계로 나뉩니다.

① 여러 PDF 로드 → ② 청크 분할·임베딩·저장 (인덱싱, 1회) → ③ RAG 체인 구성 → ④ while 루프로 반복 질의응답

① 여러 PDF 파일 일괄 로드

pdf_paths = glob.glob("./assets/docs/kopodoc/*.pdf")
  • glob.glob()은 패턴에 맞는 파일 경로를 리스트로 반환합니다. 폴더 안에 PDF가 몇 개든(학사운영규칙.pdf, 학칙.pdf, 대학요람.pdf 등) 한 번에 찾아냅니다.
  • 개별 파일마다 경로를 직접 하드코딩할 필요 없이, 폴더에 파일을 추가/삭제하기만 해도 자동으로 반영되는 구조입니다.
documents = []
for path in pdf_paths:
    print("===>", path)
    documents.extend(load_pdf(path))
  • load_pdf()는 앞서 만든 함수로, PDF 한 개를 페이지 단위 Document 리스트로 변환합니다.
  • 여러 PDF를 순회하면서 append가 아니라 extend를 쓰는 이유: append는 리스트 안에 리스트를 통째로 넣어버리지만([[...], [...]]), extend는 반환된 Document들을 낱개로 풀어서 하나의 평평한 리스트([Document, Document, ...])에 이어붙입니다. 이후 split_documents()는 평평한 Document 리스트를 기대하므로 extend가 맞는 선택입니다.
  • Documentmetadata에는 source(파일 경로)와 page(페이지 번호)가 남아 있어, 여러 문서가 섞여도 나중에 “어느 파일 몇 페이지에서 나온 내용인지” 추적이 가능합니다.

② 청크 분할 · 임베딩 · 벡터DB 저장

text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
splited_docs = text_splitter.split_documents(documents)
  • 여러 PDF에서 모은 documents를 한꺼번에 청크로 자릅니다. split_documents는 각 Document를 개별적으로 처리하므로 파일 경계를 넘어 청크가 섞이지는 않습니다(즉 A파일 끝부분과 B파일 시작부분이 한 청크로 합쳐지는 일은 없음).
  • chunk_size=1000, overlap=200: 규정·조항처럼 문맥이 중요한 문서라 이전 예제(뉴스 기사)보다 청크를 조금 더 크게 잡은 편입니다.
vectorstore = Chroma.from_documents(
    documents=splited_docs,
    embedding=OpenAIEmbeddings(model="text-embedding-3-large"),
    collection_name='openai'
)
  • 이 단계에서 실제로 OpenAI API가 호출되어 각 청크가 벡터로 변환되고, 그 결과가 ChromaDB(인메모리, persist_directory 미지정)에 저장됩니다.
  • 임베딩만 OpenAI를 쓰고 답변 생성은 Ollama를 쓰는 이유: 임베딩 품질(특히 검색 정확도)은 상용 모델이 유리한 경우가 많고, 답변 생성은 로컬 모델로도 충분히 처리 가능해 비용을 아끼려는 하이브리드 전략으로 보입니다. 단, 실행할 때마다 OpenAI 임베딩 API 비용이 발생한다는 점은 감안해야 합니다.
  • persist_directory가 없어서 이 코드를 다시 실행하면 매번 처음부터 다시 임베딩합니다 — 문서 양이 많아지면 여기서 시간·비용이 누적된다는 점이 실전에서는 개선 포인트입니다(캐시하려면 persist_directory 지정 필요).

③ RAG 체인 구성

prompt_template = """
질문은 다음 내용을 기본으로 하고 있어 : {context}

질문 : {question}
"""
prompt = ChatPromptTemplate.from_template(prompt_template)
  • {context}{question} 두 개의 변수 슬롯을 가진 템플릿입니다. 이 템플릿이 나중에 실제 검색된 텍스트와 사용자 질문으로 채워집니다.
llm = ChatOllama(model="exaone3.5:7.8b", temperature=0)
  • temperature=0: 같은 질문에 항상 같은 답이 나오도록 결정론적으로 설정. 학칙처럼 “정답이 정해진” 도메인에서는 창의적 표현보다 일관성이 더 중요하기 때문입니다.
retriever = vectorstore.as_retriever()

def format_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs)

chain = (
    {"context": retriever | format_docs, "question": RunnablePassthrough()}
    | prompt | llm | StrOutputParser()
)
  • 이 부분이 LCEL의 핵심입니다. chain.invoke(question)이 호출되면 내부적으로 다음 순서로 진행됩니다.

| 순서 | 실행되는 것 | 결과 | | — | — | — | | 1 | question 문자열이 딕셔너리의 양쪽 갈래로 동시에 전달됨 | - | | 2 | 왼쪽 갈래: retriever.invoke(question)format_docs(...) | 관련 청크 4개를 하나의 긴 문자열로 합침 → context | | 3 | 오른쪽 갈래: RunnablePassthrough() | 질문 원문을 그대로 통과 → question | | 4 | {"context": ..., "question": ...} | prompt.format(context=..., question=...)와 동일한 효과 | | 5 | prompt 결과(완성된 프롬프트 문자열) → llm | LLM이 답변 생성 | | 6 | llm 출력(AIMessage 객체) → StrOutputParser() | 순수 문자열로 변환해서 최종 반환 |

  • retriever | format_docs도 그 자체로 하나의 작은 체인입니다 — retriever의 출력(Document 리스트)이 그대로 format_docs의 입력으로 흘러갑니다.

④ 대화형 루프

while True:
    question = input("나 => ")
    if question == "":
        print("=== 종료 ===")
        break
    print("AI =>", chain.invoke(question))
    print("===============================")
  • input()으로 터미널에서 계속 질문을 받고, 빈 문자열을 입력하면 break로 루프를 빠져나가 종료합니다.
  • 매 질문마다 chain.invoke(question)이 처음부터 다시 실행됩니다 — 즉 매번 새로 검색(retrieval)이 일어나고, 매번 독립적인 프롬프트가 LLM에 전달됩니다.
  • 중요한 한계: 이 루프는 이전 질문과 답변을 전혀 기억하지 못합니다. 예를 들어 “휴학 조건이 뭐야?” 다음에 “그럼 복학은?”이라고 물으면, LLM은 “그럼”이 무엇을 가리키는지 모른 채 question 원문만 그대로 받기 때문에 엉뚱하게 검색·답변할 가능성이 높습니다. 대화 맥락을 유지하려면 이전에 다룬 create_history_aware_retriever + chat_history 구조로 바꿔야 합니다.
  • 인덱싱(①②)은 루프 밖에서 한 번만 실행되고, 루프 안에서는 이미 만들어진 vectorstorechain을 재사용합니다 — 매 질문마다 PDF를 다시 읽거나 재임베딩하지 않는다는 뜻이며, 이게 실전에서 성능을 유지하는 핵심 포인트입니다.

UI 챗봇

"""
한국폴리텍대학 RAG 챗봇 - PyQt6 GUI 버전

기존 콘솔(input/print) 기반 RAG 챗봇을 PyQt6 GUI로 포팅한 버전.
- PDF 인덱싱(임베딩)은 시작 시 백그라운드 스레드에서 1회 수행 (GUI 멈춤 방지)
- 질문에 대한 LLM 답변도 백그라운드 스레드에서 처리 (GUI 멈춤 방지)
"""

import sys
import glob

from PyQt6.QtCore import Qt, QThread, pyqtSignal
from PyQt6.QtGui import QFont, QTextCursor, QTextDocument
from PyQt6.QtWidgets import (
    QApplication,
    QMainWindow,
    QWidget,
    QVBoxLayout,
    QHBoxLayout,
    QTextEdit,
    QLineEdit,
    QPushButton,
    QLabel,
    QProgressBar,
)

from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
from langchain_core.documents import Document
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings
from langchain_ollama import ChatOllama

import pymupdf

# ------------------------------------------------------------------
# 설정값 - 필요에 맞게 수정
# ------------------------------------------------------------------
PDF_GLOB_PATTERN = "./assets/docs/kopodoc/*.pdf"
EMBEDDING_MODEL = "text-embedding-3-large"
LLM_MODEL = "exaone3.5:7.8b"
CHROMA_COLLECTION_NAME = "openai"

PROMPT_TEMPLATE = """
질문은 다음 내용을 기본으로 하고 있어 : {context}

질문 : {question}
"""

WELCOME_TEXT = (
    "나는 한국폴리텍대학의 다음을 학습한 인공지능이야\n"
    "1. 학사운영규칙\n"
    "2. 학칙\n"
    "3. 대학요람"
)

# ------------------------------------------------------------------
# PyMuPDF로 PDF에서 페이지별 텍스트 추출 (unstructured 의존성 회피)
# ------------------------------------------------------------------
def load_pdf(path: str) -> list[Document]:
    doc = pymupdf.open(path)
    docs = []
    for i, page in enumerate(doc):  # type: ignore
        text = page.get_text()
        if text.strip():
            docs.append(Document(page_content=text, metadata={"source": path, "page": i}))
    doc.close()
    return docs

def format_docs(docs: list[Document]) -> str:
    return "\n\n".join(doc.page_content for doc in docs)

# ------------------------------------------------------------------
# 백그라운드 스레드 1: 인덱싱(PDF 로드 -> 청크 분할 -> 임베딩 -> 체인 구성)
# ------------------------------------------------------------------
class IndexingWorker(QThread):
    progress = pyqtSignal(str)          # 진행 상황 텍스트
    finished_ok = pyqtSignal(object)    # 완료 시 chain 객체 전달
    failed = pyqtSignal(str)            # 실패 시 에러 메시지 전달

    def run(self):
        try:
            pdf_paths = glob.glob(PDF_GLOB_PATTERN)
            if not pdf_paths:
                self.failed.emit(f"PDF 파일을 찾을 수 없습니다: {PDF_GLOB_PATTERN}")
                return

            documents = []
            for path in pdf_paths:
                self.progress.emit(f"로딩 중... {path}")
                documents.extend(load_pdf(path))

            self.progress.emit("청크 분할 중...")
            text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
            splited_docs = text_splitter.split_documents(documents)

            self.progress.emit(f"임베딩 및 벡터DB 구축 중... (청크 {len(splited_docs)}개)")
            vectorstore = Chroma.from_documents(
                documents=splited_docs,
                embedding=OpenAIEmbeddings(model=EMBEDDING_MODEL),
                collection_name=CHROMA_COLLECTION_NAME,
            )

            prompt = ChatPromptTemplate.from_template(PROMPT_TEMPLATE)
            llm = ChatOllama(model=LLM_MODEL, temperature=0)
            retriever = vectorstore.as_retriever()

            chain = (
                {"context": retriever | format_docs, "question": RunnablePassthrough()}
                | prompt
                | llm
                | StrOutputParser()
            )

            self.progress.emit("준비 완료!")
            self.finished_ok.emit(chain)

        except Exception as e:
            self.failed.emit(str(e))

# ------------------------------------------------------------------
# 백그라운드 스레드 2: 질문 하나에 대한 chain.invoke 실행
# ------------------------------------------------------------------
class QueryWorker(QThread):
    finished_ok = pyqtSignal(str)
    failed = pyqtSignal(str)

    def __init__(self, chain, question: str):
        super().__init__()
        self.chain = chain
        self.question = question

    def run(self):
        try:
            answer = self.chain.invoke(self.question)
            self.finished_ok.emit(answer)
        except Exception as e:
            self.failed.emit(str(e))

# ------------------------------------------------------------------
# 메인 윈도우
# ------------------------------------------------------------------
class ChatWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.chain = None
        self.indexing_worker = None
        self.query_worker = None

        self.setWindowTitle("한국폴리텍대학 RAG 챗봇")
        self.resize(700, 800)

        self._build_ui()
        self._start_indexing()

    # -- UI 구성 --------------------------------------------------
    def _build_ui(self):
        central = QWidget()
        self.setCentralWidget(central)
        layout = QVBoxLayout(central)

        # 상태 표시줄 (인덱싱 진행 상황)
        self.status_label = QLabel("초기화 중...")
        self.status_label.setStyleSheet("color: gray;")
        layout.addWidget(self.status_label)

        self.progress_bar = QProgressBar()
        self.progress_bar.setRange(0, 0)  # 인디터미네이트(빙글빙글) 모드
        layout.addWidget(self.progress_bar)

        # 채팅 표시 영역
        self.chat_view = QTextEdit()
        self.chat_view.setReadOnly(True)
        self.chat_view.setFont(QFont("맑은 고딕", 11))
        layout.addWidget(self.chat_view, stretch=1)

        # 입력 영역
        input_layout = QHBoxLayout()
        self.input_line = QLineEdit()
        self.input_line.setPlaceholderText("질문을 입력하세요...")
        self.input_line.returnPressed.connect(self._on_send)
        self.input_line.setEnabled(False)  # 인덱싱 끝나기 전엔 입력 불가
        input_layout.addWidget(self.input_line)

        self.send_button = QPushButton("전송")
        self.send_button.clicked.connect(self._on_send)
        self.send_button.setEnabled(False)
        input_layout.addWidget(self.send_button)

        layout.addLayout(input_layout)

        self._append_system_message(WELCOME_TEXT)

    # -- 인덱싱 시작 -----------------------------------------------
    def _start_indexing(self):
        self.indexing_worker = IndexingWorker()
        self.indexing_worker.progress.connect(self._on_indexing_progress)
        self.indexing_worker.finished_ok.connect(self._on_indexing_finished)
        self.indexing_worker.failed.connect(self._on_indexing_failed)
        self.indexing_worker.start()

    def _on_indexing_progress(self, message: str):
        self.status_label.setText(message)

    def _on_indexing_finished(self, chain):
        self.chain = chain
        self.status_label.setText("준비 완료 - 질문을 입력하세요")
        self.progress_bar.hide()
        self.input_line.setEnabled(True)
        self.send_button.setEnabled(True)
        self.input_line.setFocus()

    def _on_indexing_failed(self, error_message: str):
        self.status_label.setText("인덱싱 실패")
        self.progress_bar.hide()
        self._append_system_message(f"[오류] 인덱싱 중 문제가 발생했습니다:\n{error_message}")

    # -- 질문 전송 ---------------------------------------------------
    def _on_send(self):
        question = self.input_line.text().strip()
        if not question or self.chain is None:
            return

        self._append_user_message(question)
        self.input_line.clear()

        # 답변 대기 중 입력 잠금
        self.input_line.setEnabled(False)
        self.send_button.setEnabled(False)
        self.status_label.setText("답변 생성 중...")

        self.query_worker = QueryWorker(self.chain, question)
        self.query_worker.finished_ok.connect(self._on_answer_ready)
        self.query_worker.failed.connect(self._on_answer_failed)
        self.query_worker.start()

    def _on_answer_ready(self, answer: str):
        self._append_ai_message(answer)
        self.status_label.setText("준비 완료 - 질문을 입력하세요")
        self.input_line.setEnabled(True)
        self.send_button.setEnabled(True)
        self.input_line.setFocus()

    def _on_answer_failed(self, error_message: str):
        self._append_system_message(f"[오류] 답변 생성 실패:\n{error_message}")
        self.status_label.setText("준비 완료 - 질문을 입력하세요")
        self.input_line.setEnabled(True)
        self.send_button.setEnabled(True)

    # -- 채팅창에 메시지 추가 ------------------------------------------
    def _append_user_message(self, text: str):
        self.chat_view.append(f"<p style='color:#1a73e8;'><b>나 ▶</b> {self._escape(text)}</p>")
        self._scroll_to_bottom()

    def _append_ai_message(self, text: str):
        # QTextDocument의 markdown 렌더러로 raw markdown -> HTML 변환
        html_body = self._markdown_to_html(text)
        self.chat_view.append(f"<div style='color:#0b8043;'><b>AI ▶</b></div>{html_body}")
        self._scroll_to_bottom()

    @staticmethod
    def _markdown_to_html(markdown_text: str) -> str:
        # 임시 QTextDocument를 이용해 markdown을 HTML로 변환
        # (QTextEdit.setMarkdown()은 문서 전체를 덮어써서 append 방식과 안 맞으므로
        #  별도 QTextDocument에서 변환만 하고 결과 HTML만 추출해서 사용)
        temp_doc = QTextDocument()
        temp_doc.setMarkdown(markdown_text)
        return temp_doc.toHtml()

    def _append_system_message(self, text: str):
        formatted = self._escape(text).replace("\n", "<br>")
        self.chat_view.append(f"<p style='color:gray;'>{formatted}</p>")
        self._scroll_to_bottom()

    def _scroll_to_bottom(self):
        self.chat_view.moveCursor(QTextCursor.MoveOperation.End)

    @staticmethod
    def _escape(text: str) -> str:
        return (
            text.replace("&", "&amp;")
            .replace("<", "&lt;")
            .replace(">", "&gt;")
        )

def main():
    app = QApplication(sys.argv)
    window = ChatWindow()
    window.show()
    sys.exit(app.exec())

if __name__ == "__main__":
    main()

전체 구조 개요

이전 콘솔 버전의 RAG 체인(PDF 로드 → 청크 분할 → 임베딩 → LCEL 체인)은 그대로 유지하면서, input()/print() 대신 PyQt6 GUI로 감싼 버전입니다. 핵심은 인덱싱과 LLM 호출을 모두 백그라운드 스레드(QThread)로 분리해서 GUI가 멈추지 않게 만든 것입니다.

[메인 스레드]           [백그라운드 스레드]
GUI 생성/표시    ───▶   IndexingWorker (PDF 로드~체인 구성)
사용자 입력 대기          │ (progress/finished_ok/failed 시그널)
질문 전송        ───▶   QueryWorker (chain.invoke)
                         │ (finished_ok/failed 시그널)
결과 화면 표시   ◀───────┘

설정값 분리

PDF_GLOB_PATTERN = "./assets/docs/kopodoc/*.pdf"
EMBEDDING_MODEL = "text-embedding-3-large"
LLM_MODEL = "exaone3.5:7.8b"
CHROMA_COLLECTION_NAME = "openai"
PROMPT_TEMPLATE = """..."""
WELCOME_TEXT = "..."
  • 콘솔 버전에서 코드 곳곳에 흩어져 있던 값들(모델명, 경로, 프롬프트)을 상단 상수로 모아 관리합니다. 모델을 바꾸거나 PDF 폴더를 바꿀 때 코드 로직을 건드릴 필요 없이 이 부분만 수정하면 되는 구조입니다.

IndexingWorker — 인덱싱을 백그라운드로

class IndexingWorker(QThread):
    progress = pyqtSignal(str)
    finished_ok = pyqtSignal(object)
    failed = pyqtSignal(str)

    def run(self):
        ...
  • QThread를 상속해서 run()을 오버라이드하는 방식입니다. .start()를 호출하면 Qt가 별도 스레드에서 run()을 실행합니다.
  • PyQt의 시그널/슬롯 패턴이 핵심입니다. 스레드 안에서 직접 GUI 위젯을 건드리면 크래시 위험이 있기 때문에, 대신 pyqtSignal로 “이벤트”만 발생시키고 실제 UI 갱신은 메인 스레드의 슬롯 함수(_on_indexing_progress 등)가 처리합니다.
    • progress = pyqtSignal(str): 로딩 중 진행 상황 텍스트를 전달 ("로딩 중... xxx.pdf", "청크 분할 중..." 등)
    • finished_ok = pyqtSignal(object): 완성된 chain 객체를 통째로 전달
    • failed = pyqtSignal(str): 에러 메시지 전달
  • run() 내부 로직은 콘솔 버전과 동일합니다(PDF 로드 → 청크 분할 → 임베딩·Chroma 저장 → prompt/llm/retriever/chain 구성). 다만 각 단계 사이사이에 self.progress.emit(...)을 넣어 진행 상황을 GUI에 실시간으로 알립니다.
  • try/except로 전체를 감싸서, PDF가 없거나 임베딩 API 실패 등 어떤 예외가 나도 앱이 죽지 않고 failed 시그널로 에러 메시지만 GUI에 전달됩니다.

QueryWorker — 질문 하나당 스레드 하나

class QueryWorker(QThread):
    finished_ok = pyqtSignal(str)
    failed = pyqtSignal(str)

    def __init__(self, chain, question: str):
        ...
    def run(self):
        answer = self.chain.invoke(self.question)
        self.finished_ok.emit(answer)
  • 이미 만들어진 chain과 사용자 질문을 받아 chain.invoke()만 실행하는 전용 워커입니다.
  • 질문을 보낼 때마다 QueryWorker 인스턴스가 새로 생성되고(_on_send에서 self.query_worker = QueryWorker(...)), 그 안에서 LLM 호출이 일어나는 동안 메인 UI 스레드는 계속 반응 가능한 상태를 유지합니다. LLM 응답이 몇 초 걸려도 창이 멈추지 않는 이유가 이것입니다.

ChatWindow — 메인 윈도우

UI 구성 (_build_ui)

| 위젯 | 역할 | | — | — | | status_label | 현재 상태 텍스트 (“초기화 중…”, “답변 생성 중…” 등) | | progress_bar (setRange(0,0)) | 진행률을 알 수 없는 작업이라 인디터미네이트(빙글빙글 도는) 모드로 설정 | | chat_view (QTextEdit, readOnly) | 대화 내용이 누적 표시되는 영역 | | input_line + send_button | 질문 입력창과 전송 버튼, returnPressed로 엔터 키 입력도 전송으로 연결 |

  • input_line.setEnabled(False), send_button.setEnabled(False): 초기 인덱싱이 끝나기 전에는 입력을 막아서, 벡터DB 준비가 안 된 상태로 chain.invoke()가 호출되는 것을 원천 차단합니다.

인덱싱 시작 및 콜백

def _start_indexing(self):
    self.indexing_worker = IndexingWorker()
    self.indexing_worker.progress.connect(self._on_indexing_progress)
    self.indexing_worker.finished_ok.connect(self._on_indexing_finished)
    self.indexing_worker.failed.connect(self._on_indexing_failed)
    self.indexing_worker.start()
  • __init__()에서 _build_ui() 직후 자동으로 호출됩니다. 즉 앱을 켜자마자 백그라운드에서 PDF 인덱싱이 시작됩니다.
  • .connect()는 시그널과 슬롯(콜백 함수)을 연결하는 PyQt 문법입니다. 워커가 emit()을 호출하면 연결된 함수가 메인 스레드에서 자동 실행됩니다.
  • _on_indexing_finished(chain): 완성된 chain을 self.chain에 저장하고, 프로그레스바를 숨기고, 입력창/버튼을 활성화합니다. 이 시점부터 사용자가 질문을 입력할 수 있습니다.
  • _on_indexing_failed(error_message): 인덱싱 실패 시 에러 메시지만 채팅창에 표시하고 입력은 계속 비활성 상태로 둡니다(질문해도 어차피 chain이 없어 처리 불가하기 때문).

질문 전송 흐름 (_on_send)

def _on_send(self):
    question = self.input_line.text().strip()
    if not question or self.chain is None:
        return
    self._append_user_message(question)
    self.input_line.clear()
    self.input_line.setEnabled(False)
    self.send_button.setEnabled(False)
    ...
    self.query_worker = QueryWorker(self.chain, question)
    self.query_worker.finished_ok.connect(self._on_answer_ready)
    self.query_worker.failed.connect(self._on_answer_failed)
    self.query_worker.start()
  • 빈 입력이거나 chain이 아직 준비 안 됐으면 아무 동작 없이 리턴합니다.
  • 질문을 즉시 채팅창에 표시(_append_user_message) → 입력창 비우기 → 답변 오기 전까지 입력창·버튼 잠금(중복 전송 방지) → QueryWorker 실행.
  • _on_answer_ready(answer): 답변을 채팅창에 추가하고 입력을 다시 활성화. _on_answer_failed: 에러만 표시하고 역시 입력을 다시 열어줍니다(재시도 가능하게).

채팅창 렌더링

def _append_ai_message(self, text: str):
    html_body = self._markdown_to_html(text)
    self.chat_view.append(f"<div style='color:#0b8043;'><b>AI ▶</b></div>{html_body}")
  • QTextDocument().setMarkdown(text).toHtml()을 이용해, LLM이 마크다운 형식으로 답변해도(목록, 강조 등) 화면에 서식이 살아서 표시되도록 변환합니다.
  • QTextEdit.setMarkdown()을 직접 쓰지 않은 이유는 주석에 명시되어 있습니다 — 그 메서드는 문서 전체를 덮어써서 대화 누적(append) 방식과 맞지 않기 때문에, 임시 QTextDocument에서 변환만 하고 결과 HTML 문자열만 뽑아 쓰는 우회 방법을 사용합니다.
  • _append_user_message/_append_system_message는 사용자 입력이나 시스템 메시지를 HTML 이스케이프(_escape) 처리 후 색상 구분해서 표시합니다. 사용자 메시지는 이스케이프만 하고 마크다운 변환은 하지 않는 것으로 보아, AI 답변만 서식 있는 텍스트로 취급하고 사용자 입력은 순수 텍스트로 다룹니다.
  • _scroll_to_bottom(): 메시지가 추가될 때마다 커서를 문서 끝으로 이동시켜 항상 최신 메시지가 보이도록 자동 스크롤합니다.

콘솔 버전과의 구조적 차이 요약

| 구분 | 콘솔 버전 | GUI 버전 | | — | — | — | | 인덱싱 실행 위치 | 메인 스레드, 스크립트 시작 시 동기 실행 | IndexingWorker 스레드에서 비동기 실행 | | 질문 처리 | while True + input() (블로킹) | QueryWorker 스레드 + 시그널 콜백 (논블로킹) | | 진행 상황 표시 | print()로 즉시 출력 | progress 시그널 → status_label 갱신 | | 에러 처리 | 예외 발생 시 프로그램 중단 가능 | try/except + failed 시그널로 앱은 유지, 에러만 표시 | | 답변 서식 | 순수 텍스트 출력 | 마크다운 → HTML 변환 후 표시 |

  • 대화 이력(맥락) 미반영 문제는 이 버전에서도 동일하게 남아 있습니다 — chain.invoke(question)이 매번 독립적으로 호출되는 구조는 콘솔 버전과 같습니다.






© 2017. by isme2n

Powered by aiden