본문으로 건너뛰기
홈
기술
기술 전체
프로그래밍68
컴퓨터 과학63
AI48
웹 개발36
인프라33
데이터31
소프트웨어 공학18
소개
← 목록으로AI › 개발 도구 › LangChain

3. RAG 2단계 : 데이터 기반 대화

목차

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

사용자 질의를 임베딩하고 데이터 소스에서 유사 문서를 검색 후 프롬프트에 컨텍스트로 활용하는 과정으로 외부 자료에서 얻은 컨텍스트를 제공해 LLM이 생성 정확도를 높이는 기법이다.

  • RAG를 사용하지 않은 경우 : LLM은 오직 사전 학습한 데이터를 기반으로 동작함으로 최신 정보를 반영하지 못한다.

RAG 시스템의 세 가지 기본 단계

  • 인덱싱 : 외부 데이터 소스를 전처리한 후, 데이터를 나타내는 임베딩을 손쉽게 조회하도록 벡터 저장소에 저장
    • 데이터 로드 → 데이터 분할 → 임베딩 → 벡터 스토어 저장
  • 검색 : 질문을 바탕으로 벡터 저장소에 보관된 관련 임베딩 및 데이터 추출
  • 생성 : 원래 프롬프트와 검색 증강 생성에서 활용한 관련 문서를 종합해 하나의 최종 프롬프트를 구성한 후, LLM에 전달해 예측 수행

스크린샷 2026-01-20 오전 10.15.48.png

[인덱싱 단계]

아래 PGVector 예제는 2장에서 준비한 PostgreSQL/pgvector를 사용한다. 실행 전에 PGVECTOR_URL 환경 변수에 본인 개발 DB의 연결 URL을 넣고, 문서 파일 data/hello.txt와 로컬 Ollama 임베딩 모델을 준비한다. DB 사용자·비밀번호를 소스 코드나 문서에 고정하지 않는다. 실제 운영에서는 이 사용자에게 필요한 테이블 권한만 준다.

from langchain_community.document_loaders import TextLoader
from langchain_ollama import OllamaEmbeddings
from langchain_postgres import PGVector
from langchain_text_splitters import RecursiveCharacterTextSplitter

import os
connection = os.environ["PGVECTOR_URL"]

# 문서를 로드 후 분할
raw_documents = TextLoader('data/hello.txt', encoding="utf-8").load()
text_splitter = RecursiveCharacterTextSplitter(chunk_size=10, chunk_overlap=3)
documents = text_splitter.split_documents(raw_documents)

# 문서에 대한 임베딩 생성
embedding_model = OllamaEmbeddings(model='embeddinggemma:300m')

db = PGVector.from_documents(documents, embedding_model, connection=connection)

검색단계는 사용자 질문과 저장된 임베딩 사이에서 코사인 계산을 수행해 인덱싱한 문서에서 관련 청크를 추출

  1. 입력된 질의를 임베딩 형태로 변환
  2. 벡터 저장소에서 사용자 질의와 가장 유사한 임베딩 산출
  3. 관련 문서 임베딩과 해당 청크를 조회

[벡터 저장소에서 관련 문서를 검색하는 동시에 문서를 인덱싱하는 흐름으로, 계층적 탐색 소형 세계(Hierarchical Navigable Small World, HNSW) 상자는 문서와 질의 간의 유사도 계산과정을 나타냄]

스크린샷 2026-01-20 오전 10.23.54.png

[검색 단계]

retriever = db.as_retriever()
query = '이름은?'
print(retriever.invoke(query))

as_retriever 메서드는 질의 임베딩과 벡터 저장소에서 실행하는 유사도 검색 계산 로직을 추상화하여 관련 문서를 효과적으로 검색하도록 설계됐다. 또한, 인자 k로 벡터 저장소에서 가져올 관련 문서의 수를 결정

# 벡터 저장소에서 2개의 관련 문서 검색
retriever = db.as_retriever(search_kwargs={'k':2})

# 관련 문서 받아오기
docs = retriever.invoke(query)

print(f"결과 : {docs}")

문서를 많이 검색한다고 반드시 더 우수한 결과가 나온다는 보장은 없다. 문서 검색 횟수가 늘어날수록 애플리케이션의 성능은 저하되고, 프롬프트의 크기(및 이에 따른 호출 비용)는 커지며, 무관한 정보가 포함된 청크가 검색된 가능성이 높아진다. 이러한 현상은 LLM이 허위 정보를 생성하는 원인이 된다.

[생성단계]

retriever = db.as_retriever()
prompt = ChatPromptTemplate.from_template("""
다음 컨텍스트만 사용해 질문을 답하세요.
컨텍스트:{context}
질문:{question}
""")

llm = ChatOllama(model=gemma_model)
chain = prompt | llm
result = chain.invoke({'context':docs, 'question':query})
print("답변 : ", result)

스크린샷 2026-01-20 오전 10.45.34.png

[단일 함수로 캡슐화]


retriever = db.as_retriever()

prompt = ChatPromptTemplate.from_template("""
다음 컨텍스트만 사용해 질문을 답하세요.
컨텍스트:{context}
질문:{question}
""")

llm = ChatOllama(model=gemma_model)

@chain
def qa(input):
    # 관련 문서 검색
    docs = retriever.invoke(input)
    # 프롬프트 포메팅
    formatted = prompt.invoke({'context':docs, 'question':input})
    # 답변 생성
    answer = llm.invoke(formatted)

    return answer

result = qa.invoke(query)
print("@chain 답변 : " ,result)

견고한 RAG 시스템을 구축하기 위한 고려사항

  • 사용자마다 입력 품질이 다르면 어떻게 처리할까?
  • 다양한 데이터 소스에서 데이터를 검색하려면 쿼리를 어떻게 라우팅할까?
  • 자연어 대상 데이터 소스의 쿼리 언어로 전환하는 방법은 무엇인가?
  • 인덱싱 과정(임베딩, 텍스트분할 등)의 최적화 방안 은 무엇인가?

[RAG 시스템의 정확도를 극대화하는 효과적인 방안]

스크린샷 2026-01-20 오전 11.02.53.png

