13년차의 서버실

서버, 인프라, 홈랩 기술 블로그

[태그:] LLM 애플리케이션 개발

  • [AI] Haystack으로 사내 지식 검색 챗봇 RAG 파이프라인 구축 사례

    [AI] Haystack으로 사내 지식 검색 챗봇 RAG 파이프라인 구축 사례

    [LLM 애플리케이션 개발] Haystack으로 사내 지식 검색 챗봇 구축 사례: RAG 파이프라인 개발기

    안녕하세요, 13년차 인프라 엔지니어입니다. 요즘 제가 홈랩에서 몰두하고 있는 프로젝트가 하나 있는데, 바로 Haystack을 활용한 사내 지식 검색 챗봇이거든요. 회사에서 일하다 보면 “그 정보 어디 있지?”라며 헤매는 시간이 정말 많지 않으신가요? 저도 처음엔 그랬습니다. 수많은 문서와 슬랙 채널을 뒤지며 시간을 낭비하는 게 비효율적이라고 늘 생각했었거든요.

    그러다 “이걸 LLM(Large Language Model, 거대 언어 모델)으로 해결할 수 있지 않을까?”라는 생각이 자꾸만 들었고, 결국 직접 만들어보기로 결심했습니다. 특히 RAG(Retrieval Augmented Generation, 검색 증강 생성) 기법을 쓰면 환각(Hallucination) 없이 정확한 답변을 얻을 수 있다는 점이 마음에 들었죠. 제가 선택한 도구는 Haystack입니다. 지금부터 제가 어떻게 이 프로젝트를 진행했는지, 그리고 어떤 삽질들을 겪었는지 자세히 풀어보겠습니다.

    사내 지식 검색 챗봇의 RAG 파이프라인 아키텍처 개념도입니다. 질문이 들어오면 Haystack이 문서를 뒤져서 관련성 높은 정보를 가져오고, 그걸 LLM에 넘겨서 답변을 만들어내는 흐름이죠.

    RAG와 Haystack: 왜 이 조합일까요?

    먼저 RAG와 Haystack이 정확히 뭔지 짚고 넘어갈게요. 이미 잘 아시는 분도 계시겠지만, 저도 처음엔 개념을 제대로 이해하는 데 시간이 걸렸거든요. LLM 애플리케이션 개발은 빠르게 진화하고 있어서, 기본기를 탄탄히 하는 게 정말 중요하더라고요.

    RAG (Retrieval Augmented Generation, 검색 증강 생성)

    LLM이 똑똑하긴 하지만, 학습 데이터 시점 이후의 정보나 우리 회사 같은 특정 도메인 지식은 알 수가 없습니다. 그리고 가끔은 환각(Hallucination), 즉 없는 이야기를 지어내기도 하죠. 이 문제를 해결하기 위해 나온 게 RAG입니다. 간단히 말해서, 질문이 들어오면 LLM이 바로 답변하는 게 아니라, 먼저 외부 지식 베이스(우리 회사 문서)에서 관련성 높은 정보를 ‘검색(Retrieval)’해서 가져온 다음, 그 정보를 ‘참고해서(Augmented)’ 답변을 ‘생성(Generation)’하는 방식입니다. 이렇게 하면 LLM이 최신 정보나 특정 도메인 지식에 기반한 정확한 답변을 할 수 있게 되더라고요.

    Haystack: RAG 파이프라인을 쉽게 구축하는 프레임워크

    RAG 파이프라인을 밑바닥부터 직접 구현하려면 생각보다 복잡합니다. 문서 로딩(Document Loading), 임베딩(Embedding), 검색기(Retriever), 생성기(Generator) 등 여러 컴포넌트를 일일이 연결해야 하거든요. Haystack은 이런 복잡한 과정을 쉽고 유연하게 만들어주는 프레임워크입니다. 다양한 DocumentStore(문서 저장소), Retriever(검색기), Generator(생성기) 컴포넌트를 모듈식으로 제공해서, 마치 레고 블록처럼 조립하듯이 파이프라인을 구축할 수 있어요. 파이썬 기반이라 저 같은 개발자에게는 접근성도 정말 좋습니다.

    실전 구현: Haystack RAG 파이프라인 만들기

    이제 제가 어떻게 사내 지식 검색 챗봇을 만들었는지 단계별로 보여드리겠습니다. 홈랩에서 직접 해보면서 시행착오도 많았는데, 핵심만 쏙쏙 뽑아서 공유해드릴게요.

    1. 환경 구성 및 Haystack 설치

    가장 먼저 개발 환경을 설정하고 Haystack을 설치해야겠죠. 저는 Python 가상 환경을 사용해서 의존성 충돌을 방지합니다. 깔끔하게 시작해야 나중에 트러블슈팅이 쉬워거든요.

    
    # 가상 환경 생성 및 활성화
    python3 -m venv haystack_env
    source haystack_env/bin/activate
    
    # Haystack 및 필요한 라이브러리 설치
    # PDF 문서를 처리할 예정이므로 pypdf도 설치합니다.
    pip install farm-haystack[pdf,faiss]
    # LLM 연동을 위해 OpenAI 라이브러리를 설치합니다.
    # 저는 테스트용으로 OpenAI API를 사용했습니다. (실제 운영 시에는 보안 고려 필수!)
    pip install openai
    

    참고로 <code>farm-haystack[pdf,faiss]처럼 대괄호 안에 옵션을 넣으면 필요한 컴포넌트들을 한 번에 설치할 수 있어요. FAISS(Facebook AI Similarity Search)를 사용해 빠른 검색을 구현했고, 나중에 스케일 아웃을 고려해서 Elasticsearch도 고려했습니다.

    2. 사내 문서 로딩 및 인덱싱

    이제 챗봇이 참고할 지식 베이스를 구축해야 합니다. 회사 내부 문서(회의록, 기술 문서, FAQ 등)를 Haystack이 이해할 수 있는 형태로 변환하고 저장하는 과정이죠. 저는 주로 PDF나 텍스트 파일 형태의 문서를 사용했습니다. 여기서 중요한 건 문서 청킹(Document Chunking) 전략입니다. 너무 길면 LLM의 컨텍스트 윈도우(Context Window)를 초과하고, 너무 짧으면 문맥을 놓칠 수 있거든요. 이 부분에서 저도 삽질 좀 했습니다. 처음엔 문서를 통째로 넣었다가 “너무 길어요”라는 LLM의 반응에 깜짝 놀랐죠. ㅎㅎ

    
    import os
    from haystack.document_stores import FAISSDocumentStore
    from haystack.nodes import TextConverter, PreProcessor
    
    # 문서 저장소 초기화 (FAISS 사용)
    # FAISS는 메모리 기반으로 빠르게 유사성 검색을 할 수 있게 해줍니다.
    document_store = FAISSDocumentStore()
    
    # 문서 변환 및 전처리 설정
    text_converter = TextConverter(remove_numeric_tables=True, valid_languages=["ko"])
    preprocessor = PreProcessor(
        clean_empty_lines=True,
        clean_whitespace=True,
        split_by="word",
        split_length=500,
        split_overlap=50,
        split_respect_sentence_boundary=True,
        language="ko"
    )
    
    # 문서 로딩
    from pathlib import Path
    DOC_DIR = "data"
    if not os.path.exists(DOC_DIR):
        os.makedirs(DOC_DIR)
    
    # PDF 파일들을 로드하고 전처리합니다.
    doc_files = list(Path(DOC_DIR).glob("*.pdf"))
    for file in doc_files:
        docs = text_converter.run(file_paths=[str(file)])["documents"]
        processed_docs = preprocessor.run(documents=docs)["documents"]
        document_store.write_documents(processed_docs)
    
    print(f"총 {document_store.get_document_count()}개의 문서(청크)가 인덱싱되었습니다.")
    

    여기서 중요한 건 PreProcessor의 split_length와 split_overlap 파라미터예요. 이 값을 어떻게 설정하느냐에 따라 검색 결과의 품질이 크게 달라지더라고요. 회사 문서의 스타일이나 평균 문장 길이를 고려해서 여러 번 테스트하고 최적값을 찾는 게 중요합니다. 저도 이 부분에서 여러 번 값을 조절하며 시행착오를 겪었습니다.

    Haystack 문서 인덱싱 과정을 시각화한 다이어그램

    Haystack에서 문서들을 로딩하고 인덱싱하는 과정입니다. 원본 문서가 텍스트로 변환되고, 의미 있는 조각(청크)으로 나뉘어 검색 가능한 형태로 저장되는 과정이죠.

    3. RAG 파이프라인 구축: 검색기와 생성기의 조립

    문서가 준비되었으니, 이제 질문이 들어왔을 때 관련 문서를 찾아주고 답변을 생성해주는 핵심 파이프라인을 만들 차례입니다. Haystack에서는 Pipeline 클래스를 사용해서 컴포넌트들을 연결해요. 저는 BM25Retriever와 PromptNode를 사용했습니다. BM25는 키워드 기반 검색에 강력하고, PromptNode는 LLM을 통합해 강력한 언어 생성 능력을 제공합니다.

    
    from haystack.nodes import BM25Retriever, PromptNode, PromptTemplate
    from haystack.pipelines import Pipeline
    import os
    
    # 1. Retriever (검색기) 설정
    # DocumentStore에서 가장 관련성 높은 문서를 찾아옵니다.
    retriever = BM25Retriever(document_store=document_store)
    
    # 2. Generator (생성기) 설정 - LLM 연동
    # PromptTemplate에 RAG를 위한 지시사항(프롬프트 엔지니어링)을 추가합니다.
    rag_prompt_template = PromptTemplate(
        prompt="""Given the following documents, answer the question in Korean.
        If you don't know the answer, just say that you don't know, don't make up an answer.
        Documents:
        {% for doc in documents %}
        {{ doc.content }}
        {% endfor %}
        Question: {{query}}
        Answer:"""
    )
    
    # PromptNode를 사용하여 LLM을 통합합니다.
    # model_name은 사용하려는 LLM 모델명입니다.
    # api_key는 OpenAI API 키를 환경 변수에서 가져옵니다.
    prompt_node = PromptNode(
        model_name_or_path="gpt-3.5-turbo",
        api_key=os.environ.get("OPENAI_API_KEY"),
        default_prompt_template=rag_prompt_template,
        max_length=500,
        model_kwargs={"temperature": 0.1}
    )
    
    # 3. RAG 파이프라인 조립
    rag_pipeline = Pipeline()
    rag_pipeline.add_node(component=retriever, name="Retriever", inputs=["Query"])
    rag_pipeline.add_node(component=prompt_node, name="PromptNode", inputs=["Retriever"])
    
    print("RAG 파이프라인이 준비되었습니다!")
    

    PromptTemplate이 정말 중요한데요. LLM이 가져온 문서를 바탕으로 어떤 답변을 해야 할지 지시하는 역할을 하거든요. “주어진 문서에서만 답변해라”, “모르면 모른다고 해라” 같은 지시를 명확히 주면 환각을 최소화할 수 있습니다. temperature 값도 중요한데, 낮을수록 정해진 사실에 기반한 답변을 유도합니다.

    ⚠️ 삽질 경험과 트러블슈팅: 제가 겪은 문제들

    이 과정을 진행하면서 겪었던 몇 가지 삽질과 해결책을 공유합니다. 여러분은 저처럼 같은 실수를 반복하지 않으시길 바라요!

    1. 문제: “Context window exceeded” 오류
      상황: 처음에는 PreProcessor에서 문서를 너무 크게 청킹하거나, 청킹을 아예 하지 않고 통째로 LLM에 넘기려 했습니다. 그랬더니 “Context window exceeded” 에러가 나더군요. LLM마다 한 번에 처리할 수 있는 토큰(단어) 수가 정해져 있는데, 이 한도를 초과한 거죠.
      해결: PreProcessor의 split_length와 split_overlap 파라미터를 조절했습니다. split_length를 LLM의 컨텍스트 윈도우 한계와 검색할 문서의 양을 고려해서 적절히 줄이고, split_overlap을 설정해 청크 간의 문맥 연결성을 유지했어요. 특히 split_respect_sentence_boundary=True 옵션을 주면 문장 중간에 잘리는 것을 방지할 수 있습니다.
    2. 문제: “관련 없는 문서 검색” 또는 “빈약한 답변”
      상황: 질문에 대한 답변이 엉뚱하거나, 문서에 분명히 내용이 있는데도 “모르겠습니다”라고 하는 경우가 있었습니다. 검색기(Retriever)가 관련성 높은 문서를 제대로 찾아오지 못했거나, 찾아온 문서가 충분히 상세하지 않았기 때문이었죠.
      해결:

      • 검색기 튜닝: BM25Retriever 외에 DensePassageRetriever(DPR)나 EmbeddingRetriever 같은 임베딩 기반 검색기도 시도해봤어요. 특히 DPR은 문맥을 더 잘 이해해서 유사성 높은 문서를 찾아주는 데 효과적이더라고요. 다만, 임베딩 모델 선택과 GPU 자원이 필요할 수 있습니다.
      • 프롬프트 엔지니어링 강화: PromptTemplate에 “주어진 문서 외의 정보는 사용하지 마세요”, “간결하게 답변해주세요” 등의 지시를 더 명확하게 추가했어요.
      • 문서 품질 개선: 결국 지식 베이스가 튼튼해야 합니다. 문서의 내용이 너무 두루뭉술하거나 핵심 정보가 부족하면 아무리 좋은 RAG 파이프라인이라도 좋은 답변을 내기 어렵거든요. 회사 내 문서들을 정리하고 표준화하는 작업도 병행했습니다.

    검증 및 결과: 드디어 챗봇과 대화하다!

    여러 번의 시행착오 끝에 드디어 만족스러운 답변을 내놓는 챗봇을 만들었어요! 파이프라인이 제대로 작동하는지 확인하는 순간은 정말 짜릿했습니다. 간단한 질문으로 테스트해볼 차례입니다.

    
    # 챗봇에 질문하기
    question = "2023년 하반기 사내 워크샵은 언제 어디서 개최되나요?"
    result = rag_pipeline.run(query=question, params={"Retriever": {"top_k": 3}})
    
    # 결과 출력
    print(f"질문: {question}\n")
    print(f"답변: {result['answers'][0].answer}\n")
    print("-" * 30)
    print("참조 문서:")
    for doc in result.get("documents", []):
        print(f"- {doc.meta.get('name', '제목 없음')} (ID: {doc.id[:8]}...)")
    

    결과를 보면, LLM이 단순히 질문에 답하는 것을 넘어, 어떤 문서에서 정보를 가져왔는지 참조 문서(Source Documents)까지 알려주더라고요. 이게 바로 RAG의 강력한 장점입니다. 사용자는 답변의 근거를 확인할 수 있어 신뢰도가 높아져요. 실제로 회사 회의록이나 프로젝트 보고서 같은 문서들을 넣어 테스트해보니, 담당자를 일일이 찾지 않아도 필요한 정보를 빠르게 얻을 수 있어서 업무 효율성이 크게 향상될 거 같다는 확신이 들었습니다.

    완성된 Haystack 기반 사내 지식 검색 챗봇 사용자 인터페이스 스크린샷

    제가 개발한 사내 지식 검색 챗봇의 실제 동작 화면입니다. 질문에 대한 명확한 답변과 함께 어떤 문서에서 정보를 가져왔는지 출처까지 알려줘서 신뢰할 수 있죠.

    Retriever 선택 가이드

    제가 여러 검색기를 사용해보면서 느낀 점을 바탕으로, 어떤 상황에서 어떤 검색기가 유리한지 정리해봤습니다. 여러분의 환경과 필요에 맞춰 선택하시면 됩니다.

    검색기 (Retriever) 장점 단점 추천 사용 사례
    BM25Retriever 설치 및 사용이 간편
    키워드 매칭에 강력
    적은 컴퓨팅 자원
    의미론적 유사성 파악 어려움
    동의어/유의어 처리 한계
    초기 프로토타입 개발
    명확한 키워드 기반 문서
    제한된 컴퓨팅 자원 환경
    EmbeddingRetriever (DPR 포함) 의미론적 유사성 검색 가능
    문맥 이해도가 높음
    질문 의도를 더 잘 파악
    임베딩 모델 필요 (추가 설치/학습)
    GPU 또는 고성능 CPU 요구
    초기 설정 복잡성
    고품질 검색 결과 요구
    의미가 복잡한 문서
    충분한 컴퓨팅 자원 보유

    저 같은 경우는 초기에는 BM25로 빠르게 시작하고, 성능 개선이 필요할 때 EmbeddingRetriever로 전환하는 전략을 사용했어요. 홈랩에서는 자원 제약이 있을 수 있으니, 이 표를 참고해서 현명한 선택을 하시길 바랍니다.

    마무리: RAG 챗봇, 가능성을 엿보다

    이번 Haystack 기반 RAG 파이프라인 개발은 저에게 LLM 애플리케이션 개발의 무한한 가능성을 보여준 값진 경험이었습니다. 처음엔 낯설고 복잡하게 느껴졌지만, 하나하나 직접 부딪히고 해결해나가면서 많은 걸 배울 수 있었어요. 특히 사내 지식 검색 챗봇은 업무 효율성을 혁신적으로 높일 수 있는 강력한 도구가 될 거라는 확신이 들었습니다. 물론 아직 개선할 부분은 많습니다. 사용자 인터페이스(UI)를 더 친숙하게 만들고, 검색 정확도를 높이기 위한 지속적인 모델 튜닝, 그리고 보안 강화 같은 과제들이 남아있거든요.

    만약 여러분도 회사 내부의 비효율적인 정보 탐색 문제로 고민하고 계신다면, 저처럼 Haystack과 RAG를 활용해 사내 지식 검색 챗봇 구축에 도전해보시길 강력히 추천합니다. 처음엔 삽질 좀 하겠지만, 그 과정에서 얻는 경험과 지식은 분명 여러분의 엔지니어링 역량을 한 단계 끌어올려 줄 겁니다. 다음 글에서는 이 Haystack 챗봇을 Kubernetes 환경에 배포하는 과정이나, 더 다양한 검색기를 통합하는 방법에 대해 다뤄볼 예정이니 기대해주세요!

    RAG 파이프라인과 Haystack의 주요 장점들을 요약한 인포그래픽

    RAG 파이프라인과 Haystack의 주요 장점들을 요약한 이미지입니다. LLM의 한계를 극복하고 효율적인 애플리케이션 개발을 가능하게 하죠.

  • [AI] LangChain RAG 오류: 임베딩 문제와 해결 전략

    [AI] LangChain RAG 오류: 임베딩 문제와 해결 전략

    LangChain RAG 오류: 임베딩 문제와 해결 전략

    LangChain RAG 오류를 처음 겪으면 꽤 당황스럽습니다. 문서는 분명 들어갔는데 검색 결과가 엉뚱하게 나오고, 어떤 날은 벡터 저장소에서 차원 불일치 에러가 터지고, 또 어떤 날은 임베딩 단계에서 조용히 일부 문서만 빠지기도 하거든요. 저도 문서 검색용 RAG(Retrieval-Augmented Generation, 검색 증강 생성) 파이프라인을 여러 번 손보면서 같은 문제를 반복해서 봤는데, 막상 파고 들어가 보니 원인은 모델 하나가 아니라 문서 전처리, 청크(chunk, 문서 분할 단위), 메타데이터, 패키지 구성, 임베딩 모델 선택이 얽혀 있더라고요.

    이번 글에서는 LangChain RAG 오류 중에서도 특히 자주 마주치는 LangChain 임베딩 문제를 중심으로, 실제 디버깅할 때 먼저 보는 체크포인트와 해결 전략을 정리해보겠습니다. 단순히 따라 하는 설정이 아니라 왜 이런 문제가 생기는지까지 같이 보면, 이후 LLM 애플리케이션 개발에서도 훨씬 덜 헤매게 됩니다.

    LangChain RAG 오류 흐름과 임베딩 문제 지점을 보여주는 아키텍처 다이어그램

    문서 로더, 텍스트 분할기, 임베딩 모델, 벡터 저장소, 검색기, LLM 응답 생성까지 이어지는 흐름과 오류가 자주 발생하는 지점을 설명하는 이미지입니다.

    왜 LangChain RAG 오류가 임베딩 단계에서 자주 생길까요?

    쉽게 말해 임베딩(Embedding, 텍스트를 숫자 벡터로 바꾸는 과정)은 RAG의 바닥 공사입니다. 여기서 조금만 어긋나도 이후 검색 품질이 크게 흔들립니다. 생성 모델은 멀쩡한데 답변이 이상한 경우, 의외로 원인은 검색 단계 이전인 임베딩 쪽에 있는 경우가 많더라고요.

    제가 직접 겪어보니 특히 아래 네 가지가 반복적으로 문제를 만들었습니다.

    • 임베딩 모델을 바꿨는데 기존 벡터 저장소를 그대로 재사용한 경우
    • 문서 청크가 비어 있거나 너무 짧거나 너무 긴 경우
    • 질문과 문서의 언어 특성이 다른데 모델 특성을 고려하지 않은 경우
    • 배치 처리(batch processing)와 API 제한 때문에 중간에 누락이 생기는 경우

    여기서 중요한 포인트가 하나 있습니다. 오류가 눈에 보이게 터지면 오히려 낫습니다. 진짜 까다로운 건 에러 없이 검색 품질만 나빠지는 상황입니다. 이건 로그만 봐서는 잘 안 보여서, 검증 루틴을 따로 만들어두는 게 진짜 편하더라고요.

    LangChain RAG 오류를 이해하는 핵심 개념

    임베딩 벡터 차원(Dimension, 벡터 길이)

    각 임베딩 모델은 고정된 형태의 벡터를 만듭니다. 벡터 저장소는 이 차원 정보를 기준으로 데이터를 저장하는데, 나중에 다른 임베딩 모델로 바꾸면서 기존 인덱스를 그대로 읽어오면 차원 불일치가 발생할 수 있습니다. 같은 텍스트라도 숫자 표현 방식 자체가 다르기 때문에, 서로 다른 모델에서 만든 벡터를 같은 인덱스에 섞어 쓰면 안 됩니다.

    청크 전략(Chunking Strategy, 문서 분할 방식)

    RAG 디버깅을 하다 보면 임베딩 모델보다 청크 전략이 더 큰 영향을 주는 경우가 많습니다. 문서를 너무 크게 자르면 검색 결과가 뭉뚱그려지고, 너무 잘게 자르면 문맥이 끊깁니다. 특히 FAQ, 기술 문서, 설정 가이드처럼 구조가 뚜렷한 문서는 헤더 기준 분할이 꽤 중요합니다.

    검색 품질과 임베딩 모델 선택

    임베딩 모델 선택은 단순히 성능 좋은 모델을 고르는 문제가 아닙니다. 문서 언어, 도메인, 비용, 지연 시간, 운영 방식까지 같이 봐야 합니다. 예를 들어 한국어 문서 비중이 높은데 영어 중심 평가만 믿고 가면 검색 적중률이 기대보다 낮게 나올 수 있습니다. 반대로 내부 문서가 짧은 영어 설정 파일 위주라면 과한 모델이 꼭 필요한 것도 아니고요.

    실전 구현: 안정적인 LangChain RAG 기본 골격

    아래 예시는 가장 기본적인 구조입니다. 현재 LangChain 생태계는 패키지가 분리되어 있어서, 문서 로더는 <code>langchain-community, OpenAI 임베딩은 langchain-openai, 텍스트 분할기는 langchain-text-splitters를 함께 설치하는 쪽이 안전합니다. 핵심 흐름은 문서를 읽고, 청크를 만들고, 임베딩한 뒤, 벡터 저장소에 넣고, 검색기로 바로 검증하는 겁니다.

    1. 문서를 로드합니다.
    2. 청크 크기와 겹침(overlap)을 정합니다.
    3. 임베딩 모델명을 명시적으로 고정합니다.
    4. 벡터 저장소를 새로 생성하거나, 메타데이터와 함께 관리합니다.
    5. 질문 샘플로 검색 결과를 직접 검증합니다.
    python -m venv .venv
    source .venv/bin/activate
    pip install -U langchain langchain-community langchain-openai langchain-text-splitters faiss-cpu pypdf

    환경 변수도 먼저 분리해두는 게 좋습니다. 운영하다 보면 테스트 키와 운영 키가 섞여서 엉뚱한 장애를 만들기도 하거든요.

    export OPENAI_API_KEY="your_api_key"
    export SOURCE_DOCS_DIR="./docs"
    export VECTOR_DIR="./vector_store"

    이 코드는 PDF 문서를 로드해 청크를 나누고, 빈 청크를 제거한 뒤, 임베딩을 생성해 FAISS 인덱스로 저장하는 예시입니다. 실무에서는 OpenAIEmbeddings()를 기본값으로 두기보다 모델명을 명시해두는 편이 추적하기 쉽습니다.

    import os
    from pathlib import Path
    
    from langchain_community.document_loaders import PyPDFDirectoryLoader
    from langchain_community.vectorstores import FAISS
    from langchain_openai import OpenAIEmbeddings
    from langchain_text_splitters import RecursiveCharacterTextSplitter
    
    DOCS_DIR = Path(os.environ.get("SOURCE_DOCS_DIR", "./docs"))
    VECTOR_DIR = os.environ.get("VECTOR_DIR", "./vector_store")
    
    loader = PyPDFDirectoryLoader(str(DOCS_DIR))
    docs = loader.load()
    
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=800,
        chunk_overlap=120,
        separators=["\n\n", "\n", ". ", " ", ""]
    )
    chunks = splitter.split_documents(docs)
    chunks = [chunk for chunk in chunks if chunk.page_content and chunk.page_content.strip()]
    
    embeddings = OpenAIEmbeddings(model="text-embedding-3-large")
    vectorstore = FAISS.from_documents(chunks, embeddings)
    vectorstore.save_local(VECTOR_DIR)
    
    print(f"documents={len(docs)}")
    print(f"chunks={len(chunks)}")

    여기서 제가 꼭 넣는 줄이 있습니다. 바로 빈 청크 제거입니다. 이거 하나만 넣어도 나중에 검색 이상 현상이 생겼을 때 원인 추적이 훨씬 쉬워집니다.

    LangChain 임베딩 문제를 줄이기 위한 문서 로딩과 청크 분할 구성 다이어그램

    실전 구현 단계에서 어떤 순서로 데이터가 이동하는지, 그리고 어느 단계에서 검증 로그를 남겨야 하는지 시각적으로 보여주는 이미지입니다.

    LangChain RAG 오류를 줄이는 검증 코드

    RAG 디버깅은 “만들었다”로 끝나지 않습니다. 인덱스를 만든 직후 검색 결과를 바로 확인해야 합니다. 저는 보통 아래처럼 샘플 질의를 몇 개 정해서 바로 돌려봅니다.

    한 가지 주의할 점도 있습니다. FAISS.load_local(..., allow_dangerous_deserialization=True)는 로컬에 저장한 신뢰 가능한 인덱스를 다시 읽을 때만 쓰는 옵션입니다. 외부에서 받은 인덱스 파일에 이 옵션을 켜는 건 위험합니다.

    from langchain_community.vectorstores import FAISS
    from langchain_openai import OpenAIEmbeddings
    
    embeddings = OpenAIEmbeddings(model="text-embedding-3-large")
    vectorstore = FAISS.load_local(
        "./vector_store",
        embeddings,
        allow_dangerous_deserialization=True
    )
    
    queries = [
        "이 문서는 어떤 시스템 구성에 대한 설명인가?",
        "장애 대응 절차는 어떻게 정리되어 있나?",
        "설정 파일 경로와 관련된 설명을 찾아줘"
    ]
    
    for query in queries:
        print(f"\n[QUERY] {query}")
        results = vectorstore.similarity_search(query, k=3)
        for idx, doc in enumerate(results, start=1):
            preview = doc.page_content[:180].replace("\n", " ")
            print(f"{idx}. {preview}")

    이 검증을 해보면 생각보다 빨리 감이 옵니다. 질문은 맞는데 본문 일부가 계속 헛나오면 청크 문제일 가능성이 높고, 전혀 다른 문서가 튀어나오면 임베딩 모델 적합성이나 메타데이터 필터 조건을 먼저 의심해보면 됩니다.

    LangChain 임베딩 문제, 이런 식으로 터집니다

    이 섹션이 핵심입니다. 실제로 많이 마주치는 증상과 원인을 표로 먼저 정리해보겠습니다.

    증상 가능한 원인 우선 조치
    차원 불일치 오류 기존 인덱스를 다른 임베딩 모델로 재사용 벡터 저장소를 새로 생성
    에러는 없는데 검색 품질이 낮음 청크 전략 부적절, 언어 특성 불일치 청크 크기/겹침 조정, 샘플 질의 검증
    임베딩 중간 실패 또는 누락 배치 크기 과다, API 제한, 재시도 부족 배치 축소, 로깅 추가, 재시도 적용
    유사도 검색 결과가 비어 있음 문서 로드 실패, 빈 청크 저장 문서 수와 청크 수를 먼저 출력

    1. 임베딩 모델을 바꿨는데 기존 인덱스를 그대로 쓴 경우

    이건 정말 자주 나옵니다. 테스트 단계에서는 모델을 자주 바꿔보게 되거든요. 그런데 FAISS 같은 벡터 저장소는 기존 벡터 차원을 기준으로 인덱스를 유지하므로, 모델을 바꿨다면 거의 항상 재생성이 안전합니다.

    해결 전략은 단순합니다. 벡터 저장소 디렉터리에 메타 파일을 두고, 어떤 임베딩 모델과 청크 설정으로 생성했는지 기록하세요.

    import json
    from pathlib import Path
    
    meta = {
        "embedding_provider": "openai",
        "embedding_model": "text-embedding-3-large",
        "chunk_size": 800,
        "chunk_overlap": 120,
    }
    
    Path("./vector_store").mkdir(exist_ok=True)
    Path("./vector_store/index_meta.json").write_text(
        json.dumps(meta, ensure_ascii=False, indent=2),
        encoding="utf-8"
    )

    2. 문서는 들어갔는데 검색이 너무 엉뚱한 경우

    처음엔 이게 뭔가 싶었는데, 실제로는 청크 설계 문제가 원인인 경우가 많았습니다. 설정 문서와 장애 대응 문서를 한 덩어리로 넣으면 질문 의도와 상관없이 공통 단어에 끌려가더라고요. 특히 한국어 문서는 줄바꿈, 제목, 코드 블록이 의미 단위를 나누는 데 중요해서, 무작정 문자 수만 기준으로 자르면 손해를 보기 쉽습니다.

    해결 전략은 아래 순서로 잡으면 편합니다.

    • 문서 유형별로 분리합니다. 예: 매뉴얼, 장애일지, 설정 예제
    • 헤더 단위 분할을 우선 고려합니다.
    • 청크 미리보기 로그를 남깁니다.
    • 질문 5개 정도를 고정해 회귀 테스트처럼 돌립니다.

    3. 임베딩 호출은 되는데 일부 문서가 빠지는 경우

    이건 조용히 지나가는 경우가 있어서 더 위험합니다. 배치 처리 중 일부 실패, 비정상 문서 인코딩, 너무 긴 텍스트 같은 이유로 누락될 수 있습니다. 저도 나중에 청크 개수와 저장 결과를 대조하다가 발견한 적이 있었는데, 그때 로그가 없었으면 원인 찾는 데 훨씬 오래 걸렸을 겁니다.

    해결 전략은 임베딩 전후 카운트를 반드시 비교하는 겁니다.

    expected_chunks = len(chunks)
    vectorstore = FAISS.from_documents(chunks, embeddings)
    
    print(f"expected_chunks={expected_chunks}")
    print("index build completed")

    단순해 보여도 이 출력 하나가 삽질 시간을 꽤 줄여줍니다.

    4. 한국어 문서 검색이 유독 약한 경우

    여기서는 문서와 질문의 언어 분포를 먼저 봐야 합니다. 내부 문서는 한국어인데 질의 테스트를 영어 예문으로만 돌리면 평가가 왜곡됩니다. 반대로 영어 설정 파일을 한국어로만 묻는 상황도 마찬가지고요. 결국 임베딩 모델 선택은 데이터 특성과 함께 봐야 합니다.

    저는 보통 아래 기준으로 판단합니다.

    • 문서 언어가 한국어 중심인지
    • 질의 언어가 문서와 같은지
    • 코드, 로그, 설정 키가 많은지
    • 짧은 답을 찾는지, 긴 문맥을 찾는지
    LangChain RAG 오류 대표 사례와 임베딩 문제 해결 도식

    실무에서 자주 겪는 임베딩 오류 유형과 각각의 원인, 우선 확인해야 할 체크포인트를 한눈에 정리한 이미지입니다.

    운영 관점에서 추천하는 LangChain RAG 오류 디버깅 체크리스트

    RAG 디버깅은 감으로 하면 오래 갑니다. 체크리스트로 고정해두면 훨씬 수월합니다.

    1. 문서 수와 청크 수를 출력합니다.
    2. 청크 샘플 3~5개를 직접 눈으로 봅니다.
    3. 사용한 임베딩 모델 정보와 청크 설정을 메타데이터로 남깁니다.
    4. 인덱스 생성 직후 고정 질의 세트를 돌립니다.
    5. 검색 결과 상위 3개를 로그로 저장합니다.
    6. 임베딩 모델 변경 시 기존 인덱스를 재사용하지 않습니다.
    7. 문서 인코딩과 특수문자 깨짐 여부를 확인합니다.

    이 정도만 지켜도 대부분의 LangChain RAG 오류는 꽤 빨리 좁혀집니다. 특히 4번과 5번은 꼭 해보세요. 검색 결과를 눈으로 안 보면, 문제를 LLM 탓으로 착각하기 쉽습니다.

    검증 결과는 어떻게 봐야 할까요?

    검증 단계에서는 정답 문장 하나를 맞췄는지보다, 관련 있는 문서를 안정적으로 가져오느냐를 먼저 봐야 합니다. RAG는 1차 검색이 맞아야 2차 생성도 좋아집니다. 저는 보통 아래 기준으로 확인합니다.

    • 같은 질문을 여러 번 해도 상위 결과가 크게 흔들리지 않는지
    • 문서 제목 또는 섹션 단위로 관련성이 보이는지
    • 질문 표현을 조금 바꿔도 비슷한 결과가 나오는지
    • 한국어 질의와 영어 키워드 혼합 질의에서 성능 차이가 과한지

    상위 3개 문서가 납득 가능한 수준으로 꾸준히 나오기 시작하면, 그다음부터는 프롬프트(prompt, 모델 지시문) 튜닝으로 넘어가면 됩니다. 이 지점이 오면 한숨 돌려도 됩니다. 이거 진짜 체감이 확 오더라고요.

    LangChain RAG 오류 검증을 위한 검색 결과 대시보드와 RAG 디버깅 시각화

    샘플 질의를 기준으로 검색 결과의 일관성과 관련성을 확인하는 검증 화면을 표현한 이미지입니다.

    임베딩 모델 선택 시 현실적인 기준

    모델 이름만 비교하면 판단이 오히려 어려워집니다. 운영 조건까지 같이 적어두면 의사결정이 훨씬 쉬워집니다.

    기준 질문 판단 포인트
    언어 한국어 문서 비중이 높은가? 다국어 대응 여부를 우선 확인
    문서 형태 코드/로그/설정 파일이 많은가? 토큰 분포와 특수문자 처리 성향 확인
    운영 비용 대량 재색인이 자주 필요한가? 배치 처리 비용과 속도 고려
    지연 시간 실시간 임베딩이 필요한가? 오프라인 색인과 온라인 질의 경로 분리

    모델을 바꾸면 다 해결될 줄 알았는데, 실제 원인은 청크 설정이었던 경우가 생각보다 많습니다. 저도 처음엔 모델만 계속 갈아타다가 시간을 꽤 썼습니다. 운영에서는 좋은 모델 하나보다 일관된 파이프라인이 더 중요하더라고요.

    이전 글에서 다룬 문서 청크 설계 글도 함께 읽어보시면 흐름이 더 잘 잡힙니다. 내부 링크를 걸어두면 독자 입장에서도 다음 액션이 분명해서 체류 시간 관리에 도움이 됩니다.

    정리: LangChain 임베딩 문제는 로그와 검증 루틴으로 잡아야 합니다

    오늘 내용만 딱 정리하면 이렇습니다.

    • 차원 불일치가 보이면 임베딩 모델 변경 여부부터 확인합니다.
    • 검색 품질 저하는 청크 전략과 문서 구조를 먼저 봅니다.
    • 조용한 실패를 막으려면 문서 수, 청크 수, 샘플 질의 결과를 반드시 기록합니다.
    • 임베딩 모델 선택은 언어, 문서 유형, 운영 비용까지 같이 판단합니다.

    다음 글에서는 RAG 디버깅을 좀 더 확장해서, 재순위화(Reranking, 검색 결과 재정렬)와 하이브리드 검색(Hybrid Search, 키워드+벡터 검색 결합) 기준도 다뤄볼 예정입니다. 이전 글의 문서 청크 설계 내용과 함께 보면 이해가 더 빨라집니다.

    문서 로딩, 청크 분할, 임베딩 생성, 인덱스 재생성, 검색 검증까지의 해결 흐름을 한 장으로 요약한 이미지입니다.

    자주 묻는 질문

    Q1. 에러가 없는데도 답변 품질이 낮으면 어디부터 봐야 하나요?

    A. LLM보다 먼저 검색 결과를 보셔야 합니다. 상위 문서 3개가 질문과 관련이 없으면, 거의 항상 임베딩이나 청크 쪽 문제입니다.

    Q2. 벡터 저장소는 언제 다시 만들어야 하나요?

    A. 임베딩 모델을 바꿨거나, 청크 전략을 크게 바꿨거나, 문서 구조가 크게 바뀌었다면 재생성이 안전합니다.

    Q3. RAG 디버깅에서 가장 먼저 자동화할 것은 뭔가요?

    A. 고정 질의 세트 기반의 회귀 테스트입니다. 같은 질문에 대한 검색 결과를 저장해두면 변경 영향이 바로 보입니다.