Published on

LangChain+Responses로 멀티모달 RAG 구축 실전

Authors

서로 다른 모달리티(텍스트, 이미지, PDF 스캔 등)를 한 파이프라인에서 검색하고 답변까지 연결하는 멀티모달 RAG는 “데이터는 많은데 답을 못 찾는” 상황을 크게 줄여줍니다. 특히 현업에서는 제품 매뉴얼 PDF(텍스트)와 현장 사진(이미지), UI 캡처(이미지), 설계서(텍스트)가 동시에 존재하는 경우가 흔하고, 사용자는 이들을 구분하지 않고 질문합니다.

이 글에서는 LangChain을 오케스트레이션 레이어로 두고, LLM 호출은 OpenAI의 Responses API를 사용해 멀티모달 입력과 출력을 안정적으로 다루는 실전 구성을 다룹니다. 핵심은 다음 3가지입니다.

  • 멀티모달 문서(텍스트+이미지)를 “검색 가능한 표현”으로 바꿔 인덱싱하기
  • 질의 시 텍스트 질문과 이미지 질문을 동일한 검색-생성 플로우로 합치기
  • 운영 관점에서 재시도, 레이트리밋, 관측성(로그/트레이싱)을 설계하기

운영에서 재시도 설계는 필수라서, 레이트리밋 대응은 아래 글과 함께 보면 더 빠르게 정리됩니다.


아키텍처 개요: 멀티모달 RAG의 2-인덱스 전략

멀티모달 RAG에서 가장 흔한 실전 패턴은 “원본은 멀티모달로 보관하되, 검색은 텍스트 중심으로 단순화”하는 것입니다. 이유는 명확합니다.

  • 벡터 검색 인프라(FAISS, pgvector, Pinecone 등)는 텍스트 임베딩 기반이 가장 성숙함
  • 이미지 임베딩도 가능하지만, 최종 답변 근거(근거 문장, 페이지, 캡처 위치)를 설명하기가 텍스트가 훨씬 쉬움

그래서 다음 전략을 추천합니다.

  1. 텍스트 인덱스: PDF 텍스트, HTML, 위키, 코드, 로그 등은 그대로 청크로 쪼개 임베딩
  2. 이미지 인덱스(텍스트화): 이미지는 LLM 또는 OCR로 “이미지 캡션/요약/구조화 설명”을 만들어 텍스트로 임베딩

즉, 이미지 자체를 바로 검색하기보다는 “이미지의 의미를 텍스트로 변환한 파생 문서”를 검색합니다. 원본 이미지는 메타데이터로 연결해 근거로 첨부합니다.


데이터 모델: Document에 무엇을 넣을 것인가

LangChain의 Documentpage_content(검색 대상 텍스트)와 metadata(추적/근거/필터링)를 분리해 담는 게 중요합니다.

권장 메타데이터 예시

  • source_type: pdf, image, wiki, ticket
  • source_uri: S3 URL, Git URL, Confluence URL 등
  • doc_id: 원본 문서 식별자
  • chunk_id: 청크 식별자
  • page: PDF 페이지
  • bbox: 스캔 이미지에서 텍스트 영역 좌표(가능하면)
  • created_at: 인덱싱 시각

이 구조를 지키면 “답변에 근거 링크/페이지/이미지”를 붙이거나, 특정 소스만 필터링하는 검색이 쉬워집니다.


인덱싱 1: 텍스트 문서 청크ing과 임베딩

텍스트 인덱싱은 정석대로 가되, 멀티모달 RAG에서는 청크 정책이 특히 중요합니다.

  • PDF/매뉴얼: 제목 기반 분할이 가능하면 제목 우선, 불가하면 문단+길이 기반
  • 로그/코드: 함수/섹션 단위 분할이 유리
  • FAQ/티켓: 한 항목을 한 청크로 유지

아래는 LangChain 기반의 단순 예시입니다.

from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_core.documents import Document

splitter = RecursiveCharacterTextSplitter(
    chunk_size=900,
    chunk_overlap=150,
    separators=["\n\n", "\n", " ", ""]
)