쿼리 변환

기본 RAG 시스템은 사용자가 입력한 쿼리의 품질에 과도한 영향을 받는다. 그래서 쿼리의 품질이 안좋으면 정확한 출력을 도출하지 못하는 경우가 발생하여, 불완전한 쿼리나 모호한 쿼리, 미흡하게 표현된 쿼리가 모델의 환각을 일으킬 확률이 크다.

쿼리 변환(Query Transformation)은 사용자 입력을 수정하는 전략 중 하나이다.

[추상화 수준에 따라 사용자 쿼리를 변환하는 다양한 방식]

스크린샷 2026-01-20 오전 11.08.06.png

1. 재작성-검색-읽기(RRR: Rewrite-Retrieve-Read)

검색을 수행하기 전 LLM에 사용자 쿼리를 재작성하도록 프롬프트를 전송.

[불필요한 정보를 담은 쿼리를 호출]

from langchain_community.document_loaders import TextLoader
from langchain_core.prompts import ChatPromptTemplate
from langchain_ollama import OllamaEmbeddings, ChatOllama
from langchain_postgres import PGVector
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_core.runnables import chain
from config.load_sys import gemma_model

import os
connection = os.environ["PGVECTOR_URL"]

# 문서를 로드 후 분할
raw_documents = TextLoader('data/sample2.txt', encoding="utf-8").load()
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=20)
documents = text_splitter.split_documents(raw_documents)

# 문서에 대한 임베딩 생성
embedding_model = OllamaEmbeddings(model='embeddinggemma:300m')

db = PGVector.from_documents(documents, embedding_model, connection=connection)

retriever = db.as_retriever()

prompt = ChatPromptTemplate.from_template("""
다음 컨텍스트만 사용해 질문을 답하세요.
컨텍스트:{context}
질문:{question}
""")

llm = ChatOllama(model=gemma_model)

@chain
def qa(input):
    # 관련 문서 검색
    docs = retriever.invoke(input)
    # 프롬프트 포메팅
    formatted = prompt.invoke({'context':docs, 'question':input})
    # 답변 생성
    answer = llm.invoke(formatted)

    return answer

query = '일어나서 이를 닦고 뉴스를 읽었어요. 그러다 전자레인지에 음식을 넣어둔 걸 깜빡했네요. 고대 그리스 철학사의 주요 인물은 누구인가요?'
result = qa.invoke(query)
print("답변 : " ,result)

[답변]

답변 :  content='문맥에서 고대 그리스 철학사의 주요 인물에 대한 직접적인 답변을 찾을 수 없습니다. 소크라테스와 소피스트들이 언급되어 있지만, 이들이 주요 인물인지에 대한 정보는 제공되지 않습니다. 따라서 저는 모릅니다.' additional_kwargs={} response_metadata={'model': 'gemma3:12b', 'created_at': '2026-01-20T02:31:53.508616Z', 'done': True, 'done_reason': 'stop', 'total_duration': 10803370458, 'load_duration': 110077958, 'prompt_eval_count': 2397, 'prompt_eval_duration': 8529900750, 'eval_count': 57, 'eval_duration': 2147198954, 'logprobs': None, 'model_name': 'gemma3:12b', 'model_provider': 'ollama'} id='lc_run--019bd93e-616f-7073-8151-526089eacbe1-0' tool_calls=[] invalid_tool_calls=[] usage_metadata={'input_tokens': 2397, 'output_tokens': 57, 'total_tokens': 2454}

사용자 쿼리가 상관없는 정보를 전달해 LLM이 질문에 답을 제대로 못함.

재작성-검색-읽기(RRR) 프롬프트 구현

from langchain_community.document_loaders import TextLoader
from langchain_core.prompts import ChatPromptTemplate
from langchain_ollama import OllamaEmbeddings, ChatOllama
from langchain_postgres import PGVector
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_core.runnables import chain
from config.load_sys import gemma_model

import os
connection = os.environ["PGVECTOR_URL"]

# 문서를 로드 후 분할
raw_documents = TextLoader('data/sample2.txt', encoding="utf-8").load()
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=20)
documents = text_splitter.split_documents(raw_documents)

# 문서에 대한 임베딩 생성
embedding_model = OllamaEmbeddings(model='embeddinggemma:300m')

db = PGVector.from_documents(documents, embedding_model, connection=connection)

retriever = db.as_retriever()

prompt = ChatPromptTemplate.from_template("""
당신은 질문-답변(Question-Answer) Task 를 수행한는 AI 어시스턴트 입니다.
검색된 문맥(context)를 사용하여 질문(question)에 답하세요. 
만약, 문맥(context) 으로부터 답을 찾을 수 없다면 '모른다' 고 말하세요. 
한국어로 대답하세요.

#Question: 
{question}

#Context: 
{context}
""")

llm = ChatOllama(model=gemma_model)

@chain
def qa(input):
    # 관련 문서 검색
    docs = retriever.invoke(input)
    # 프롬프트 포메팅
    formatted = prompt.invoke({'context':docs, 'question':input})
    # 답변 생성
    answer = llm.invoke(formatted)

    return answer

query = '일어나서 이를 닦고 뉴스를 읽었어요. 그러다 전자레인지에 음식을 넣어둔 걸 깜빡했네요. 고대 그리스 철학사의 주요 인물은 누구인가요?'
result = qa.invoke(query)
print("답변 : " ,result)

#RRR

rewrite_prompt = ChatPromptTemplate.from_template("""
웹 검색 엔진이 주어진 질문에 답할 수 있도록 더 나은 영문 검색어를 제공해주세요. 쿼리는 \'**\'로 끝내세요.
질문 : {x}

답변: 
""")

def parse_rewriter_output(message):
    return message.content.strip('\'').strip('**')

rewriter = rewrite_prompt | llm | parse_rewriter_output
@chain
def qa_rrr(input):
    #쿼리 재작성
    new_query = rewriter.invoke(input)
    #관련 문서 검색
    docs = retriever.invoke(new_query)
    # 프롬프트 포메팅
    formatted = prompt.invoke({'context': docs, 'question': input})
    # 답변 생성
    answer = llm.invoke(formatted)
    return answer

