Featured Post

LlamaIndex로 PDF와 PostgreSQL을 연결해보니, RAG 데이터 연결이 훨씬 편해졌습니다

LlamaIndex - 데이터 연결 관련 이미지

제가 LlamaIndex를 제대로 만져보게 된 이유

요즘 개발자들끼리 이야기하다 보면 AI 바이브 코딩이라는 말을 정말 자주 하게 됩니다. 저도 처음엔 그냥 “프롬프트 잘 쓰면 되는 거 아닌가?” 정도로 생각했어요. 그런데 막상 회사 일에 붙여보려고 하니까, 생각보다 중요한 건 프롬프트보다 데이터를 어떻게 연결하느냐더라고요.

특히 RAG(Retrieval-Augmented Generation) 시스템을 만들 때는 더 그렇습니다. 모델이 아무리 똑똑해도 우리 회사 PDF 문서, 매뉴얼, DB 안에 들어 있는 최신 데이터를 모르면 결국 그럴듯한 헛소리를 하거든요. 개발 오래 하신 분들은 아실 겁니다. “그럴듯한 답변”이 제일 위험합니다. 차라리 에러가 나는 게 낫죠.

제가 이 글을 쓰게 된 계기도 딱 그런 상황 때문이었어요. 며칠 전 팀 프로젝트에서 200페이지가 넘는 PDF 제품 매뉴얼과 PostgreSQL에 들어 있는 판매 데이터를 동시에 LLM에 연결해야 했습니다. 처음엔 속으로 “이거 하루는 잡아야겠네…” 싶었는데, 막상 LlamaIndex로 붙여보니 오전 안에 기본 구조가 잡히더라고요. 뭐랄까, 오래된 공구함에서 딱 맞는 스패너를 찾은 느낌이었습니다.

LlamaIndex로 PDF 문서와 PostgreSQL 데이터를 함께 연결하는 개발 작업 흐름

PDF만 연결했더니 바로 한계가 보이더라고요

처음에는 단순하게 갔습니다. PDF 매뉴얼만 인덱싱해서 질문을 던져봤어요. “A제품의 주요 기능 알려줘” 같은 질문은 꽤 잘 대답했습니다. 매뉴얼에 있는 내용이니까요.

그런데 바로 다음 질문에서 막혔습니다.

“그럼 A제품의 최근 3개월 판매량이랑 주요 기능을 같이 알려줘.”

여기서 문제가 생깁니다. 주요 기능은 PDF에 있는데, 최근 3개월 판매량은 PostgreSQL에 있거든요. PDF만 보고 있는 LLM 입장에서는 알 방법이 없습니다. 그럴 때 가끔 “데이터가 없습니다”라고 정직하게 말하면 귀엽기라도 한데, 어떤 경우엔 대충 숫자를 만들어내는 듯한 답변을 하기도 합니다. 솔직히 이런 답변은 실무에서는 못 씁니다.

그래서 구조를 살짝 바꿨습니다. PDF 같은 비정형 데이터는 Vector Index로 연결하고, PostgreSQL 같은 정형 데이터는 SQL Query Engine으로 연결한 다음, 질문의 성격에 따라 알아서 적절한 쪽으로 보내는 방식으로요.

제가 잡은 전체 구조는 이렇게 단순했습니다

    • PDF 문서는 SimpleDirectoryReader로 읽어오기
    • 문서 내용은 VectorStoreIndex로 인덱싱하기
    • PostgreSQL은 SQLDatabaseNLSQLTableQueryEngine으로 연결하기
    • 두 개의 Query Engine을 RouterQueryEngine으로 묶기
    • 질문에 따라 PDF로 보낼지, DB로 보낼지 LlamaIndex가 판단하게 하기

이게 말로 하면 조금 있어 보이는데, 실제 코드는 생각보다 담백합니다. 예전 같았으면 직접 if문으로 분기하고, 벡터 검색 따로 만들고, SQL 생성 프롬프트 따로 관리하고, 응답 합치는 로직까지 짜야 했을 텐데요. 그런 귀찮은 연결부를 LlamaIndex가 꽤 많이 덜어줍니다.