def split_docs(raw_docs: list[Document]) -> list[Document]:
    out = []
    for d in raw_docs:
        for i, chunk in enumerate(splitter.split_text(d.page_content)):
            out.append(Document(
                page_content=chunk,
                metadata={**d.metadata, "chunk_id": i}
            ))
    return out

청크 크기는 정답이 없지만, 멀티모달 RAG에서는 “이미지에서 생성된 캡션 텍스트”와 “원본 텍스트 청크”가 함께 검색되므로, 너무 작은 청크는 검색 결과가 산만해질 수 있습니다. 대체로 700에서 1200 사이를 먼저 시도하고, 검색 품질과 토큰 비용을 보고 조정하세요.


인덱싱 2: 이미지에서 검색용 텍스트 만들기(캡션/요약)

이미지를 검색에 쓰려면 결국 텍스트가 필요합니다. 방법은 2가지가 대표적입니다.

  • OCR 중심: 화면 캡처, 문서 스캔처럼 “글자”가 핵심인 이미지에 유리
  • 비전 LLM 캡션: 사진, 다이어그램, UI 흐름처럼 “의미”가 핵심인 이미지에 유리

실전에서는 둘을 섞습니다.

  • OCR로 텍스트를 뽑고
  • 비전 LLM에 OCR 결과와 이미지를 같이 주고 “검색용 설명”을 생성

이때 Responses API를 쓰면 멀티모달 입력을 한 요청으로 구성하기가 편합니다. 아래 코드는 개념 예시이며, 실제 SDK 버전별 필드는 다를 수 있으니 사용 중인 SDK 문서를 확인하세요.

import base64
from openai import OpenAI

client = OpenAI()

def b64_image(path: str) -> str:
    with open(path, "rb") as f:
        return base64.b64encode(f.read()).decode("utf-8")

def image_to_retrieval_text(image_path: str, ocr_text: str | None = None) -> str:
    img_b64 = b64_image(image_path)

    prompt = (
        "당신은 검색 인덱싱용 캡션 생성기입니다. "
        "이미지를 보고 검색에 유리한 키워드 중심으로 요약하세요. "
        "UI 캡처면 메뉴 경로, 버튼 라벨, 에러 메시지를 우선 포함하세요. "
        "다이어그램이면 구성요소와 관계를 포함하세요. "
        "출력은 한글로 5~10문장 이내."
    )

    if ocr_text:
        prompt += "\n\n추출된 OCR 텍스트:\n" + ocr_text

    resp = client.responses.create(
        model="gpt-4.1-mini",
        input=[
            {
                "role": "user",
                "content": [
                    {"type": "input_text", "text": prompt},
                    {
                        "type": "input_image",
                        "image_url": f"data:image/png;base64,{img_b64}"
                    }
                ]
            }
        ]
    )

    # SDK에 따라 텍스트 추출 방식이 다를 수 있음
    return resp.output_text

이 결과 텍스트를 Document.page_content에 넣고, metadata에는 원본 이미지 경로(예: S3)와 캡처 시각, 서비스/화면 이름 등을 넣습니다.


벡터스토어 구성: 하나로 합칠까, 둘로 나눌까

실전에서는 보통 “하나의 벡터 인덱스에 텍스트 청크와 이미지 캡션 청크를 같이 넣는 방식”이 단순하고 잘 동작합니다.

  • 장점: 검색 단계가 단일화, 랭킹/필터링이 쉬움
  • 단점: 이미지 캡션이 과도하게 일반적이면 텍스트 청크를 오염시킬 수 있음

오염을 줄이는 팁

  • 이미지 캡션에 반드시 고유 키워드(화면명, 제품명, 에러코드)를 포함
  • source_type을 메타데이터로 두고, 검색 시 가중치를 다르게 주거나 후처리에서 다양성 보장

검색 단계: 멀티모달 질의를 텍스트 질의로 정규화

사용자 입력이

  • 텍스트만 있는 질문
  • 이미지와 함께 “이 화면에서 왜 에러가 나요” 같은 질문

둘 다 들어올 수 있습니다.