result = qa_rrr.invoke(query)
print("new 답변 : " ,result)
new 답변 :  content='문맥에 따르면 고대 그리스 철학사의 주요 인물은 피타고라스, 아낙시만드로스, 소크라테스, 플라톤, 파르메니데스, 제논, 에피쿠로스, 탈레스, 아리스토텔레스 등이 있습니다.' additional_kwargs={} response_metadata={'model': 'gemma3:12b', 'created_at': '2026-01-20T02:32:10.110262Z', 'done': True, 'done_reason': 'stop', 'total_duration': 11509358708, 'load_duration': 107017625, 'prompt_eval_count': 2436, 'prompt_eval_duration': 8995720583, 'eval_count': 63, 'eval_duration': 2388275954, 'logprobs': None, 'model_name': 'gemma3:12b', 'model_provider': 'ollama'} id='lc_run--019bd93e-9f88-7893-a649-f9eae3104fa7-0' tool_calls=[] invalid_tool_calls=[] usage_metadata={'input_tokens': 2436, 'output_tokens': 63, 'total_tokens': 2499}

이 기법은 벡터 저장소를 사용할 뿐만 아니라, 웹 검색 같은 다른 검색 과정에도 사용가능하다. 이 방식은 LLM을 두 번 연속으로 수행해야 하기 때문에 체인에 추가적인 지연이 발생한다는 단점이 있다.

2. 다중 쿼리 검색

쿼리 하나만으로 포괄적인 답변을 하는데 피룡한 모든 정보를 충분히 반영하기 어렵다.

다중 쿼리 검색은 초기 쿼리를 바탕으로 LLM에 여러 개의 쿼리를 생성하도록 지시한 후, 데이터 소스에서 각 쿼리에 대한 병렬 검색을 수행한다. 검색 이후 결과들을 프롬프트 컨텍스트에 삽입해 최종 모델 출력물을 생성하는 방식

스크린샷 2026-01-20 오전 11.35.38.png

[다중 쿼리를 위한 프롬프트]

from langchain_core.prompts import ChatPromptTemplate
from langchain_ollama import ChatOllama, OllamaEmbeddings
from langchain_postgres import PGVector

from config.load_sys import gemma_model

import os
connection = os.environ["PGVECTOR_URL"]

# 문서에 대한 임베딩 생성
embedding_model = OllamaEmbeddings(model='embeddinggemma:300m')
# PGVector 벡터 스토어 초기화
db = PGVector(
    embeddings=embedding_model,
    connection=connection,
    use_jsonb=True, # 속도 및 인덱싱 최적화를 위해 JSONB 사용 권장
)

#다중 쿼리를 위한 프롬프트
perspectives_prompt = ChatPromptTemplate.from_template(
'''
당신은 AI 언어 모델 어시스턴트 입니다. 주어진 사용자 질문의 다섯 가지 버전을 생성하여 벡터 데이터베이스에서 관한 문서를 검색하세요.
사용자 질문에 대한 다양한 관점을 생성함으로써 사용자가 거리 기반 유사도 검색의 한계를 극복할 수 있도록 돕는 것이 목표입니다.
이러한 대체 질문을 개행으로 구분하여 제공하세요
원래 질문: {question}
'''
)

llm = ChatOllama(model=gemma_model)

def parse_queries_output(message):
    return message.content.split('\n')

query_gen = perspectives_prompt | llm | parse_queries_output

생성된 쿼리 목록을 활용해 각 쿼리에 대해 관련성이 높은 문서를 병렬로 검색한 후, 문서를 모아 중복을 제거한 전체 관련문서 집합을 생성

### 관련 문서 집합
def get_unique_union(document_lists):
    # 목록 여러 개를 포함한 리스트를 평탄화하고 중복 제거
    deduped_docs = {
        doc.page_content: doc for sublist in document_lists for doc in sublist
    }
    # 고유한 문서만 반환
    return list(deduped_docs.values())

retriever = db.as_retriever()

retrieval_chain = query_gen | retriever.batch | get_unique_union

동일한 검색기로 여러 관련 쿼리를 통해 문서를 검색하는 경우, 일부 문서가 중복될 가능성이 있다.

질문에 대한 답변을 활용할 컨텍스트로 사용하기 전, 중복을 제거해 각 항목이 단 한 번씩만 존재하도록 해야한다. 문서의 내용을 문자열로 활용해 딕셔너리의 키로 지정해 중복된 문서를 제거한다. 각 키에는 단 하나의 항목들만 등록할 수 있기 때문이다. 모든 문서를 순회 후 중복을 제거한 딕셔너리의 값들을 추출한다.

[사용자 질문과 검색된 사전 관련 문서를 결합한 프롬프트를 구성해 예측 생성을 위한 모델 인터페이스]

## 프롬프트 구성
prompt = ChatPromptTemplate.from_template(
'''
다음 컨텍스트만 사용해 질문에 답하세요.
컨텍스트 : {context}
질문 : {question}
'''
)

query = '고대 그리스 철학사의 주요 인물은 누기인가요?'

@chain
def multi_query_qa(input):
    #관련 문서 검색
    docs = retrieval_chain.invoke(input)
    formatted = prompt.invoke({'context':docs, 'question':input})
    return llm.invoke(formatted)

## 실행
print('다중 쿼리 검색\n')
result = multi_query_qa.invoke(query)
print(result.content)
다중 쿼리 검색

컨텍스트에 따르면 고대 그리스 철학사의 주요 인물은 피타고라스, 아낙시만드로스, 소크라테스, 플라톤, 파르메니데스, 제논, 에피쿠로스, 탈레스, 아리스토텔레스 등이 있습니다.

이러한 기법을 효과적으로 활용하려면 각 기법을 독립적인 체인으로 구현하는 것이 중요.

체인을 독립적으로 구성하면 기법을 선택하고 결합하기 편해진다.

3. RAG 융합

RAG 융합은 다중 쿼리 검색과 유사하지만, 모든 검색된 문서에 대해 최종 재정렬 단계를 추가로 실행한다.

