Skip to main content

개요

LLM이 가능하게 하는 가장 강력한 애플리케이션 중 하나는 정교한 질의응답(Q&A) 챗봇입니다. 이러한 애플리케이션은 특정 소스 정보에 대한 질문에 답변할 수 있습니다. 이러한 애플리케이션들은 검색 증강 생성(Retrieval Augmented Generation) 또는 RAG로 알려진 기술을 사용합니다. 이 튜토리얼에서는 비정형 텍스트 데이터 소스를 대상으로 간단한 Q&A 애플리케이션을 구축하는 방법을 보여드립니다. 다음 내용을 다룹니다:
  1. 간단한 도구로 검색을 실행하는 RAG 에이전트. 이는 범용적으로 사용할 수 있는 좋은 구현입니다.
  2. 쿼리당 단일 LLM 호출만 사용하는 2단계 RAG 체인. 이는 간단한 쿼리에 빠르고 효과적인 방법입니다.

개념

다음 개념들을 다룹니다:
  • 인덱싱: 소스에서 데이터를 수집하고 인덱싱하는 파이프라인. 일반적으로 별도의 프로세스에서 수행됩니다.
  • 검색 및 생성: 실제 RAG 프로세스로, 런타임에 사용자 쿼리를 받아 인덱스에서 관련 데이터를 검색한 다음 모델에 전달합니다.
데이터를 인덱싱한 후에는 에이전트를 오케스트레이션 프레임워크로 사용하여 검색 및 생성 단계를 구현합니다.
이 튜토리얼의 인덱싱 부분은 의미적 검색 튜토리얼을 크게 따릅니다.데이터가 이미 검색 가능한 상태(즉, 검색을 실행할 함수가 있음)이거나 해당 튜토리얼 내용에 익숙하다면, 검색 및 생성 섹션으로 건너뛰어도 좋습니다.

미리보기

이 가이드에서는 웹사이트의 콘텐츠에 대한 질문에 답하는 앱을 구축합니다. 사용할 특정 웹사이트는 Lilian Weng의 LLM Powered Autonomous Agents 블로그 포스트입니다. 이를 통해 게시물의 내용에 대한 질문을 할 수 있습니다. 약 40줄의 코드로 간단한 인덱싱 파이프라인과 RAG 체인을 만들 수 있습니다. 전체 코드 스니펫은 아래를 참조하세요:
import "cheerio";
import { createAgent, tool } from "langchain";
import { CheerioWebBaseLoader } from "@langchain/community/document_loaders/web/cheerio";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
import * as z from "zod";

// Load and chunk contents of blog
const pTagSelector = "p";
const cheerioLoader = new CheerioWebBaseLoader(
  "https://lilianweng.github.io/posts/2023-06-23-agent/",
  {
    selector: pTagSelector
  }
);

const docs = await cheerioLoader.load();

const splitter = new RecursiveCharacterTextSplitter({
  chunkSize: 1000,
  chunkOverlap: 200
});
const allSplits = await splitter.splitDocuments(docs);

// Index chunks
await vectorStore.addDocuments(allSplits)

// Construct a tool for retrieving context
const retrieveSchema = z.object({ query: z.string() });

const retrieve = tool(
  async ({ query }) => {
    const retrievedDocs = await vectorStore.similaritySearch(query, 2);
    const serialized = retrievedDocs
      .map(
        (doc) => `Source: ${doc.metadata.source}\nContent: ${doc.pageContent}`
      )
      .join("\n");
    return [serialized, retrievedDocs];
  },
  {
    name: "retrieve",
    description: "Retrieve information related to a query.",
    schema: retrieveSchema,
    responseFormat: "content_and_artifact",
  }
);

const agent = createAgent({ model: "openai:gpt-5", tools: [retrieve] });
let inputMessage = `What is Task Decomposition?`;

let agentInputs = { messages: [{ role: "user", content: inputMessage }] };

for await (const step of await agent.stream(agentInputs, {
  streamMode: "values",
})) {
  const lastMessage = step.messages[step.messages.length - 1];
  prettyPrint(lastMessage);
  console.log("-----\n");
}
LangSmith 추적을 확인하세요.

설정

설치

이 튜토리얼에는 다음 langchain 의존성이 필요합니다:
npm i langchain @langchain/community @langchain/textsplitters
자세한 내용은 설치 가이드를 참조하세요.