전략은 동일합니다.

  1. 사용자 입력(텍스트+이미지)을 LLM에 넣고 “검색용 질의문”을 생성
  2. 그 질의문으로 벡터 검색
  3. 검색 결과를 근거로 최종 답변 생성

검색용 질의문 생성은 짧고 키워드 중심이 좋습니다.

from openai import OpenAI

client = OpenAI()

def build_retrieval_query(user_text: str, image_b64: str | None = None) -> str:
    sys = (
        "너는 RAG 검색어 생성기다. "
        "사용자 질문에서 제품명, 기능명, 에러코드, 화면 라벨, 파일명, 설정 키를 추출해 "
        "검색에 적합한 짧은 질의로 재작성하라. "
        "불필요한 수사는 제거하고 키워드 중심으로. "
        "출력은 한 줄."
    )

    content = [{"type": "input_text", "text": user_text}]
    if image_b64:
        content.append({
            "type": "input_image",
            "image_url": f"data:image/png;base64,{image_b64}"
        })

    resp = client.responses.create(
        model="gpt-4.1-mini",
        input=[
            {"role": "system", "content": [{"type": "input_text", "text": sys}]},
            {"role": "user", "content": content}
        ]
    )
    return resp.output_text.strip()

이렇게 하면 이미지가 포함된 질문도 “검색 가능한 텍스트 질의”로 정규화되어 벡터 검색 파이프라인을 그대로 재사용할 수 있습니다.


생성 단계: Responses로 근거 기반 답변 만들기

생성 단계에서 중요한 것은 “근거를 모델 입력에 어떻게 넣을지”입니다.

  • 검색 결과 청크를 그대로 붙이면 토큰이 급격히 늘어납니다.
  • 청크가 많아질수록 환각 가능성도 커집니다.

실전 패턴

  • Top k를 작게(예: 5~8) 시작
  • 각 청크에 source_uri, page, chunk_id를 붙여 “인용 가능한 형태”로 전달
  • 답변 포맷에 “근거” 섹션을 강제
from openai import OpenAI

client = OpenAI()

def format_context(docs):
    blocks = []
    for d in docs:
        meta = d.metadata
        ref = f"source_uri={meta.get('source_uri')} page={meta.get('page')} chunk_id={meta.get('chunk_id')}"
        blocks.append(f"[CONTEXT {ref}]\n{d.page_content}")
    return "\n\n".join(blocks)

def answer_with_citations(user_question: str, retrieved_docs) -> str:
    context = format_context(retrieved_docs)

    instruction = (
        "너는 사내 기술지원 RAG 어시스턴트다. "
        "아래 CONTEXT에 근거해서만 답하라. "
        "근거가 부족하면 '추가 정보 필요'를 명시하고 필요한 정보를 질문하라. "
        "답변 마지막에 근거로 사용한 CONTEXT의 source_uri와 page를 나열하라."
    )

    resp = client.responses.create(
        model="gpt-4.1",
        input=[
            {
                "role": "user",
                "content": [
                    {"type": "input_text", "text": instruction},
                    {"type": "input_text", "text": "\n\n" + context},
                    {"type": "input_text", "text": "\n\n질문: " + user_question}
                ]
            }
        ]
    )

    return resp.output_text

여기서 model은 비용과 품질 요구에 따라 조정합니다.

  • 검색어 생성/이미지 캡션: 상대적으로 저렴한 모델
  • 최종 답변: 고품질 모델

이 “2단계 모델 전략”은 멀티모달 RAG에서 비용 대비 효과가 좋습니다.


LangChain과 Responses의 역할 분담

LangChain을 쓰는 이유는 LLM 호출 자체보다도 “파이프라인 구성 요소를 교체 가능하게 만드는 것”에 있습니다.

  • 로더: PDF, HTML, S3, DB 등
  • 스플리터: 문서 구조에 맞춘 청크
  • 벡터스토어: 로컬 FAISS에서 운영 Pinecone/pgvector로 전환
  • 리트리버: 필터링, MMR, 하이브리드 검색

Responses API는 멀티모달 입력을 포함한 “모델 호출의 표준화”에 집중합니다.