실제로 작성했던 코드 흐름

아래 코드는 제가 테스트 프로젝트에서 거의 비슷하게 썼던 형태입니다. 물론 운영 환경에서는 API Key 관리, DB 계정 관리, 예외 처리, 로깅을 더 촘촘히 넣어야 합니다. 그래도 전체 그림을 보기에는 이 정도가 가장 편하더라고요.

from llama_index.core import SimpleDirectoryReader, VectorStoreIndex
from llama_index.core.query_engine import RouterQueryEngine
from llama_index.core.tools import QueryEngineTool, ToolMetadata
from llama_index.embeddings.openai import OpenAIEmbedding
from llama_index.llms.openai import OpenAI
from llama_index.core import SQLDatabase
from llama_index.experimental.query_engine import NLSQLTableQueryEngine
from sqlalchemy import create_engine

# PDF 데이터 로딩
documents = SimpleDirectoryReader("./manuals").load_data()

index = VectorStoreIndex.from_documents(
    documents,
    embed_model=OpenAIEmbedding(model="text-embedding-3-small")
)

pdf_engine = index.as_query_engine(similarity_top_k=3)

# PostgreSQL 연결
engine = create_engine("postgresql://user:pass@localhost:5432/sales_db")

sql_database = SQLDatabase(
    engine,
    include_tables=["products", "orders"]
)

sql_engine = NLSQLTableQueryEngine(
    sql_database=sql_database,
    tables=["products", "orders"],
    llm=OpenAI(model="gpt-4o"),
    context_str="""
    products(id, name, category, price)
    orders(id, product_id, quantity, amount, created_at)

    products.name: 제품명
    products.category: 제품 카테고리
    orders.quantity: 주문 수량
    orders.amount: 주문 금액, 원화 기준
    orders.created_at: 주문 일시
    """
)

# 각 데이터 소스를 Tool로 등록
tools = [
    QueryEngineTool(
        query_engine=pdf_engine,
        metadata=ToolMetadata(
            name="pdf_manual",
            description="제품 매뉴얼, 기능 설명, 사용법, 주의사항을 찾을 때 사용"
        )
    ),
    QueryEngineTool(
        query_engine=sql_engine,
        metadata=ToolMetadata(
            name="sales_db",
            description="제품 판매량, 주문 내역, 매출 데이터를 조회할 때 사용"
        )
    )
]

# RouterQueryEngine 생성
router_engine = RouterQueryEngine.from_defaults(
    tools=tools,
    llm=OpenAI(model="gpt-4o"),
    verbose=True
)

# 테스트 질문
response = router_engine.query(
    "A제품의 최근 3개월 판매량과 주요 기능을 같이 알려줘"
)

print(response)

이렇게 해두면 질문에 “판매량”, “주문”, “매출” 같은 뉘앙스가 있으면 sales_db 쪽으로 보내고, “기능”, “설치”, “사용법”, “주의사항” 같은 질문은 pdf_manual 쪽으로 보내는 식으로 동작합니다.

재미있는 건 verbose=True를 켜두면 내부에서 어떤 Tool을 선택했는지 로그로 보인다는 점입니다. 처음 테스트할 때 이 로그가 꽤 도움이 됩니다. “왜 PDF로 가야 할 질문이 DB로 가지?” 같은 걸 바로 눈으로 확인할 수 있거든요.

Selecting query engine tool: sales_db
Generated SQL query:
SELECT SUM(quantity)
FROM orders
WHERE product_id = ...
AND created_at >= ...

Selecting query engine tool: pdf_manual
Retrieving top 3 chunks from vector index...

이 로그를 보고 있으면 약간 신기합니다. 물론 완벽하진 않아요. 가끔 엉뚱한 쪽으로 보내기도 합니다. 그런데 그럴 때는 Tool의 description을 조금 더 구체적으로 고쳐주면 꽤 잘 잡힙니다. 저는 여기서 “설명 문장을 대충 쓰면 라우팅도 대충 된다”는 걸 몸으로 배웠습니다.

