Langchain으로 적응형(Adaptive) RAG
Langchain으로 적응형(Adaptive) RAG
[쿼리 분석]을 사용해 질문의 복잡성에 따라 다양한 RAG 접근으로 라우팅하는 적응형 RAG(Adaptive RAG) 전략을 Mistral과 LangGraph로 구현하는 문서예요.
출처: 문서
본문
Adaptive RAG는 [쿼리 분석]을 사용해 질문의 복잡성에 따라 다양한 RAG 접근으로 라우팅하는 RAG 전략이에요.
[논문]의 아이디어 중 일부를 빌려올게요 (빨간색으로 표시):
- 쿼리 분석을 수행해 질문을 라우팅
또한 Corrective RAG [논문] (파란색)과 Self-RAG [논문] (빨간색)의 아이디어도 기반으로 사용할게요:
- 우리의 인덱스(벡터스토어)와 웹 검색 사이에서 라우팅
- 검색된 문서가 사용자 질문과 관련 있는지 평가
- LLM 생성 결과물이 문서에 충실한지 평가 (예: 환각이 없는지 확인)
- LLM 생성 결과물이 질문에 유용한지 평가 (예: 질문에 답하는지)
이 아이디어들을 Mistral과 [LangGraph]를 사용해 처음부터 구현할게요:
- 그래프로 제어 흐름(control flow)을 표현
- 그래프 상태에는 노드 사이에서 전달하려는 정보(질문, 문서 등)를 포함
- 각 그래프 노드는 상태를 수정
- 각 그래프 엣지는 다음에 방문할 노드를 결정
환경 설정 (Environment)
%%capture --no-stderr
%pip install --quiet -U langchain langchain_community tiktoken langchain-mistralai scikit-learn langgraph tavily-python bs4
[Mistral API 키]가 설정되어 있는지 확인하세요. 검색에는 LLM과 RAG에 최적화된 검색 엔진인 [Tavily]를 사용해요.
import os, getpass
def _set_env(var: str):
if not os.environ.get(var):
os.environ[var] = getpass.getpass(f"{var}: ")
_set_env("MISTRAL_API_KEY")
_set_env("TAVILY_API_KEY")
os.environ['TOKENIZERS_PARALLELISM'] = 'true'
선택적으로 트레이싱에 [LangSmith]를 사용할 수 있어요.
_set_env("LANGCHAIN_API_KEY")
os.environ["LANGCHAIN_TRACING_V2"] = "true"
os.environ["LANGCHAIN_PROJECT"] = "mistral-cookbook"
인덱싱 (Index)
[Mistral 임베딩]으로 3개의 블로그 포스트를 인덱싱해요.
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import WebBaseLoader
from langchain_community.vectorstores import SKLearnVectorStore
from langchain_mistralai import MistralAIEmbeddings
urls = [
"https://lilianweng.github.io/posts/2023-06-23-agent/",
"https://lilianweng.github.io/posts/2023-03-15-prompt-engineering/",
"https://lilianweng.github.io/posts/2023-10-25-adv-attack-llm/",
]
# Load documents
docs = [WebBaseLoader(url).load() for url in urls]
docs_list = [item for sublist in docs for item in sublist]
# Split documents
text_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(
chunk_size=1000, chunk_overlap=200
)
doc_splits = text_splitter.split_documents(docs_list)
# Add to vectorDB
vectorstore = SKLearnVectorStore.from_documents(
documents=doc_splits,
embedding=MistralAIEmbeddings(),
)
# Create retriever
retriever = vectorstore.as_retriever()
LLM
특정 노드에서 Mistral 함수 호출을 사용해 [구조화된 출력]을 만들 수 있어요. 흐름은 이렇습니다:
### Set LLM
from langchain_mistralai import ChatMistralAI
mistral_model = "mistral-large-latest" # "open-mixtral-8x22b"
llm = ChatMistralAI(model=mistral_model, temperature=0)
Router (라우터)
질문을 벡터스토어 또는 웹 검색으로 라우팅해요. 구조화된 출력 RouteQuery를 사용해 datasource를 결정합니다.
### Router
from typing import Literal
from pydantic import BaseModel, Field
from langchain_core.messages import HumanMessage, SystemMessage
# Data model
class RouteQuery(BaseModel):
""" Route a user query to the most relevant datasource. """
datasource: Literal["vectorstore", "websearch"] = Field(
...,
description="Given a user question choose to route it to web search or a vectorstore.",
)
# LLM with structured output
structured_llm_router = llm.with_structured_output(RouteQuery)
# Prompt
router_instructions = """You are an expert at routing a user question to a vectorstore or web search.
The vectorstore contains documents related to agents, prompt engineering, and adversarial attacks.
Use the vectorstore for questions on these topics. For all else, use web-search."""
# Test router
print(structured_llm_router.invoke([SystemMessage(content=router_instructions)] + [HumanMessage(content="Who will the Bears draft first in the NFL draft?")]))
print(structured_llm_router.invoke([SystemMessage(content=router_instructions)] + [HumanMessage(content="What are the types of agent memory?")]))
Retrieval Grader (검색 결과 채점기)
검색된 문서가 질문과 관련 있는지 이진 점수로 평가해요.
### Retrieval Grader
from langchain_mistralai import ChatMistralAI
from langchain_core.prompts import ChatPromptTemplate
# Data model
class GradeDocuments(BaseModel):
"""Binary score for relevance check on retrieved documents."""
binary_score: str = Field(description="Documents are relevant to the question, 'yes' or 'no'")
# LLM with structured output
structured_llm_doc_grader = llm.with_structured_output(GradeDocuments)
# Doc grader instructions
doc_grader_instructions = """You are a grader assessing relevance of a retrieved document to a user question.
If the document contains keyword(s) or semantic meaning related to the question, grade it as relevant.
Give a binary score 'yes' or 'no' score to indicate whether the document is relevant to the question."""
# Grader prompt
doc_grader_prompt = "Here is the retrieved document: \n\n {document} \n\n Here is the user question: \n\n {question}"
# Test
question = "agent memory"
docs = retriever.get_relevant_documents(question)
doc_txt = docs[1].page_content
doc_grader_prompt_formatted = doc_grader_prompt.format(document=doc_txt, question=question)
print(structured_llm_doc_grader.invoke([SystemMessage(content=doc_grader_instructions)] + [HumanMessage(content=doc_grader_prompt_formatted)]))
Generate (생성)
RAG 프롬프트와 형식화 함수로 질문에 답을 생성해요.
### Generate
from langchain_core.output_parsers import StrOutputParser
# Prompt
rag_prompt = """You are an assistant for question-answering tasks.
Use the following pieces of retrieved context to answer the question.
If you don't know the answer, just say that you don't know.
Use three sentences maximum and keep the answer concise.
Question: {question}
Context: {context}
Answer:"""
# LLM
llm = ChatMistralAI(model=mistral_model, temperature=0)
# Post-processing
def format_docs(docs):
return "\n\n".join(doc.page_content for doc in docs)
# Test
question = "The types of agent memory"
docs = retriever.get_relevant_documents(question)
docs_txt = format_docs(docs)
rag_prompt_formatted = rag_prompt.format(context=docs_txt, question=question)
generation = llm.invoke([HumanMessage(content=rag_prompt_formatted)])
print(generation)
Hallucination Grader (환각 채점기)
생성물이 문서(사실)에 근거하는지 이진 점수로 평가해요.
### Hallucination Grader
# Data model
class GradeHallucinations(BaseModel):
"""Binary score for hallucination present in generation answer."""
binary_score: str = Field(description="Answer is grounded in the facts, 'yes' or 'no'")
explanation: str = Field(description="Explain the reasoning for the score")
# LLM with function call
structured_llm_hallucination_grader = llm.with_structured_output(GradeHallucinations)
# Hallucination grader instructions
hallucination_grader_instructions = """You are a teacher grading a quiz.
You will be given FACTS and a STUDENT ANSWER.
Here is the grade criteria to follow:
(1) Ensure the STUDENT ANSWER is grounded in the FACTS.
(2) Ensure the STUDENT ANSWER does not contain "hallucinated" information outside the scope of the FACTS.
Score:
A score of 1 means that the student's answer meets all of the criteria. This is the highest (best) score.
A score of 0 means that the student's answer does not meet all of the criteria. This is the lowest possible score you can give.
Explain your reasoning in a step-by-step manner to ensure your reasoning and conclusion are correct.
Avoid simply stating the correct answer at the outset."""
# Grader prompt
hallucination_grader_prompt = "FACTS: \n\n {documents} \n\n STUDENT ANSWER: {generation}"
# Test using documents and generation from above
hallucination_grader_prompt_formatted = hallucination_grader_prompt.format(documents=docs_txt, generation=generation)
score = structured_llm_hallucination_grader.invoke([SystemMessage(content=hallucination_grader_instructions)] + [HumanMessage(content=hallucination_grader_prompt_formatted)])
print(score.binary_score, score.explanation)
Answer Grader (답변 채점기)
생성물이 질문에 답하는지 이진 점수로 평가해요.
### Answer Grader
# Data model
class GradeAnswer(BaseModel):
"""Binary score to assess answer addresses question."""
binary_score: str = Field(description="Answer addresses the question, 'yes' or 'no'")
explanation: str = Field(description="Explain the reasoning for the score")
# LLM with function call
llm = ChatMistralAI(model=mistral_model, temperature=0)
structured_llm_answer_grader = llm.with_structured_output(GradeAnswer)
# Answer grader instructions
answer_grader_instructions = """You are a teacher grading a quiz.
You will be given a QUESTION and a STUDENT ANSWER.
Here is the grade criteria to follow:
(1) Ensure the STUDENT ANSWER is concise and relevant to the QUESTION
(2) Ensure the STUDENT ANSWER helps to answer the QUESTION
Score:
A score of 1 means that the student's answer meets all of the criteria. This is the highest (best) score.
A score of 0 means that the student's answer does not meet all of the criteria. This is the lowest possible score you can give.
Explain your reasoning in a step-by-step manner to ensure your reasoning and conclusion are correct.
Avoid simply stating the correct answer at the outset."""
# Grader prompt
answer_grader_prompt = "QUESTION: \n\n {question} \n\n STUDENT ANSWER: {generation}"
# Test using question and generation from above
answer_grader_prompt_formatted = answer_grader_prompt.format(question=question, generation=generation)
score = structured_llm_answer_grader.invoke([SystemMessage(content=answer_grader_instructions)] + [HumanMessage(content=answer_grader_prompt_formatted)])
print(score.binary_score, score.explanation)
웹 검색 도구 (Web Search Tool)
from langchain_community.tools.tavily_search import TavilySearchResults
web_search_tool = TavilySearchResults(k=3)
그래프 (Graph)
위 워크플로우를 [LangGraph]를 사용해 그래프로 구축해요.
그래프 상태 (Graph state)
그래프 state 스키마에는 각 노드에 전달하고, 선택적으로 각 노드에서 수정하려는 키들이 포함돼요. 개념 문서는 [여기]를 참조하세요.
import operator
from typing_extensions import TypedDict
from typing import List, Annotated
class GraphState(TypedDict):
"""
Graph state is a dictionary that contains information we want to propagate to, and modify in, each graph node.
"""
question : str # User question
generation : str # LLM generation
web_search : str # Binary decision to run web search
max_retries : int # Max number of retries for answer generation
answers : int # Number of answers generated
loop_step: Annotated[int, operator.add]
documents : List[str] # List of retrieved documents
그래프의 각 노드는 1) state를 입력으로 받고, 2) state를 수정하며, 3) 수정된 state를 state 스키마(dict)에 쓰는 단순한 함수예요. 각 엣지는 그래프의 노드 사이를 라우팅합니다.
from langchain.schema import Document
from langgraph.graph import END
### Nodes
def retrieve(state):
"""
Retrieve documents from vectorstore
Args:
state (dict): The current graph state
Returns:
state (dict): New key added to state, documents, that contains retrieved documents
"""
print("---RETRIEVE---")
question = state["question"]
# Write retrieved documents to documents key in state
documents = retriever.invoke(question)
return {"documents": documents}
def generate(state):
"""
Generate answer using RAG on retrieved documents
Args:
state (dict): The current graph state
Returns:
state (dict): New key added to state, generation, that contains LLM generation
"""
print("---GENERATE---")
question = state["question"]
documents = state["documents"]
loop_step = state.get("loop_step", 0)
# RAG generation
docs_txt = format_docs(documents)
rag_prompt_formatted = rag_prompt.format(context=docs_txt, question=question)
generation = llm.invoke([HumanMessage(content=rag_prompt_formatted)])
return {"generation": generation, "loop_step": loop_step+1}
def grade_documents(state):
"""
Determines whether the retrieved documents are relevant to the question
If any document is not relevant, we will set a flag to run web search
Args:
state (dict): The current graph state
Returns:
state (dict): Filtered out irrelevant documents and updated web_search state
"""
print("---CHECK DOCUMENT RELEVANCE TO QUESTION---")
question = state["question"]
documents = state["documents"]
# Score each doc
filtered_docs = []
web_search = "No"
for d in documents:
doc_grader_prompt_formatted = doc_grader_prompt.format(document=d.page_content, question=question)
score = structured_llm_doc_grader.invoke([SystemMessage(content=doc_grader_instructions)] + [HumanMessage(content=doc_grader_prompt_formatted)])
grade = score.binary_score
# Document relevant
if grade.lower() == "yes":
print("---GRADE: DOCUMENT RELEVANT---")
filtered_docs.append(d)
# Document not relevant
else:
print("---GRADE: DOCUMENT NOT RELEVANT---")
# We do not include the document in filtered_docs
# We set a flag to indicate that we want to run web search
web_search = "Yes"
continue
return {"documents": filtered_docs, "web_search": web_search}
def web_search(state):
"""
Web search based based on the question
Args:
state (dict): The current graph state
Returns:
state (dict): Appended web results to documents
"""
print("---WEB SEARCH---")
question = state["question"]
documents = state.get("documents", [])
# Web search
docs = web_search_tool.invoke({"query": question})
web_results = "\n".join([d["content"] for d in docs])
web_results = Document(page_content=web_results)
documents.append(web_results)
return {"documents": documents}
### Edges
def route_question(state):
"""
Route question to web search or RAG
Args:
state (dict): The current graph state
Returns:
str: Next node to call
"""
print("---ROUTE QUESTION---")
source = structured_llm_router.invoke([SystemMessage(content=router_instructions)] + [HumanMessage(content=state["question"])])
if source.datasource == 'websearch':
print("---ROUTE QUESTION TO WEB SEARCH---")
return "websearch"
elif source.datasource == 'vectorstore':
print("---ROUTE QUESTION TO RAG---")
return "vectorstore"
def decide_to_generate(state):
"""
Determines whether to generate an answer, or add web search
Args:
state (dict): The current graph state
Returns:
str: Binary decision for next node to call
"""
print("---ASSESS GRADED DOCUMENTS---")
question = state["question"]
web_search = state["web_search"]
filtered_documents = state["documents"]
if web_search == "Yes":
# All documents have been filtered check_relevance
# We will re-generate a new query
print("---DECISION: NOT ALL DOCUMENTS ARE RELEVANT TO QUESTION, INCLUDE WEB SEARCH---")
return "websearch"
else:
# We have relevant documents, so generate answer
print("---DECISION: GENERATE---")
return "generate"
def grade_generation_v_documents_and_question(state):
"""
Determines whether the generation is grounded in the document and answers question
Args:
state (dict): The current graph state
Returns:
str: Decision for next node to call
"""
print("---CHECK HALLUCINATIONS---")
question = state["question"]
documents = state["documents"]
generation = state["generation"]
max_retries = state.get("max_retries", 3) # Default to 3 if not provided
hallucination_grader_prompt_formatted = hallucination_grader_prompt.format(documents=format_docs(documents), generation=generation.content)
score = structured_llm_hallucination_grader.invoke([SystemMessage(content=hallucination_grader_instructions)] + [HumanMessage(content=hallucination_grader_prompt_formatted)])
grade = score.binary_score
# Check hallucination
if grade == "yes":
print("---DECISION: GENERATION IS GROUNDED IN DOCUMENTS---")
# Check question-answering
print("---GRADE GENERATION vs QUESTION---")
# Test using question and generation from above
answer_grader_prompt_formatted = answer_grader_prompt.format(question=question, generation=generation.content)
score = structured_llm_answer_grader.invoke([SystemMessage(content=answer_grader_instructions)] + [HumanMessage(content=answer_grader_prompt_formatted)])
grade = score.binary_score
if grade == "yes":
print("---DECISION: GENERATION ADDRESSES QUESTION---")
return "useful"
elif state["loop_step"] <= max_retries:
print("---DECISION: GENERATION DOES NOT ADDRESS QUESTION---")
return "not useful"
else:
print("---DECISION: MAX RETRIES REACHED---")
return "max retries"
elif state["loop_step"] <= max_retries:
print("---DECISION: GENERATION IS NOT GROUNDED IN DOCUMENTS, RE-TRY---")
return "not supported"
else:
print("---DECISION: MAX RETRIES REACHED---")
return "max retries"
그래프 구축 (Build Graph)
from langgraph.graph import StateGraph
from IPython.display import Image, display
workflow = StateGraph(GraphState)
# Define the nodes
workflow.add_node("websearch", web_search) # web search
workflow.add_node("retrieve", retrieve) # retrieve
workflow.add_node("grade_documents", grade_documents) # grade documents
workflow.add_node("generate", generate) # generatae
# Build graph
workflow.set_conditional_entry_point(
route_question,
{
"websearch": "websearch",
"vectorstore": "retrieve",
},
)
workflow.add_edge("websearch", "generate")
workflow.add_edge("retrieve", "grade_documents")
workflow.add_conditional_edges(
"grade_documents",
decide_to_generate,
{
"websearch": "websearch",
"generate": "generate",
},
)
workflow.add_conditional_edges(
"generate",
grade_generation_v_documents_and_question,
{
"not supported": "generate",
"useful": END,
"not useful": "websearch",
"max retries": END,
},
)
# Compile
graph = workflow.compile()
display(Image(graph.get_graph().draw_mermaid_png()))
그래프를 실행해 보세요:
graph_input = {"question": "What are the types of agent memory?", "max_retries": 3}
for event in graph.stream(graph_input, stream_mode="values"):
print(event)
graph_input = {"question": "What is the recent Mistral multi-modal model?", "max_retries": 3}
for event in graph.stream(graph_input, stream_mode="values"):
print(event)
max_retries를 제공하지 않으면 기본값 3으로 설정된다는 점을 다른 질문으로 확인할 수 있어요.
graph_input = {"question": "Who is the top candidate in the 2025 NFL draft?"}
for event in graph.stream(graph_input, stream_mode="values"):
print(event)
전체 흐름을 요약하면: 라우터가 질문을 벡터스토어 검색 또는 웹 검색으로 보내고, 검색된 문서를 관련성으로 채점하며, 답변을 생성한 뒤 환각·유용성 여부를 채점해 필요하면 웹 검색이나 재생성으로 재시도하는 적응형 RAG 에이전트예요.