LangSmith

LangChain으로 구축하는 많은 애플리케이션에는 여러 LLM 호출을 포함하는 여러 단계가 포함됩니다. 이러한 애플리케이션이 복잡해질수록 체인이나 에이전트 내부에서 정확히 무슨 일이 일어나고 있는지 검사할 수 있는 것이 매우 중요합니다. 이를 위한 최선의 방법은 LangSmith를 사용하는 것입니다. 위 링크에서 가입한 후, 추적 로깅을 시작하려면 환경 변수를 설정해야 합니다:
export LANGSMITH_TRACING="true"
export LANGSMITH_API_KEY="..."

구성 요소

LangChain의 통합 모음에서 세 가지 구성 요소를 선택해야 합니다. 채팅 모델 선택:
  • OpenAI
  • Anthropic
  • Azure
  • Google Gemini
  • Bedrock Converse
👉 Read the OpenAI chat model integration docs
npm install @langchain/openai
import { initChatModel } from "langchain";

process.env.OPENAI_API_KEY = "your-api-key";

const model = await initChatModel("openai:gpt-4.1");
임베딩 모델 선택:
  • OpenAI
  • Azure
  • AWS
  • VertexAI
  • MistralAI
  • Cohere
npm i @langchain/openai
import { OpenAIEmbeddings } from "@langchain/openai";

const embeddings = new OpenAIEmbeddings({
  model: "text-embedding-3-large"
});
벡터 스토어 선택:
  • Memory
  • Chroma
  • FAISS
  • MongoDB
  • PGVector
  • Pinecone
  • Qdrant
npm i @langchain/classic
import { MemoryVectorStore } from "@langchain/classic/vectorstores/memory";

const vectorStore = new MemoryVectorStore(embeddings);

1. 인덱싱

이 섹션은 의미적 검색 튜토리얼의 축약 버전입니다.데이터가 이미 인덱싱되어 검색 가능한 상태(즉, 검색을 실행할 함수가 있음)이거나 문서 로더, 임베딩, 벡터 스토어에 익숙하다면, 검색 및 생성 섹션으로 건너뛰어도 좋습니다.
인덱싱은 일반적으로 다음과 같이 작동합니다:
  1. 로드: 먼저 데이터를 로드해야 합니다. 이는 문서 로더로 수행됩니다.
  2. 분할: 텍스트 분할기는 대용량 Documents를 더 작은 청크로 나눕니다. 이는 데이터 인덱싱 및 모델에 전달하는 데 유용합니다. 큰 청크는 검색하기 어렵고 모델의 제한된 컨텍스트 윈도우에 맞지 않기 때문입니다.
  3. 저장: 나중에 검색할 수 있도록 분할된 청크를 저장하고 인덱싱할 곳이 필요합니다. 이는 일반적으로 VectorStore임베딩 모델을 사용하여 수행됩니다.
index_diagram

문서 로드하기

먼저 블로그 포스트 내용을 로드해야 합니다. 소스에서 데이터를 로드하고 Document 객체 목록을 반환하는 객체인 DocumentLoaders를 사용할 수 있습니다.
import "cheerio";
import { CheerioWebBaseLoader } from "@langchain/community/document_loaders/web/cheerio";

const pTagSelector = "p";
const cheerioLoader = new CheerioWebBaseLoader(
  "https://lilianweng.github.io/posts/2023-06-23-agent/",
  {
    selector: pTagSelector,
  }
);

const docs = await cheerioLoader.load();

console.assert(docs.length === 1);
console.log(`Total characters: ${docs[0].pageContent.length}`);
Total characters: 22360
console.log(docs[0].pageContent.slice(0, 500));
Building agents with LLM (large language model) as its core controller is...
더 깊이 알아보기 DocumentLoader: 소스에서 Documents 목록으로 데이터를 로드하는 객체.
  • 통합: 선택할 수 있는 160개 이상의 통합.
  • Interface: 기본 인터페이스에 대한 API 참조.

문서 분할하기