제가 부딪히면서 배운 LlamaIndex 데이터 연결 팁

문서만 보면 다 쉬워 보입니다. 그런데 실무 데이터는 늘 지저분합니다. PDF에 빈 페이지가 있고, 테이블 컬럼명은 애매하고, 날짜 포맷은 제각각이고, 어떤 파일은 인코딩이 꼬여 있고요. 개발 20년 해도 이런 건 여전히 사람을 피곤하게 합니다.

아래는 제가 실제로 테스트하면서 “아, 이건 처음부터 알고 갔으면 좋았겠다” 싶었던 것들입니다.

상황 제가 선택한 방식 실제로 느낀 점
Chunk size 설정 512 정도로 시작 너무 크면 엉뚱한 문맥이 섞이고, 너무 작으면 답변이 얇아집니다. 기술 매뉴얼은 512~1024 사이가 무난했어요.
PDF 페이지 추적 파일명, 페이지 번호를 metadata에 저장 나중에 “이 답변이 어디서 나온 건데?”라는 질문을 받을 때 정말 중요합니다.
Embedding 모델 text-embedding-3-small 사용 비용 대비 괜찮았습니다. 한국어 문서도 일반적인 사내 문서 수준에서는 크게 불편하지 않았어요.
SQL 테이블 범위 include_tablestables를 명시 전체 테이블을 다 열어주면 느려지고 위험합니다. 필요한 테이블만 좁혀주는 게 마음도 편합니다.
컬럼 의미 전달 context_str에 설명 추가 LLM은 컬럼명을 보고 추측합니다. amount가 금액인지 수량인지 설명해주는 게 좋습니다.

metadata는 귀찮아도 꼭 넣는 쪽이 좋습니다

개인적으로 metadata는 RAG에서 보험 같은 거라고 생각합니다. 당장 데모 만들 때는 없어도 굴러갑니다. 그런데 실제 사용자에게 보여주는 순간부터 이야기가 달라져요.

사용자는 꼭 묻습니다.

“이 답변은 어떤 문서 몇 페이지 기준이에요?”

이때 출처를 못 보여주면 신뢰도가 확 떨어집니다. 그래서 저는 파일명, 페이지 번호, 문서 버전, 작성일 정도는 가능하면 넣어두는 편입니다. 나중에 답변 아래에 “참고 문서: A제품_매뉴얼_v2.pdf, 37페이지” 이런 식으로 붙일 수 있거든요. 별거 아닌데 사용자 반응이 꽤 다릅니다.

def file_metadata(file_path):
    return {
        "file_name": file_path.split("/")[-1],
        "source_type": "product_manual",
        "version": "2025.06"
    }

documents = SimpleDirectoryReader(
    "./manuals",
    file_metadata=file_metadata
).load_data()

실제로 자주 만난 에러들

LlamaIndex가 편하긴 한데, 그렇다고 마법은 아닙니다. 특히 데이터가 지저분하면 에러는 어김없이 납니다. 저도 테스트하면서 몇 번 혼잣말을 했습니다. “아니, 아까는 됐잖아…” 이런 말이요.

문서 로딩이 제대로 안 됐을 때

가장 먼저 만난 건 이런 류의 에러였습니다.

AttributeError: 'NoneType' object has no attribute 'text'

대부분은 PDF 안에 빈 페이지가 있거나, 스캔본이라 텍스트 추출이 제대로 안 된 경우였습니다. 그래서 저는 문서를 로딩한 직후에 개수와 일부 내용을 꼭 확인합니다. 이거 정말 단순한데, 시간을 꽤 아껴줍니다.

documents = SimpleDirectoryReader("./manuals").load_data()

print("loaded documents:", len(documents))

for doc in documents[:3]:
    print(doc.text[:300])