재정렬 단계에서는 상소 순위 융합(RRF: Reciprocal Rank Fusion) 알고리즘을 활용해, 서로 다른 검색 결과의 순위를 결합해 하나의 통합된 순위를 산출한다. 여러 쿼리의 순위를 결합해 최종 목록 상단에 관련성이 가장 높은 문서를 배치한다. RRF는 규모가 다르거나 점수 분포가 다른 쿼리들의 결과를 결합하는데 사용할 수 있다.

prompt_rag_fusion = ChatPromptTemplate.from_template(
'''
하나의 입력 쿼리를 기반으로 여러 개의 검색 쿼리를 생성하는 유용한 어시스턴트 입니다.
다음과 관련된 여러 쿼리를 생성합니다.
{question}

출력(쿼리4개):
'''
)

def parse_queries_output(message):
    return message.content.split('\n')

llm = ChatOllama(model=gemma_model)
query_gen = prompt_rag_fusion | llm | parse_queries_output

쿼리를 생성한 후 각 쿼리에 대해 관련 문서를 수집하고 함수에 전달하여 관련 문서 목록(관련도에 따라)을 재정렬한다.

reciprocal_ran_fusion 함수는 각 쿼리의 검색 결과 목록, 즉 각 쿼리에 대해 관련도 순으로 정렬한 문서들의 목록을 입력으로 받는다. RFF 알고리즘은 각 문서가 여러 목록에서 차지한 순위를 참고해 새로운 점수를 산출한 후, 정렬하고 최종 재정렬한 목록을 생성

결합된 점수를 산출 후, 이 점수를 기준으로 문서들을 내림차순으로 정렬해 최종 재정렬한 목록을 반환


retriever = db.as_retriever()

def reciprocal_rank_fusion(results: list[list], k=60):
    '''여러 순위 문서 목록에 대한 상호 순위 융합 및 RRF 공식에 사용되는 선택적 매개변수 k 입니다.'''
    # 사전을 초기화해 각 문서에 대한 융합된 점수를 보관
    # 고유성을 보장하기 위해 문서가 콘테츠별로 키를 생성
    fused_scores = {}
    documents = {}
    for docs in results:
        # 목록에 있는 각 문서를 순위(목록 내 위치)에 따라 반복
        for rank, doc in enumerate(docs):
            doc_str = doc.page_content
            if doc_str not in fused_scores:
                fused_scores[doc_str] = 0
                documents[doc_str] = doc
            fused_scores[doc_str] += 1 / (rank + k)
    #융합된 점수를 기준으로 문서를 내림차순으로 정렬하여 최종 재순위 결과를 정리
    reranked_doc_strs = sorted(
        fused_scores, key=lambda d: fused_scores[d], reverse=True)
    return [documents[doc_str] for doc_str in reranked_doc_strs]

retrieval_chain = query_gen | retriever.batch | reciprocal_rank_fusion

[체인 실행]

## 프롬프트 구성
prompt = ChatPromptTemplate.from_template(
'''
다음 컨텍스트만 사용해 질문에 답하세요.
컨텍스트 : {context}
질문 : {question}
'''
)

query = '고대 그리스 철학사의 주요 인물은 누기인가요?'

@chain
def rag_fusion(input):
    #관련 문서 검색
    docs = retrieval_chain.invoke(input)
    formatted = prompt.invoke({'context':docs, 'question':input})
    return llm.invoke(formatted)

## 실행
print('다중 쿼리 검색\n')
result = rag_fusion.invoke(query)
print(result)
content='컨텍스트에 따르면 고대 그리스 철학사의 주요 인물은 다음과 같습니다.\n\n*   피타고라스\n*   소크라테스\n*   플라톤\n*   아리스토텔레스\n*   데모크리토스\n*   안티스테네스 (소크라테스의 제자)\n*   디오게네스\n*   테베의 크라테스\n*   아리스티포스 (소크라테스의 제자)\n*   메가라의 에우크레이데스\n*   엘리스의 파이돈' additional_kwargs={} response_metadata={'model': 'gemma3:12b', 'created_at': '2026-01-20T04:22:59.349801Z', 'done': True, 'done_reason': 'stop', 'total_duration': 11517004750, 'load_duration': 107074000, 'prompt_eval_count': 1830, 'prompt_eval_duration': 6507782458, 'eval_count': 129, 'eval_duration': 4867840701, 'logprobs': None, 'model_name': 'gemma3:12b', 'model_provider': 'ollama'} id='lc_run--019bd9a4-1517-7bc1-89b6-c9b1e334f3f4-0' tool_calls=[] invalid_tool_calls=[] usage_metadata={'input_tokens': 1830, 'output_tokens': 129, 'total_tokens': 1959}

RAG 융합은 사용자가 의도한 표현을 정확하게 파악하고 복잡한 쿼리를 원활하게 처리해, 검색된 문서의 범위를 확장해 뜻밖의 가능성을 연다는 장점이 있다.

4. 가상 문서 임베딩(HyDE: Hypothetical Document Embeddings)

사용자 쿼리를 토대로 가상의 문서를 작성하고, 해당 문서를 임베딩하여, 벡터 유사도에 기반해 관련 문서를 검색하는 방식

스크린샷 2026-01-20 오후 1.42.18.png

[가상문서를 생성하는 프롬프트 정의]

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

from config.load_sys import gemma_model

prompt_hyde = ChatPromptTemplate.from_template(
'''
질문에 답할 구절을 작성해주세요.
질문: {question}
구절:
'''
)

llm = ChatOllama(model=gemma_model, temperature=0)

generate_doc = (prompt_hyde | llm | StrOutputParser())

[가상 문서를 retriever의 입력으로 전달할 임베딩 생성 후 벡터 저장소에서 유사한 문서 검색]

## 문서 검색 체인
retriever = db.as_retriever()
retrieval_chain = generate_doc | retriever

[검색한 문서를 최종 프롬프트로 전달 후 LLM이 출력하도록 생성]

HyDE 실행