로드된 문서는 42,000자가 넘어 많은 모델의 컨텍스트 윈도우에 맞지 않습니다. 전체 포스트를 컨텍스트 윈도우에 맞출 수 있는 모델의 경우에도, 모델은 매우 긴 입력에서 정보를 찾는 데 어려움을 겪을 수 있습니다. 이를 처리하기 위해 Document를 임베딩 및 벡터 저장을 위한 청크로 분할합니다. 이는 런타임에 블로그 포스트의 가장 관련성 높은 부분만 검색하는 데 도움이 됩니다. 의미적 검색 튜토리얼에서와 같이, RecursiveCharacterTextSplitter를 사용합니다. 이는 새 줄과 같은 공통 구분자를 사용하여 각 청크가 적절한 크기가 될 때까지 문서를 재귀적으로 분할합니다. 이는 일반 텍스트 사용 사례에 권장되는 텍스트 분할기입니다.
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";

const splitter = new RecursiveCharacterTextSplitter({
  chunkSize: 1000,
  chunkOverlap: 200,
});
const allSplits = await splitter.splitDocuments(docs);
console.log(`Split blog post into ${allSplits.length} sub-documents.`);
Split blog post into 29 sub-documents.

문서 저장하기

이제 런타임에 검색할 수 있도록 66개의 텍스트 청크를 인덱싱해야 합니다. 의미적 검색 튜토리얼에 따라, 각 문서 분할의 내용을 임베딩하고 이러한 임베딩을 벡터 스토어에 삽입하는 접근 방식을 사용합니다. 입력 쿼리가 주어지면 벡터 검색을 사용하여 관련 문서를 검색할 수 있습니다. 튜토리얼 시작 부분에서 선택한 벡터 스토어 및 임베딩 모델을 사용하여 단일 명령으로 모든 문서 분할을 임베딩하고 저장할 수 있습니다.
await vectorStore.addDocuments(allSplits);
더 깊이 알아보기 Embeddings: 텍스트를 임베딩으로 변환하는 데 사용되는 텍스트 임베딩 모델의 래퍼.
  • 통합: 선택할 수 있는 30개 이상의 통합.
  • Interface: 기본 인터페이스에 대한 API 참조.
VectorStore: 임베딩을 저장하고 쿼리하는 데 사용되는 벡터 데이터베이스의 래퍼.
  • 통합: 선택할 수 있는 40개 이상의 통합.
  • Interface: 기본 인터페이스에 대한 API 참조.
이것으로 파이프라인의 인덱싱 부분이 완료됩니다. 이 시점에서 블로그 포스트의 청크된 콘텐츠가 포함된 쿼리 가능한 벡터 스토어가 있습니다. 사용자 질문이 주어지면 이상적으로 질문에 답하는 블로그 포스트의 스니펫을 반환할 수 있어야 합니다.

2. 검색 및 생성

RAG 애플리케이션은 일반적으로 다음과 같이 작동합니다:
  1. 검색: 사용자 입력이 주어지면 Retriever를 사용하여 저장소에서 관련 분할을 검색합니다.
  2. 생성: 모델이 검색된 데이터와 함께 질문을 포함하는 프롬프트를 사용하여 답변을 생성합니다.
retrieval_diagram 이제 실제 애플리케이션 로직을 작성해 봅시다. 사용자 질문을 받아 해당 질문과 관련된 문서를 검색하고, 검색된 문서와 초기 질문을 모델에 전달하여 답변을 반환하는 간단한 애플리케이션을 만들고자 합니다. 다음을 시연합니다:
  1. 간단한 도구로 검색을 실행하는 RAG 에이전트. 이는 범용적으로 사용할 수 있는 좋은 구현입니다.
  2. 쿼리당 단일 LLM 호출만 사용하는 2단계 RAG 체인. 이는 간단한 쿼리에 빠르고 효과적인 방법입니다.

RAG 에이전트

RAG 애플리케이션의 한 가지 형식은 정보를 검색하는 도구를 가진 간단한 에이전트입니다. 벡터 스토어를 래핑하는 도구를 구현하여 최소한의 RAG 에이전트를 조립할 수 있습니다:
import * as z from "zod";
import { tool } from "@langchain/core/tools";

const retrieveSchema = z.object({ query: z.string() });