여기서 내용이 비어 있거나 이상한 문자만 잔뜩 나오면, LlamaIndex 문제가 아니라 문서 추출 문제일 가능성이 큽니다. 스캔 PDF라면 OCR을 먼저 돌려야 하고요.

LLM이 SQL 컬럼명을 잘못 추측할 때

SQL 쪽에서는 이런 에러를 꽤 자주 봤습니다.

psycopg2.errors.UndefinedColumn: column "order_date" does not exist

사람은 created_at을 보면 주문일이라고 이해할 수 있지만, LLM은 가끔 order_date 같은 그럴듯한 컬럼명을 만들어냅니다. 이럴 때는 스키마 설명을 더 자세히 주는 게 좋았습니다.

sql_engine = NLSQLTableQueryEngine(
    sql_database=sql_database,
    tables=["products", "orders"],
    llm=OpenAI(model="gpt-4o"),
    context_str="""
    orders.created_at은 주문이 생성된 날짜와 시간입니다.
    order_date라는 컬럼은 존재하지 않습니다.
    최근 3개월 조건을 만들 때는 orders.created_at을 사용하세요.
    orders.amount는 주문 금액이며 KRW 기준입니다.
    """
)

조금 웃기지만, “존재하지 않습니다”라고 명시해주는 게 도움이 될 때가 있습니다. 사람한테 설명하듯이 써주는 게 의외로 잘 먹힙니다. 이게 LLM 기반 개발의 묘한 재미이기도 하고요.

LlamaIndex를 쓰기 전과 후, 체감 차이가 꽤 컸습니다

예전에는 이런 구조를 직접 만들었습니다. PDF는 PyMuPDF로 읽고, 텍스트 쪼개고, OpenAI Embedding API 호출하고, Faiss에 넣고, SQLAlchemy로 DB 붙이고, 질문 분기 로직은 또 따로 만들고요. 만들 수는 있습니다. 개발자니까요. 그런데 그걸 매번 하는 건 솔직히 좀 피곤합니다.

LlamaIndex를 쓰면 이 연결 작업이 많이 줄어듭니다. 아래 표는 제가 느낀 차이를 조금 현실적으로 적어본 겁니다.

작업 직접 구현했을 때 LlamaIndex를 썼을 때
PDF 로딩 PyMuPDF, pdfplumber 등을 직접 조합 SimpleDirectoryReader로 빠르게 시작
문서 인덱싱 Chunk 분할, Embedding 호출, Vector DB 저장을 직접 작성 VectorStoreIndex.from_documents로 기본 구조 완성
SQL 자연어 질의 프롬프트 설계와 SQL 검증 로직을 직접 관리 NLSQLTableQueryEngine으로 빠르게 테스트 가능
여러 데이터 소스 연결 if-else 분기나 별도 라우터 구현 RouterQueryEngine에 Tool 추가
프로토타입 개발 시간 대략 2~3일 빠르면 2~3시간

물론 운영 수준으로 가면 이야기가 또 달라집니다. 권한 체크도 해야 하고, SQL Injection에 가까운 위험한 질의도 막아야 하고, 사용자별 접근 가능한 문서도 구분해야 합니다. 그래도 프로토타입을 빨리 만들고 팀원들과 방향을 맞추는 데는 LlamaIndex가 확실히 좋았습니다.

제가 생각하는 실무 적용 체크리스트

LlamaIndex로 데모가 잘 나온다고 바로 운영에 올리면 조금 불안합니다. 저도 나이 들면서 겁이 많아졌는지, 이제는 “일단 돌아가네?”에서 멈추지 않으려고 합니다. 아래 항목 정도는 확인하고 넘어가는 편이 마음이 편합니다.

    • 문서 출처 표시: 답변마다 파일명, 페이지, 버전을 보여줄 수 있는지 확인합니다.
    • 빈 문서 처리: PDF 로딩 후 텍스트가 비어 있는 문서를 걸러냅니다.
    • 테이블 제한: LLM이 접근 가능한 DB 테이블을 최소한으로 줄입니다.
    • SQL 로그 확인: 생성된 SQL을 로깅하고, 이상한 쿼리가 없는지 봅니다.
    • 응답 검증: 숫자 데이터는 가능하면 원본 쿼리 결과와 함께 검증합니다.
    • 비용 측정: Embedding 비용과 LLM 호출 비용을 테스트 구간에서 미리 계산합니다.
    • Tool description 개선: 라우팅이 이상하면 코드보다 설명 문장부터 손봅니다.