결과 :  content='컨텍스트에 따르면 고대 그리스 철학사의 주요 인물은 탈레스, 아리스토텔레스, 피타고라스, 소크라테스, 플라톤, 파르메니데스, 제논, 에피쿠로스, 아낙시만드로스입니다.' additional_kwargs={} response_metadata={'model': 'gemma3:12b', 'created_at': '2026-01-20T04:58:59.264306Z', 'done': True, 'done_reason': 'stop', 'total_duration': 10773543875, 'load_duration': 111227500, 'prompt_eval_count': 2339, 'prompt_eval_duration': 8253567000, 'eval_count': 63, 'eval_duration': 2383583712, 'logprobs': None, 'model_name': 'gemma3:12b', 'model_provider': 'ollama'} id='lc_run--019bd9c5-0d29-7d23-9b0a-dc4042c36424-0' tool_calls=[] invalid_tool_calls=[] usage_metadata={'input_tokens': 2339, 'output_tokens': 63, 'total_tokens': 2402}

쿼리 변환은 입력된 원본 쿼리를 바탕으로 다음 단계를 수행

  • 대상 텍스트를 하나 이상의 쿼리 형태로 재작성
  • 여러 쿼리에서 산출한 결과를 하나로 모아, 가장 관련성이 높은 결과 집합으로 통합

쿼리 라우팅(Query Routing)

쿼리를 라우팅하여 적절한 데이터 소스를 전달해 관련문서를 검색한다. 즉, 사용자의 쿼리를 적절한 데이터 소스로 전달하는 방법이다.

1. 논리적 라우팅(Logical Routing)

LLM에 활용할 수 있는 여러 데이터 소스에 관한 정보를 제공해, 쿼리를 토대로 적합한 데이터 소스를 선택하도록 지시한다.

스크린샷 2026-01-20 오후 2.10.11.png

함수 호출(function call)은 쿼리를 근거로 채팅 모델이 함수의 인수를 산출하는 스키마를 정의한다.

이렇게 구조화된 출력값을 생성하면 다른 함수에도 활용할 수 있다.

from typing import Literal

from langchain_core.prompts import ChatPromptTemplate
from langchain_ollama import ChatOllama
from pydantic import BaseModel, Field

from config.load_sys import gemma_model

#데이터 모델 클래스
class RouteQuery(BaseModel):
    datasource: Literal['python_docs', 'js_docs'] = Field(
        ...,
        description='Given a user question, choose which datasource would be most relevant for answering their question'
    )

## 프롬프트 템플릿
## 함수 호출
llm = ChatOllama(model=gemma_model)

## with_structured_output 주어진 스키마와 일치하도록 형식화된 출력을 반환하는 모델 래퍼
structured_llm =llm.with_structured_output(RouteQuery)

## 프로프트 템플릿
system = '''
당신은 사용자 질문을 적절한 데이터 소스로 라우팅하는 전문가입니다. 질문이 지목하는 프로그래밍 언어에 따라 해당 데이터 소스로 라우팅하세요.
'''
prompt = ChatPromptTemplate.from_messages(
    [('system',system),('human','{question}')]
)
# 라우터 정의
router = prompt | structured_llm

question = '''이 코드가 안돌아가는 이유를 설명해주세요.

prompt = ChatPromptTemplate.from_template(
    ['human','speak in {language}']
)
prompt.invoke('french')
'''
result = router.invoke({'question':question})
print('\nRouting to:', result)

[출력]

Routing to: datasource='python_docs'

위 값을 다른 함수에 전달하여 필요에 따른 로직을 수정

[라우팅 선택]

def choose_route(result):
    if 'python_docs' in result.datasource.lower():
        return 'chain for python_docs'
    else:
        return 'chain for js_docs'

full_chain = router | RunnableLambda(choose_route)

result = full_chain.invoke({'question':question})
print('\nChoose Route :', result)

[출력]

Choose Route : chain for python_docs

2. 의미론적 라우팅(Semantic Routing)

다양한 데이터소스를 대표하는 프롬프트를 준비해 임베딩하고, 쿼리에 벡터 유사도를 검색해 가장 유사한 프롬프트를 검색

스크린샷 2026-01-20 오후 3.05.31.png

from langchain_community.utils.math import cosine_similarity
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import PromptTemplate
from langchain_core.runnables import chain
from langchain_ollama import OllamaEmbeddings, ChatOllama

from config.load_sys import gemma_model

physics_template = '''
당신은 매우 똑똑한 물리학 교수 입니다.
당신은 물리학에 대한 질문에 간결하고 쉽게 이해할 수 있는 방식으로 대답하는 데 뛰어납니다.
당신이 질문에 대한 답을 모를 때는 모른다고 인정합니다.
다음 질문에 답하세요. : {query}
'''

math_template = '''
당신은 매우 뛰어난 수학자 입니다. 당신은 수학 문제에 대답하는 데 뛰어납니다.
당신은 어려운 문제를 구성 요소로 분해하고 구성 요소를 해결한 다음
함께 모아 더 넓은 질문에 대답힙니다.
다음 질문에 답하세요. : {query}
'''

# 임베딩
embeddings = OllamaEmbeddings(model='embeddinggemma:300m')
prompt_templates = [physics_template, math_template]
prompt_embeddings = embeddings.embed_documents(prompt_templates)

#질문을 프롬프트에 라우팅

@chain
def prompt_router(query):
    query_embedding = embeddings.embed_query(query)
    similarity = cosine_similarity([query_embedding], prompt_embeddings)[0]
    most_similar = prompt_templates[similarity.argmax()]
    print('수학 프롬프트 사용' if most_similar == math_template else '물리 프롬프트 사용')
    return PromptTemplate.from_template(most_similar)

llm = ChatOllama(model=gemma_model)
semantic_router = (prompt_router | llm | StrOutputParser())

result = semantic_router.invoke('블랙홀이란 무엇인가요?')
print('\n의미론적 라우팅 결과 : ', result)

[블랙홀이란 무엇인가요? 질문의 결과]

물리 프롬프트 사용

의미론적 라우팅 결과 :  블랙홀은 중력이 너무 강해서 빛조차 빠져나올 수 없는 시공간 영역입니다. 