const retrieve = tool(
  async ({ query }) => {
    const retrievedDocs = await vectorStore.similaritySearch(query, 2);
    const serialized = retrievedDocs
      .map(
        (doc) => `Source: ${doc.metadata.source}\nContent: ${doc.pageContent}`
      )
      .join("\n");
    return [serialized, retrievedDocs];
  },
  {
    name: "retrieve",
    description: "Retrieve information related to a query.",
    schema: retrieveSchema,
    responseFormat: "content_and_artifact",
  }
);
여기서는 responseFormatcontent_and_artifact로 지정하여 도구가 원시 문서를 각 ToolMessageartifacts로 첨부하도록 구성합니다. 이를 통해 모델로 전송되는 문자열화된 표현과 별도로 애플리케이션에서 문서 메타데이터에 액세스할 수 있습니다.
도구가 주어지면 에이전트를 구성할 수 있습니다: 도구가 주어지면 에이전트를 구성할 수 있습니다:
import { createAgent } from "langchain";

const tools = [retrieve];
const systemPrompt = new SystemMessage(
    "You have access to a tool that retrieves context from a blog post. " +
    "Use the tool to help answer user queries."
)

const agent = createAgent({ model: "openai:gpt-5", tools, systemPrompt });
테스트해 봅시다. 일반적으로 답변하기 위해 반복적인 검색 단계 시퀀스가 필요한 질문을 구성합니다:
let inputMessage = `What is the standard method for Task Decomposition?
Once you get the answer, look up common extensions of that method.`;

let agentInputs = { messages: [{ role: "user", content: inputMessage }] };

const stream = await agent.stream(agentInputs, {
  streamMode: "values",
});
for await (const step of stream) {
  const lastMessage = step.messages[step.messages.length - 1];
  console.log(`[${lastMessage.role}]: ${lastMessage.content}`);
  console.log("-----\n");
}
[human]: What is the standard method for Task Decomposition?
Once you get the answer, look up common extensions of that method.
-----

[ai]:
Tools:
- retrieve({"query":"standard method for Task Decomposition"})
-----

[tool]: Source: https://lilianweng.github.io/posts/2023-06-23-agent/
Content: hard tasks into smaller and simpler steps...
Source: https://lilianweng.github.io/posts/2023-06-23-agent/
Content: System message:Think step by step and reason yourself...
-----

[ai]:
Tools:
- retrieve({"query":"common extensions of Task Decomposition method"})
-----

[tool]: Source: https://lilianweng.github.io/posts/2023-06-23-agent/
Content: hard tasks into smaller and simpler steps...
Source: https://lilianweng.github.io/posts/2023-06-23-agent/
Content: be provided by other developers (as in Plugins) or self-defined...
-----

[ai]: ### Standard Method for Task Decomposition

The standard method for task decomposition involves...
-----
에이전트가 다음을 수행하는 것을 확인할 수 있습니다:
  1. 작업 분해의 표준 방법을 검색하기 위한 쿼리를 생성합니다.
  2. 답변을 받은 후, 그 방법의 일반적인 확장을 검색하기 위한 두 번째 쿼리를 생성합니다.
  3. 필요한 모든 컨텍스트를 받은 후 질문에 답변합니다.
LangSmith 추적에서 지연 시간 및 기타 메타데이터와 함께 전체 단계 시퀀스를 확인할 수 있습니다.
LangGraph 프레임워크를 직접 사용하여 더 깊은 수준의 제어와 사용자 정의를 추가할 수 있습니다. 예를 들어, 문서 관련성을 등급 매기고 검색 쿼리를 다시 작성하는 단계를 추가할 수 있습니다. 더 고급 형식을 보려면 LangGraph의 에이전틱 RAG 튜토리얼을 확인하세요.

RAG 체인

위의 에이전틱 RAG 형식에서는 LLM이 사용자 쿼리에 답변하는 데 도움이 되도록 도구 호출을 생성하는 재량권을 허용합니다. 이는 범용적으로 사용할 수 있는 좋은 솔루션이지만 몇 가지 트레이드오프가 있습니다:
✅ 장점⚠️ 단점
필요할 때만 검색 – LLM은 불필요한 검색을 트리거하지 않고 인사말, 후속 질문 및 간단한 쿼리를 처리할 수 있습니다.두 번의 추론 호출 – 검색이 수행되면 쿼리를 생성하는 호출과 최종 응답을 생성하는 호출이 필요합니다.
컨텍스트 기반 검색 쿼리 – 검색을 query 입력이 있는 도구로 처리함으로써 LLM은 대화 컨텍스트를 통합하는 자체 쿼리를 작성합니다.제어 감소 – LLM은 실제로 필요할 때 검색을 건너뛰거나 불필요할 때 추가 검색을 실행할 수 있습니다.
여러 검색 허용 – LLM은 단일 사용자 쿼리를 지원하기 위해 여러 검색을 실행할 수 있습니다.
또 다른 일반적인 접근 방식은 항상 검색을 실행하고(잠재적으로 원시 사용자 쿼리 사용) 결과를 단일 LLM 쿼리의 컨텍스트로 통합하는 2단계 체인입니다. 이는 쿼리당 단일 추론 호출을 수행하여 유연성을 희생하면서 지연 시간을 줄입니다. 이 접근 방식에서는 더 이상 루프에서 모델을 호출하지 않고 대신 단일 패스를 수행합니다. 에이전트에서 도구를 제거하고 대신 검색 단계를 사용자 정의 프롬프트에 통합하여 이 체인을 구현할 수 있습니다:
import { createAgent, dynamicSystemPromptMiddleware } from "langchain";
import { SystemMessage } from "@langchain/core/messages";