특히 DB를 연결할 때는 조심해야 합니다. 자연어로 SQL을 만들 수 있다는 건 편하다는 뜻이기도 하지만, 반대로 위험한 쿼리도 만들어질 수 있다는 뜻이거든요. 저는 조회 전용 계정을 따로 만들고, 필요한 테이블만 열어두는 방식을 선호합니다. 개발자는 늘 귀찮은 걸 싫어하지만, 장애는 더 싫으니까요.

LlamaIndex가 늘 정답은 아니지만, RAG 데이터 연결에는 꽤 좋은 선택입니다

솔직히 말하면 모든 프로젝트에 LlamaIndex를 쓰겠다는 생각은 아닙니다. 아주 가벼운 기능 하나 넣는 데 LlamaIndex까지 붙이면 오히려 무거울 수 있습니다. 실시간성이 극도로 중요한 시스템이나, 인프라 제약이 큰 환경에서도 한 번 더 따져봐야 하고요.

그런데 사내 문서 검색, 제품 매뉴얼 QA, 내부 데이터 분석 챗봇, PDF와 DB를 함께 보는 업무 도우미 같은 걸 만든다면 이야기가 다릅니다. 이런 경우에는 LlamaIndex 데이터 연결이 꽤 든든합니다. 특히 여러 데이터 소스를 하나의 질문 인터페이스로 묶어야 할 때 장점이 잘 보입니다.

제가 느낀 가장 큰 장점은 “초반 삽질이 줄어든다”는 거였습니다. 개발 오래 하다 보면 새 기술을 볼 때 괜히 냉소적으로 보게 될 때가 있잖아요. 저도 그렇습니다. 그런데 LlamaIndex는 적어도 RAG의 데이터 연결 영역에서는 꽤 실용적이었습니다. 화려한 말보다, 오전에 붙여서 오후에 팀원에게 보여줄 수 있다는 게 더 중요하니까요.

이 글은 이런 분들이 읽으면 특히 도움이 될 것 같습니다.

    • PDF, Word, Markdown 같은 문서를 LLM에 연결하고 싶은 개발자
    • PostgreSQL이나 MySQL 데이터를 자연어로 조회하는 기능을 만들고 싶은 분
    • LangChain이 조금 무겁거나 복잡하게 느껴졌던 분
    • RAG 프로토타입을 빠르게 만들어 팀에 보여줘야 하는 실무자
    • AI 바이브 코딩을 실제 업무 코드로 가져오고 싶은 개발자

저는 다음 프로젝트에서도 LlamaIndex를 한 번 더 써볼 생각입니다. 다만 이번에는 처음부터 metadata와 권한 모델을 제대로 잡고 들어가려고요. 경험상 이런 건 나중에 고치려면 두 배로 귀찮습니다.

데이터 연결 때문에 RAG가 막막했다면, 가볍게라도 LlamaIndex로 한 번 붙여보세요. 생각보다 금방 감이 옵니다. 그리고 아마 이런 생각이 드실 겁니다. “아, 이 정도면 우리 팀에서도 한번 해볼 만하겠는데?”

이 글은 2025년 6월 기준 LlamaIndex 0.12.x 버전을 기준으로 다듬었습니다. LlamaIndex는 버전별로 API가 바뀌는 경우가 있으니, 실제 적용 전에는 공식 문서와 현재 설치된 패키지 버전을 꼭 함께 확인해보시는 걸 권합니다.

댓글