쉽게 설명하면, 엄청나게 많은 물질이 매우 작은 공간에 모여서 만들어지는 천체입니다. 마치 엄청나게 빽빽한 곳에 덩어리처럼 물질이 뭉쳐 있는 모습이라고 생각하시면 됩니다.

**블랙홀의 특징:**

*   **강력한 중력:** 블랙홀의 중력은 너무 강해서 빛조차 빠져나올 수 없습니다. 그래서 '검은 구멍'이라고도 불립니다.
*   **사건의 지평선 (Event Horizon):** 블랙홀의 경계면을 '사건의 지평선'이라고 합니다. 이 경계면을 넘어서면 어떤 것도 빠져나올 수 없습니다.
*   **특이점 (Singularity):** 블랙홀의 중심에는 모든 물질이 한 점에 모여 있는 '특이점'이 있습니다. 이곳에서는 우리가 아는 물리 법칙이 적용되지 않습니다.

**블랙홀은 어떻게 만들어지나요?**

대부분의 블랙홀은 매우 큰 별이 수명을 다하고 붕괴하면서 만들어집니다. 별의 핵융합 반응이 멈추면 중력에 의해 붕괴하게 되고, 붕괴가 계속되면 블랙홀이 됩니다.

**블랙홀은 우리에게 위협이 되나요?**

블랙홀은 주변의 물질을 흡수하기 때문에, 블랙홀 근처에 너무 가까이 다가간다면 위험할 수 있습니다. 하지만 우주의 거리에 있는 블랙홀은 우리에게 직접적인 위협이 되지는 않습니다.

더 궁금한 점이 있으시면 언제든지 질문해주세요.

[선형대수란 무엇인가요? 질문의 결과]

수학 프롬프트 사용

의미론적 라우팅 결과 :  네, 제가 수학 문제를 해결하는 데 도움을 드릴 수 있습니다. 특히 선형대수와 관련된 질문을 해주셔서 기쁩니다.

선형대수는 수학의 핵심 분야 중 하나이며, 다양한 분야에서 활용되는 강력한 도구입니다. 선형대수의 핵심 내용을 구성 요소를 분해하여 설명하고, 최종적으로 선형대수가 무엇인지 명확히 정의해 드리겠습니다.

**1. 선형대수의 기본 구성 요소**

*   **벡터(Vector):**
    *   벡터는 크기와 방향을 가진 양입니다. 2차원 평면이나 3차원 공간에서 화살표로 표현할 수 있습니다.
    *   벡터는 숫자들의 리스트로도 표현될 수 있습니다. 예를 들어, (1, 2)는 2차원 벡터입니다.
    *   벡터는 점이나 변위, 힘 등을 나타낼 수 있습니다.
*   **스칼라(Scalar):**
    *   스칼라는 크기만 가지는 양입니다. 예를 들어, 온도, 속도, 무게 등이 스칼라입니다.
    *   스칼라는 일반적으로 실수(Real number)로 표현됩니다.
*   **행렬(Matrix):**
    *   행렬은 숫자들을 사각형 형태로 배열한 것입니다.
    *   행렬은 선형 변환을 표현하거나 시스템의 관계를 나타내는 데 사용됩니다.
    *   예를 들어, 2x3 행렬은 2개의 행과 3개의 열을 가집니다.
*   **선형 변환(Linear Transformation):**
    *   선형 변환은 벡터 공간에서 다른 벡터 공간으로의 함수로, 벡터의 합과 스칼라 곱을 보존하는 변환입니다.
    *   선형 변환은 벡터의 방향이나 크기를 바꿀 수 있지만, 직선을 직선으로 유지합니다.
*   **벡터 공간(Vector Space):**
    *   벡터 공간은 벡터들의 집합으로, 벡터의 합과 스칼라 곱에 대해 닫혀 있는 집합입니다.
    *   벡터 공간은 다양한 방식으로 정의될 수 있으며, 벡터들의 조합을 통해 다른 벡터를 생성할 수 있습니다.

**2. 선형대수의 주요 개념**

*   **선형 시스템(Linear System):**
    *   선형 방정식으로 이루어진 시스템입니다.
    *   선형 시스템은 행렬을 사용하여 표현하고, 가우스 소거법(Gaussian elimination)이나 LU 분해 등을 통해 해를 구할 수 있습니다.
*   **고유값과 고유 벡터(Eigenvalue and Eigenvector):**
    *   고유 벡터는 선형 변환을 적용해도 방향이 변하지 않는 벡터입니다.
    *   고유값은 해당 고유 벡터에 적용되는 스칼라 배수입니다.
    *   고유값과 고유 벡터는 행렬의 특성을 분석하고, 시스템의 안정성을 평가하는 데 사용됩니다.
*   **정칙 행렬(Orthogonal Matrix):**
    *   정칙 행렬은 자신의 전치 행렬과 곱했을 때 단위 행렬이 되는 행렬입니다.
    *   정칙 행렬은 벡터 공간의 회전 변환을 표현하는 데 사용됩니다.
*   **내적(Inner Product):**
    *   내적은 두 벡터를 입력으로 받아 스칼라 값을 출력하는 연산입니다.
    *   내적은 두 벡터 사이의 각도를 계산하거나, 한 벡터를 다른 벡터에 투영하는 데 사용됩니다.

**3. 선형대수의 활용 분야**

*   **컴퓨터 그래픽스:** 3D 모델링, 렌더링, 애니메이션 등에 활용됩니다.
*   **머신러닝:** 데이터 분석, 패턴 인식, 최적화 등에 활용됩니다.
*   **물리학:** 역학, 전자기학, 양자역학 등에 활용됩니다.
*   **공학:** 신호 처리, 제어 시스템, 네트워크 분석 등에 활용됩니다.
*   **경제학:** 게임 이론, 최적화, 시뮬레이션 등에 활용됩니다.

**4. 선형대수의 정의**

이제 위에서 설명한 구성 요소와 개념을 바탕으로 선형대수를 정의할 수 있습니다.