즉,

  • LangChain: 데이터/검색 파이프라인
  • Responses: 멀티모달 추론과 텍스트 생성

이렇게 분리하면 특정 클라우드 벡터DB로 옮기거나, 캡션 모델만 바꾸는 변경이 쉬워집니다.


품질을 올리는 실전 팁 7가지

1) 이미지 캡션에 “검색 키”를 강제

캡션이 “로그인 화면입니다” 수준이면 검색에 도움이 안 됩니다.

  • 화면명, 메뉴 경로, 버튼 라벨, 에러코드, 제품 버전
  • 다이어그램이면 컴포넌트 명칭과 연결 관계

프롬프트에 “반드시 포함”을 넣고, 누락 시 재생성하는 가드도 고려하세요.

2) 문서별로 청크 정책을 다르게

매뉴얼과 티켓은 구조가 다릅니다. 하나의 chunk_size로 통일하면 검색이 흔들립니다.

  • 매뉴얼: 섹션 단위
  • 티켓: 한 건 단위
  • 릴리즈노트: 버전 단위

3) MMR로 다양성 확보

유사한 청크만 8개 뽑히면 답변이 편향됩니다. MMR(Maximal Marginal Relevance)을 켜면 다양성이 좋아집니다.

4) “근거 부족”을 시스템 규칙으로 강제

RAG는 근거가 없을 때 솔직해야 합니다. “모르면 모른다”가 품질입니다.

5) 인용 포맷을 고정

운영에서 가장 많이 하는 일이 “이 답이 어디서 나왔지” 추적입니다. source_uri, page, chunk_id는 거의 필수입니다.

6) 레이트리밋과 재시도는 초기에 설계

멀티모달은 요청당 비용과 지연이 커서, 재시도 정책이 없으면 장애가 바로 사용자 경험으로 번집니다. 429 대응 설계는 아래 글도 참고하세요.

7) 상태가 있는 플로우는 그래프로 관리

질문 정규화 -> 검색 -> 답변 생성 -> 추가 질문(필요 시) 같은 멀티스텝이 늘어나면, 단일 함수 체인보다 상태 머신이 안전합니다. 이때는 LangGraph 스타일이 잘 맞습니다.


운영 체크리스트: 장애·비용·보안

관측성

  • 요청 단위로 trace_id를 발급하고, 검색 질의문/Top 문서/최종 답변에 모두 연결
  • 어떤 source_uri가 자주 인용되는지 집계하면 문서 품질 개선 포인트가 보입니다

비용

  • 이미지 캡션은 “최초 1회 생성 후 캐시”가 기본입니다
  • 최종 답변에 넣는 컨텍스트 토큰을 줄이기 위해 “청크 요약 캐시”도 고려하세요

보안

  • source_uri가 외부로 노출되면 안 되는 경우, 사용자 권한에 따라 마스킹하거나 내부 링크 프록시를 둡니다
  • 인덱싱 시 PII 제거 파이프라인을 별도로 두는 것이 안전합니다

마무리: 멀티모달 RAG의 핵심은 검색 정규화

멀티모달 RAG를 어렵게 만드는 지점은 “이미지를 어떻게 검색에 얹을까”인데, 실전에서는 이미지 자체를 직접 검색하기보다 “이미지를 텍스트로 정규화해서 기존 텍스트 RAG에 합류시키는 방식”이 가장 빠르고 안정적입니다.

정리하면,

  • 인덱싱에서 이미지 -> 검색용 텍스트(캡션/요약) 변환을 잘 만들고
  • 질의에서도 텍스트+이미지 -> 검색 질의문으로 정규화한 뒤
  • Responses API로 멀티모달 입력을 깔끔하게 처리하면

LangChain 기반의 RAG 파이프라인을 크게 바꾸지 않고도 멀티모달 확장이 가능합니다.

다음 단계로는 “질문이 모호할 때 추가 질문을 자동 생성하고, 사용자의 추가 입력을 받아 재검색하는 루프”를 붙이는 것을 추천합니다. 이때 상태가 늘어나므로 그래프 기반 오케스트레이션이 특히 유용합니다.