const agent = createAgent({
  model,
  tools: [],
  middleware: [
    dynamicSystemPromptMiddleware(async (state) => {
        const lastQuery = state.messages[state.messages.length - 1].content;

        const retrievedDocs = await vectorStore.similaritySearch(lastQuery, 2);

        const docsContent = retrievedDocs
        .map((doc) => doc.pageContent)
        .join("\n\n");

        // Build system message
        const systemMessage = new SystemMessage(
        `You are a helpful assistant. Use the following context in your response:\n\n${docsContent}`
        );

        // Return system + existing messages
        return [systemMessage, ...state.messages];
    })
  ]
});
테스트해 봅시다:
let inputMessage = `What is Task Decomposition?`;

let chainInputs = { messages: [{ role: "user", content: inputMessage }] };

const stream = await agent.stream(chainInputs, {
  streamMode: "values",
})
for await (const step of stream) {
  const lastMessage = step.messages[step.messages.length - 1];
  prettyPrint(lastMessage);
  console.log("-----\n");
}
LangSmith 추적에서 모델 프롬프트에 통합된 검색된 컨텍스트를 확인할 수 있습니다. 이는 일반적으로 사용자 쿼리를 의미적 검색을 통해 실행하여 추가 컨텍스트를 가져오려는 제한된 설정에서 간단한 쿼리에 빠르고 효과적인 방법입니다.
위의 RAG 체인은 검색된 컨텍스트를 해당 실행의 단일 시스템 메시지에 통합합니다.에이전틱 RAG 형식에서처럼, 때때로 문서 메타데이터에 액세스하기 위해 애플리케이션 상태에 원시 소스 문서를 포함하고 싶을 수 있습니다. 2단계 체인 사례에서 다음을 수행하여 이를 수행할 수 있습니다:
  1. 검색된 문서를 저장할 키를 상태에 추가
  2. pre-model 훅을 통해 새 노드를 추가하여 해당 키를 채웁니다(그리고 컨텍스트를 주입합니다).
import { createMiddleware, Document, createAgent } from "langchain";
import { MessagesZodSchema } from "@langchain/langgraph";

const StateSchema = z.object({
  messages: MessagesZodSchema,
  context: z.array(z.custom<Document>()),
})

const retrieveDocumentsMiddleware = createMiddleware({
  stateSchema: StateSchema,
  beforeModel: async (state) => {
    const lastMessage = state.messages[state.messages.length - 1].content;
    const retrievedDocs = await vectorStore.similaritySearch(lastMessage, 2);

    const docsContent = retrievedDocs
      .map((doc) => doc.pageContent)
      .join("\n\n");

    const augmentedMessageContent = [
        ...lastMessage.content,
        { type: "text", text: `Use the following context to answer the query:\n\n${docsContent}` }
    ]

    // Below we augment each input message with context, but we could also
    // modify just the system message, as before.
    return {
      messages: [{
        ...lastMessage,
        content: augmentedMessageContent,
      }]
      context: retrievedDocs,
    }
  },
});

const agent = createAgent({
  model,
  tools: [],
  middleware: [retrieveDocumentsMiddleware],
});

다음 단계

이제 @[create_agent]를 통해 간단한 RAG 애플리케이션을 구현했으므로 새로운 기능을 쉽게 통합하고 더 깊이 들어갈 수 있습니다:
Connect these docs programmatically to Claude, VSCode, and more via MCP for real-time answers.
I