**선형대수는 벡터, 스칼라, 행렬, 선형 변환, 그리고 이들 간의 연산과 관계를 연구하는 수학의 한 분야입니다.** 선형대수는 선형 시스템의 해를 구하고, 선형 변환을 표현하며, 벡터 공간의 구조를 분석하는 데 필요한 도구를 제공합니다.

**결론적으로,** 선형대수는 현대 과학과 공학의 다양한 분야에서 필수적인 도구이며, 문제를 해결하고 시스템을 이해하는 데 매우 유용한 수학적 프레임워크를 제공합니다.

더 궁금한 점이 있으시면 언제든지 질문해주세요. 특정 개념에 대한 자세한 설명이나 추가 예시가 필요하시면 말씀해주세요.

쿼리 구성(Query Construction)

쿼리 구성은 자연어 쿼리를 사용중인 데이터베이스 또는 데이터 소스의 쿼리 언어로 변환하는 과정을 말한다.

RAG는 쿼리를 바탕으로 벡터 저장소에서 비정형 데이터를 임베딩하고, 관련 비정형 데이터를 검색한다. 그러나, 실제 운영 애플리케이션에 활용되는 대부분의 데이터는 정형화되어, 대개 관계형 데이터베이스에 저장된다. 더불어, 벡터 저장소에 임베딩한 비정형 데이터에는 중요한 정보를 담은 정형된 메타데이터가 함게 포함된다.

스크린샷 2026-01-20 오후 3.18.53.png

1. 텍스트-메타데이터 필터

대부분의 벡터 저장소는 메타데이터를 토대로 벡터 검색 범위를 제한하는 기능을 갖추고 있다. 임베딩 과정에는 인덱스 벡터에 메타데이터(키-값 쌍)를 부여한 후, 이후 쿼리 호출에서 필터 표현식을 지정할 수 있다.

랭체인은 SelfQueryRetriever를 제공하여 해당 로직을 추상화 한 후 다양한 데이터 소스의 자연어 쿼리를 간단하게 구조화된 쿼리로 변환한다. 자체 쿼리는 사용자가 입력한 쿼리와 미리 정의된 메타데이터 스키마를 토대로 LLM을 활용해 관련 메타 데이터 필터를 추출하고 실행하는 메서드이다.

from langchain_classic.chains.query_constructor.schema import AttributeInfo
from langchain_classic.retrievers import SelfQueryRetriever
from langchain_core.documents import Document
from langchain_ollama import ChatOllama, OllamaEmbeddings
from langchain_postgres import PGVector

from config.load_sys import gemma_model
import os
connection = os.environ["PGVECTOR_URL"]

# 문서에 대한 임베딩 생성
embedding_model = OllamaEmbeddings(model='embeddinggemma:300m')
# PGVector 벡터 스토어 초기화
db = PGVector(
    embeddings=embedding_model,
    connection=connection,
    use_jsonb=True, # 속도 및 인덱싱 최적화를 위해 JSONB 사용 권장
)

docs = [
    Document(
        page_content='과학자들이 공룡을 되살리고 대혼란이 일어난다.',
        metadata={'year': 1993, 'rating': 7.7, 'genre': 'SF'},
    ),
    Document(
        page_content='레오나르도 디카프리오가 꿈속의 꿈속의 꿈속의 꿈속에 빠진다.',
        metadata={'year': 2010, 'director': '크리스토퍼 놀란', 'rating': 8.2},
    ),
    Document(
        page_content='심리학자인 형사가 꿈속의 꿈속의 꿈속의 꿈속의 꿈속에 빠진다. 인셉션이 이 발상을 차용했다.',
        metadata={'year': 2006, 'director': '곤 사토시', 'rating': 8.6},
    ),
    Document(
        page_content='평범한 체형의 매우 건강하고 순수한 매력을 지닌 여성들을 남성들이 동경한다.',
        metadata={'year': 2019, 'director': '그레타 거윅', 'rating': 8.3},
    ),
    Document(
        page_content='장난감들이 살아 움직이며 신나는 시간을 보낸다',
        metadata={'year': 1995, 'genre': '애니메이션'},
    ),
    Document(
        page_content='세 남자가 구역으로 들어가고, 세 남자가 구역 밖으로 나온다.',
        metadata={
            'year': 1979,
            'director': '안드레이 타르코프스키',
            'genre': '스릴러',
            'rating': 9.9,
        },
    ),
]

db.add_documents(docs)

fields = [
    AttributeInfo(
        name='genre',
        description='영화 장르',
        type='string of list[string]'
    ),
    AttributeInfo(
        name='year',
        description='영화 개봉 연도',
        type='integer'
    ),
    AttributeInfo(
        name='director',
        description='영화 감독',
        type='string'
    ),
    AttributeInfo(
        name='rating',
        description='영화 평점 1-10점',
        type='float'
    ),
]

description = '영화에 대한 간략한 정보'
llm = ChatOllama(model=gemma_model, temperature=0)
retriever = SelfQueryRetriever.from_llm(llm, db, description, fields)

## 필터 적용
print(retriever.invoke('평점이 8.5점 이상인 영화가 보고 싶어요.'))
print('\n')
#다양한 필터 적용
print(retriever.invoke('평점이 높은(8.5점 이상) SF영화는 무엇인가요?'))
[Document(id='f6037327-c3b5-4a89-bd5c-3940771f24f4', metadata={'year': 1979, 'genre': '스릴러', 'rating': 9.9, 'director': '안드레이 타르코프스키'}, page_content='세 남자가 구역으로 들어가고, 세 남자가 구역 밖으로 나온다.'), Document(id='5395451c-ec7a-4bdb-ba22-c42673fe3be1', metadata={'year': 2006, 'rating': 8.6, 'director': '곤 사토시'}, page_content='심리학자인 형사가 꿈속의 꿈속의 꿈속의 꿈속의 꿈속에 빠진다. 인셉션이 이 발상을 차용했다.')]

[Document(id='f6037327-c3b5-4a89-bd5c-3940771f24f4', metadata={'year': 1979, 'genre': '스릴러', 'rating': 9.9, 'director': '안드레이 타르코프스키'}, page_content='세 남자가 구역으로 들어가고, 세 남자가 구역 밖으로 나온다.'), Document(id='5395451c-ec7a-4bdb-ba22-c42673fe3be1', metadata={'year': 2006, 'rating': 8.6, 'director': '곤 사토시'}, page_content='심리학자인 형사가 꿈속의 꿈속의 꿈속의 꿈속의 꿈속에 빠진다. 인셉션이 이 발상을 차용했다.')]

검색기가 사용자 쿼리를 입력받아 여러 부분으로 분할한다.

  • 각 문서의 메타데이터에 우선 적용할 필터
  • 문서의 의미론적 검색에 활용하기 위한 쿼리

이를 위해 문서 메타데이터에 포함된 각종 필드를 구체적으로 기술해야 한다. 해당 내용은 프롬프트에 포함된다. 그 후 검색기는 다음과 같은 작업을 수행한다.

  • LLM에 쿼리 생성 프롬프트를 전달
  • LLM의 출력에서 메타데이터 필터와 재작성된 쿼리를 파싱
  • LLM이 생성한 메타데이터 필터를 벡터 저장소에 맞는 형식으로 변환
  • 생성된 필터를 통과하는 메타데이터를 가진 문서와 일치하도록 필터링한 벡터 저장소에서 유사도 검색을 실행

2. 텍스트-SQL 문 변환

SQL 및 관계 데이터베이스 구조화된 데이터의 중요한 소스지만, 자연어와 직접 상호작용할 수 없다. LLM으로 사용자의 쿼리를 SQL 쿼리로 변환할 수 있지만 오류가 발생할 여지가 있다.

[텍스트를 SQL로 변환하는 유용한방법]

  • 데이터베이스 설명 : LLM에 정확한 데이터베이스 설명을 제공해 SQL 쿼리를 명확하게 만든다.

    • 각 테이블에 대해 컬럼명과 자료형을 포함한 CREATE TABLE 설명을 LLM에게 제공
  • 퓨샷 예시 : 프롬프트에 질문과 쿼리 간의 대응 예시 몇 가지를 첨부해 쿼리 생성의 정확성을 높일 수 있다. 질의문에 대한 쿼리를 작성할 때 작성 방식을 안내하는 표준 정적 예시를 프롬프트에 추가하면 된다.

    스크린샷 2026-01-20 오후 3.39.50.png

from langchain_classic.chains.sql_database.query import create_sql_query_chain
from langchain_community.utilities import SQLDatabase
from langchain_ollama import ChatOllama

from config.load_sys import gemma_model

# 사용할 db 경로로 수정
db = SQLDatabase.from_uri('sqlite:///Chinook.db')
print(db.get_usable_table_names())

llm = ChatOllama(model=gemma_model, temperature=0)

# 질문을 SQL 쿼리로 변환
write_query = create_sql_query_chain(llm, db)

# 모델이 제안한 SQL을 출력해 검토한다. 이 블록은 쿼리를 실행하지 않는다.
candidate = write_query.invoke({'question': '직원(employee)은 모두 몇 명인가요?'})
print(candidate)

데이터베이스에서 의도치 않게 실행되는 쿼리로 인한 위험을 줄이기 위한 대책

  • 읽기 전용 권한만 부여된 계정으로 쿼리를 실행
  • 쿼리를 실행하는 데이터베이스 사용자는 쿼리 접근 허용 대상 테이블에만 한정해 권한을 부여
  • 애플리케이션에서 실행하는 쿼리에 시간 제한을 추가.
  • 과도한 비용이 발생하는 쿼리가 생성되더라도 DB자원을 지나치게 소모하기 전에 자동으로 취소할 수 있다.

위 candidate는 신뢰할 수 없는 모델 출력이다. 읽기 전용 계정만으로도 대량 조회나 민감한 읽기가 가능할 수 있다. 실행하려면 허용 테이블·컬럼, 사용자별 행 권한, SELECT만 허용하는 검증, 결과 건수와 시간 제한을 DB와 애플리케이션 양쪽에서 적용한다. 모델이 만든 SQL을 정규식 한 개로 검사하는 방식은 CTE, 주석, DB별 문법을 놓칠 수 있으므로 파서와 DB 권한을 함께 사용한다. 운영에서는 일반적인 질문이 정해져 있다면 모델 생성 SQL보다 검증된 파라미터화 쿼리나 조회 API가 단순하다.

검색·생성 실패를 분리해서 읽기

RAG의 마지막 출력만 보면 잘못된 답이 어디에서 만들어졌는지 알기 어렵다. 질문 재작성 → 검색 → 문맥 조립 → 답변 생성 단계마다 입력과 결과를 기록한다. 다만 사용자 입력과 검색 문서에 개인정보가 포함될 수 있으므로 로그에는 필요한 ID·버전·시간을 우선 남긴다.

증상먼저 확인할 것조치 후보
정답 문서가 검색되지 않음원본 인덱스, 청크, 쿼리 변환재인덱싱·검색 조정
정답 문서는 있는데 답이 틀림모델에 실제 전달된 문맥문맥 순서·출력 검증
구 규정을 인용문서 버전과 적용일메타데이터 필터
질문과 무관한 문서가 많음다중 쿼리 합치기·중복재정렬과 문맥 예산
근거가 없는데 숫자를 답함거절 사례와 프롬프트근거 0건에서 생성 중단
flowchart LR
    Q[질문] --> T[쿼리 변환]
    T --> R[검색]
    R --> C[근거 문맥]
    C --> G[답변]
    G --> V[인용·내용 검사]
    T -. 평가 .-> E[실패 분류]
    R -. 평가 .-> E
    V -. 평가 .-> E

이 그래프에서 쿼리 변환은 검색 질의를 넓힐 수 있지만 원래 질문의 기간이나 대상 조건을 지워 버릴 수도 있다. 그래서 변환 전 질문과 변환된 쿼리를 함께 저장한다. 다중 쿼리 검색이나 RAG 융합은 검색 적중률을 높일 여지가 있지만 중복 문서를 늘리고 추가 모델 호출을 요구한다. 기본 검색에서 놓친 사례가 무엇인지 확인한 후 적용한다(LangChain RAG 튜토리얼).

같은 카테고리의 글