LangChainJS, Milvus, Strapi로 RAG 구축하기
사용자 질의에 정확하고 관련성 높은 답변을 제공하는 것은 AI 애플리케이션을 구축할 때 매우 중요합니다. 자연스러운 대화가 가능하더라도, 기존 챗봇과 AI 모델은 특정하고 최신 정보에 접근하지 못하는 경우가 많습니다. 이들의 응답은 오래되었거나 전문적인 요구에 비해 너무 일반적일 수 있는 학습 데이터에서 나옵니다.
Retrieval Augmented Generation (RAG)는 AI 모델의 생성 능력을 Milvus와 같은 vector database 기반의 맞춤형 지식 베이스와 결합하여 이러한 한계를 해결합니다. 사전 학습된 지식에만 의존하는 대신, RAG는 응답을 생성하기 전에 관련 정보를 찾기 위해 사용자의 콘텐츠를 적극적으로 검색합니다. 이 접근 방식은 답변이 정확하고 문맥에 적절하도록 보장합니다.
RAG와 Vector Search 이해하기
RAG는 검색 메커니즘을 생성 프로세스와 통합하여 일반적인 AI 응답과 전문 지식 사이의 간극을 메웁니다. 사전 학습된 지식에만 의존하는 기존 언어 모델과 달리, RAG는 먼저 맞춤형 지식 베이스에서 관련 정보를 검색하여 AI의 응답을 보완합니다.
예를 들어, AI 어시스턴트가 특정 정책이나 절차에 대해 질문을 받으면 기억에만 의존하지 않습니다. 대신 답변을 제공하기 전에 관련 문서를 검색합니다. 이 검색 프로세스는 embedding models와 vector databases에 의해 구동됩니다.
이제 표준 RAG가 어떻게 작동하는지 살펴보겠습니다.
Text Embeddings: 텍스트는 embedding model에 의해 의미론적 의미를 포착하는 숫자 표현, 즉 vectors로 변환됩니다. 예를 들어 How do I return an item?와 What's the refund process?라는 문구는 서로 다른 단어를 사용할 수 있지만 유사한 의미를 공유하며, 이는 vector 유사성에 반영됩니다.
Vector Search: 사용자가 질문을 하면 질의도 vector로 변환됩니다. 그런 다음 이 vector를 Milvus와 같은 vector database에 저장된 다른 vector들과 비교하여 의미적으로 가장 유사한 항목을 찾습니다.
Answer Generation: 검색된 정보는 사용자 질의와 함께 large language model로 전달됩니다. 모델은 검색된 콘텐츠에 기반한 응답을 생성하여 답변이 관련성 있고 정확하도록 보장합니다.
이러한 검색과 생성의 결합은 AI의 응답이 문맥에 적절하고 지식 베이스에서 가져온 가장 관련성 높은 데이터에 기반하도록 보장합니다.
다음 섹션에서는 Strapi에서 관리되는 지식 베이스를 사용하여 Milvus에 대한 질문에 답할 수 있는 RAG 기반 FAQ 시스템을 만들어 보겠습니다. 이 시스템은 vector search를 위해 Milvus, 워크플로 조정을 위해 LangChain.js, 정확하고 문맥에 맞는 응답 제공을 위해 OpenAI의 언어 모델을 통합합니다. 콘텐츠는 오픈 소스 Node.js headless CMS인 Strapi에서 구성되며, AI의 답변을 사용자의 맞춤형 데이터에 기반하도록 실시간으로 쿼리됩니다.
빠르게 살펴보고 싶다면 full code와 Strapi backend가 여기 있습니다.
개발 환경 설정하기
코드를 작성하기 전에, 위에서 논의한 세 가지 핵심 구성 요소를 설정해야 합니다: vector 저장소를 위한 Milvus, 콘텐츠 관리를 위한 Strapi, 그리고 embeddings와 응답을 생성하기 위한 OpenAI API 키입니다. 각각을 설정해 보겠습니다.
Milvus 설치하기
먼저 벡터 데이터베이스인 Milvus를 설정해 보겠습니다. 사전 구성된 Docker Compose 파일을 수동으로 다운로드하거나 wget을 사용할 수 있습니다. 터미널에서 다음을 실행하세요:
wget https://github.com/milvus-io/milvus/releases/download/v2.0.2/milvus-standalone-docker-compose.yml -O docker-compose.yml
구성 파일이 준비되면 Docker를 사용해 Milvus를 시작합니다:
sudo docker-compose up -d
Docker Compose V1이 아니라 V2를 사용 중이라면 docker-compose 대신 docker compose를 사용하세요. docker compose version을 실행해 버전을 확인할 수 있습니다.
Strapi 설치하기
이제 지식 베이스 콘텐츠를 관리하기 위해 Strapi를 설정해 보겠습니다. 다음 명령을 사용해 새 Strapi 프로젝트를 만드세요:
npx create-strapi-app@latest my-project
프롬프트가 표시되면 데이터베이스로 SQLite를 선택하세요. 설치 후 Strapi가 브라우저에서 열리며, 여기서 관리자 계정을 생성하게 됩니다.
Strapi 관리자 패널에서 MILVUS-KNOWLEDGEBASE라는 새 컬렉션 유형을 만드세요. 두 개의 필수 필드를 추가하세요: Text 타입을 사용하는 Title 필드와 Rich text 타입을 사용하는 Content 필드입니다.
그림- Strapi 컬렉션에 필드 추가하기
그림: Strapi 컬렉션에 필드 추가하기
이 구조는 지식 베이스 항목을 효과적으로 정리하는 데 도움이 됩니다.
컬렉션 설정이 완료되면 Content Manager로 이동해 샘플 콘텐츠를 추가하세요.
그림- 데이터로 채워진 Strapi 컬렉션
그림: 데이터로 채워진 Strapi 컬렉션
항목을 만든 후에는 반드시 게시하세요. 게시되지 않은 항목은 애플리케이션에서 접근할 수 없습니다.
항목을 게시한 후에는 권한을 구성해 콘텐츠를 RAG 시스템에서 사용할 수 있도록 해야 합니다. Settings로 이동한 다음 Roles로 이동하고 Public 역할을 선택하세요. MILVUS-KNOWLEDGEBASE 컬렉션을 찾아 find 및 findOne 권한을 활성화하세요.
OpenAI API 키 받기
마지막으로 필요한 것은 임베딩과 응답을 생성하기 위한 OpenAI API 키입니다. OpenAI platform을 방문해 가입하거나 로그인하세요. API keys 섹션으로 이동해 새 비밀 키를 만드세요. 이 키는 나중에 필요하며 한 번만 볼 수 있으므로 복사해 안전한 곳에 저장하세요.
React 애플리케이션 만들기
핵심 서비스가 준비되었으니, RAG 시스템을 호스팅할 React 애플리케이션을 설정해 보겠습니다. create-react-app 도구를 사용해 새 React 프로젝트를 만들고 필요한 의존성을 설치하겠습니다.
먼저 새 React 애플리케이션을 만드세요:
npx create-react-app rag-app
cd rag-app
이 명령은 필요한 모든 빌드 도구와 구성을 갖춘 완전한 React 프로젝트를 설정합니다. 다음으로 RAG 시스템에 필요한 패키지를 설치하세요:
npm install @langchain/community @langchain/openai @zilliz/milvus2-sdk-node axios cors express langchain react-markdown
각 패키지가 애플리케이션에서 수행할 역할은 다음과 같습니다:
@langchain/community: 다양한 도구 및 서비스와의 통합에 접근할 수 있게 해줍니다. Milvus 벡터 저장소 통합에 사용할 것입니다.@langchain/openai: OpenAI 모델과의 상호작용을 처리합니다. GPT-3.5를 사용해 임베딩을 생성하고 응답을 생성하는 데 사용할 것입니다.@zilliz/milvus2-sdk-node: Node.js용 공식 Milvus SDK입니다. 벡터 작업을 위해 Milvus 인스턴스와 직접 통신할 수 있게 해줍니다.axios: HTTP 클라이언트입니다. Strapi API에서 콘텐츠를 가져오고 프론트엔드와 백엔드 간에 요청을 보내는 데 사용할 것입니다.cors: Cross-Origin Resource Sharing을 활성화하는 Express용 미들웨어입니다. 이를 통해 프론트엔드가 백엔드 서버와 안전하게 통신할 수 있습니다.express: Node.js용 웹 프레임워크입니다. 프론트엔드, Milvus, OpenAI 간을 조정하는 백엔드 API를 구축하는 데 사용할 것입니다.langchain: RAG 워크플로를 오케스트레이션하고, 콘텐츠 검색과 응답 생성 사이의 흐름을 관리하는 데 도움을 주는 핵심 LangChain 라이브러리입니다.react-markdown: React 컴포넌트에서 Markdown 콘텐츠를 렌더링합니다. AI 어시스턴트의 서식이 지정된 응답을 표시하는 데 사용할 것입니다.
백엔드 서버 구축하기
이제 개발 환경 설정을 완료했으므로, RAG 시스템을 구동할 서버 구축을 시작해 보겠습니다. 서버는 Strapi에서 콘텐츠 가져오기, 텍스트를 벡터 임베딩으로 변환하기, 이를 Milvus에 저장하기, 그리고 응답을 생성하기 위해 RAG 워크플로를 조정하기 등 몇 가지 핵심 작업을 처리합니다.
프로젝트 루트에 server.mjs라는 파일과 .env라는 또 다른 파일을 만드세요. .env 파일에 OpenAI 키를 다음과 같이 붙여 넣으세요:
OPENAI_API_KEY= your-api-key
이 파일은 민감한 정보를 저장하는 데 사용할 파일입니다. 이제 서버 파일을 열고 코딩을 시작해 보겠습니다.
1. 서버 및 환경 설정하기
필요한 라이브러리를 가져오고 환경 변수를 구성하여 시스템이 안전하게 실행되도록 시작하세요.
import express from "express";
import axios from "axios";
import dotenv from "dotenv";
import cors from "cors";
import { ChatOpenAI } from "@langchain/openai";
import { createStuffDocumentsChain } from "langchain/chains/combine_documents";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
import { OpenAIEmbeddings } from "@langchain/openai";
import { Milvus } from "@langchain/community/vectorstores/milvus";
import { Document } from "langchain/document";
import { HumanMessage, AIMessage } from "@langchain/core/messages";
dotenv.config();
환경에 설치한 라이브러리를 코드로 가져오면 코드에서 해당 함수와 메서드를 호출하고 사용할 수 있습니다. dotenv.config() 함수는 API 키를 로드하여, 코드에 직접 노출하지 않고도 애플리케이션 전체에서 접근할 수 있도록 합니다.
2. 애플리케이션 구성하기
다음으로, 구성 객체를 사용해 애플리케이션의 설정을 중앙 집중화합니다. 이 단계는 모든 핵심 매개변수를 한곳에 유지하여 필요에 따라 더 쉽게 관리하고 조정할 수 있도록 도와줍니다.
const CONFIG = {
PORT: parseInt(process.env.PORT || "30080", 10),
STRAPI: {
URL: "http://localhost:1337/api/milvus-knowledgebases",
TIMEOUT: 5000
},
MILVUS: {
URL: "localhost:19530",
COLLECTION: "rag_collection",
PRIMARY_FIELD: "pk",
VECTOR_FIELD: "vector",
TEXT_FIELD: "text",
TEXT_MAX_LENGTH: 4096,
SEARCH_PARAMS: {
nprobe: 16,
offset: 0
},
BATCH_SIZE: 100
},
CHUNKING: {
SIZE: 2000,
OVERLAP: 200
},
TOP_K: 3
};
이 구성 객체는 애플리케이션의 중요한 매개변수를 정의합니다. PORT 설정은 서버가 수신 대기할 포트를 결정합니다. STRAPI 섹션에는 콘텐츠 관리 시스템의 URL과 데이터를 가져올 때 오래 기다리는 것을 방지하기 위한 타임아웃 값이 포함되어 있습니다.
MILVUS 섹션은 벡터 데이터베이스를 구성하며, 컬렉션 이름, 텍스트 길이 제한, 검색 매개변수와 같은 세부 정보를 지정합니다. nprobe는 Milvus가 검사하는 클러스터 수를 조정하여 벡터 검색의 정밀도를 제어합니다. 마지막으로, CHUNKING 섹션은 청크 크기와 오버랩을 포함해 문서를 더 작은 부분으로 분할하는 방법을 설명하며, 처리 중에 컨텍스트가 손실되지 않도록 합니다.
3. 서버 및 AI 서비스 초기화하기
구성이 완료되었으므로, 다음 단계는 Express 서버를 초기화하고 AI 서비스를 설정하는 것입니다. 여기에는 응답 생성을 위한 대규모 언어 모델과 텍스트를 벡터로 변환하기 위한 임베딩 서비스가 포함됩니다.
const app = express();
app.use(express.json({ limit: '1mb' }));
app.use(cors());
const model = new ChatOpenAI({
modelName: "gpt-3.5-turbo",
temperature: 0.7,
openAIApiKey: process.env.OPENAI_API_KEY,
maxRetries: 3,
timeout: 30000,
});
const embeddings = new OpenAIEmbeddings({
openAIApiKey: process.env.OPENAI_API_KEY,
maxRetries: 3,
});
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: CONFIG.CHUNKING.SIZE,
chunkOverlap: CONFIG.CHUNKING.OVERLAP,
separators: ["\n\n", "\n", " ", ""]
});
app 객체는 Express를 사용하여 생성되며, 들어오는 JSON 요청을 파싱하고 CORS를 사용하여 교차 출처 요청을 처리하기 위한 미들웨어가 추가됩니다. 이를 통해 서버는 서로 다른 도메인에서 오는 페이로드를 수락할 수 있으며, 이는 프론트엔드 통신에 필수적입니다.
다음으로, 응답 생성을 위해 시스템을 OpenAI의 GPT-3.5 모델에 연결하는 데 사용할 ChatOpenAI 인스턴스를 생성합니다. temperature 매개변수는 응답의 창의성을 제어하며, 낮은 값은 더 예측 가능한 출력을 생성하고 높은 값은 더 다양하고 창의적인 출력을 생성합니다.. OpenAIEmbeddings 인스턴스는 텍스트를 벡터로 변환하여 의미 기반 검색을 가능하게 합니다. 마지막으로, RecursiveCharacterTextSplitter 인스턴스는 구성에 정의된 크기와 오버랩을 기준으로 텍스트를 청크로 나눕니다.
4. 처리를 위한 문서 준비
Milvus를 사용하여 문서를 저장하거나 검색하기 전에, 원시 콘텐츠를 일관된 형식으로 정리하고 구조화해야 합니다. 이를 통해 데이터가 워크플로의 이후 단계에서 임베딩 및 효율적인 검색을 수행할 준비가 되도록 보장합니다.
let milvusStore = null;
let lastDataHash = null;
const processDocument = (content) => {
if (!content?.Content || !content.Title) return null;
const processedContent = content.Content
.map(section => {
if (section.type === 'paragraph' && section.children) {
return section.children
.map(child => child.text)
.join(' ')
.trim();
}
return null;
})
.filter(Boolean)
.join('\n\n');
return processedContent ? new Document({
pageContent: processedContent,
metadata: {
source: 'milvus_content',
id: content.id,
title: content.Title,
documentId: content.documentId
}
}) : null;
};
위 코드에서 let milvusStore와 let lastDataHash 변수는 전역 참조 역할을 합니다. milvusStore는 Milvus 컬렉션에 대한 초기화된 연결을 보유하며, lastDataHash는 마지막 업데이트 이후 기본 문서 데이터가 변경되었는지 추적합니다.
processDocument 함수는 Strapi의 원시 데이터를 구조화된 Document 객체로 형식화하는 데 도움이 됩니다. 먼저 콘텐츠와 제목 필드가 존재하는지 확인합니다. 유효한 경우, 콘텐츠의 섹션을 순회하며 문단에서 텍스트를 추출하고 불필요한 공백을 제거하여 정리합니다. 이러한 섹션들은 하나의 응집된 텍스트 블록으로 결합됩니다. 그런 다음 함수는 문서의 제목, ID, 출처와 같은 메타데이터를 포함하여 처리된 텍스트를 Document 객체로 감싸 반환합니다. 이 형식은 추가 처리를 위해 Milvus 및 LangChain과의 호환성을 보장합니다.
5. Milvus에 연결하거나 초기화하기
이제 문서가 준비되었으므로, 다음 단계는 이를 Milvus와 통합하는 것입니다. 기존 Milvus 컬렉션에 대한 연결을 설정하거나, 존재하지 않는 경우 새로 생성하고 준비된 문서로 채울 것입니다.
async function getMilvusStore() {
if (milvusStore) {
return milvusStore;
}
try {
// First try to connect to existing collection
try {
console.log('Attempting to connect to existing collection');
milvusStore = await Milvus.fromExistingCollection(
embeddings,
{
url: CONFIG.MILVUS.URL,
collectionName: CONFIG.MILVUS.COLLECTION,
primaryField: CONFIG.MILVUS.PRIMARY_FIELD,
vectorField: CONFIG.MILVUS.VECTOR_FIELD,
textField: CONFIG.MILVUS.TEXT_FIELD,
textFieldMaxLength: CONFIG.MILVUS.TEXT_MAX_LENGTH,
}
);
return milvusStore;
} catch (error) {
console.log('Collection does not exist, will create new one');
// If collection doesn't exist, fetch data and create it
const response = await axios.get(CONFIG.STRAPI.URL, {
timeout: CONFIG.STRAPI.TIMEOUT
});
const processPromises = response.data.data.map(processDocument);
const processedDocs = await Promise.all(processPromises);
const uniqueDocs = new Map();
processedDocs
.filter(Boolean)
.forEach(doc => uniqueDocs.set(doc.metadata.documentId, doc));
const docs = Array.from(uniqueDocs.values());
// Split documents
const splitPromises = docs.map(doc => splitter.splitDocuments([doc]));
const splitDocs = await Promise.all(splitPromises);
const allSplitDocs = splitDocs.flat();
console.log(`Creating new collection with ${allSplitDocs.length} documents`);
milvusStore = await Milvus.fromDocuments(
allSplitDocs,
embeddings,
{
url: CONFIG.MILVUS.URL,
collectionName: CONFIG.MILVUS.COLLECTION,
primaryField: CONFIG.MILVUS.PRIMARY_FIELD,
vectorField: CONFIG.MILVUS.VECTOR_FIELD,
textField: CONFIG.MILVUS.TEXT_FIELD,
textFieldMaxLength: CONFIG.MILVUS.TEXT_MAX_LENGTH,
}
);
lastDataHash = Buffer.from(JSON.stringify(response.data)).toString('base64');
return milvusStore;
}
} catch (error) {
console.error('Failed to initialize Milvus:', error);
throw error;
}
}`
이 함수는 Milvus 컬렉션에 대한 연결을 관리합니다. milvusStore 변수가 이미 활성 연결을 보유하고 있다면, 불필요한 작업을 피하기 위해 함수는 즉시 이를 반환합니다. 그렇지 않으면 fromExistingCollection 메서드를 사용하여 Milvus의 기존 컬렉션에 연결을 시도합니다. 컬렉션이 존재하지 않으면 함수는 새 컬렉션을 생성합니다. 먼저 Strapi에서 데이터를 가져오고, processDocument 함수를 사용해 문서를 처리하며, documentId를 Map의 키로 사용하여 각 문서가 고유한지 확인합니다. 그런 다음 문서는 splitter를 사용해 더 작은 청크로 분할되며, 이를 통해 문서가 Milvus에서 임베딩하거나 인덱싱하기에 너무 크지 않도록 합니다. 처리된 청크는 이후 fromDocuments 메서드를 사용해 Milvus 컬렉션에 저장됩니다. lastDataHash는 Strapi 데이터의 현재 상태를 반영하도록 업데이트되어 변경 사항을 추적할 수 있게 합니다.
6. Milvus를 최신 상태로 유지하기
벡터 데이터베이스에 최신 데이터가 포함되도록 하려면 Milvus가 Strapi와 동기화된 상태를 유지해야 합니다. Strapi의 콘텐츠 변경 사항을 감지하고, 업데이트되었거나 새로 추가된 문서를 식별하며, 그에 따라 Milvus 컬렉션을 업데이트하는 프로세스를 구현해 보겠습니다.
// Update Milvus data if needed
async function updateMilvusData() {
try {
const response = await axios.get(CONFIG.STRAPI.URL, {
timeout: CONFIG.STRAPI.TIMEOUT
});
const currentHash = Buffer.from(JSON.stringify(response.data)).toString('base64');
if (currentHash === lastDataHash) {
console.log('Data unchanged, skipping update');
return false;
}
console.log('Content changed, updating Milvus collection');
const processPromises = response.data.data.map(processDocument);
const processedDocs = await Promise.all(processPromises);
const uniqueDocs = new Map();
processedDocs
.filter(Boolean)
.forEach(doc => uniqueDocs.set(doc.metadata.documentId, doc));
const docs = Array.from(uniqueDocs.values());
// Split documents
const splitPromises = docs.map(doc => splitter.splitDocuments([doc]));
const splitDocs = await Promise.all(splitPromises);
const allSplitDocs = splitDocs.flat();
// Delete all existing documents before adding new ones
await milvusStore.delete({});
// Add new documents in batches
for (let i = 0; i < allSplitDocs.length; i += CONFIG.MILVUS.BATCH_SIZE) {
const batch = allSplitDocs.slice(i, i + CONFIG.MILVUS.BATCH_SIZE);
await milvusStore.addDocuments(batch);
console.log(`Added batch ${Math.floor(i / CONFIG.MILVUS.BATCH_SIZE) + 1} of ${Math.ceil(allSplitDocs.length / CONFIG.MILVUS.BATCH_SIZE)}`);
}
lastDataHash = currentHash;
console.log(`Updated Milvus with ${allSplitDocs.length} documents`);
return true;
} catch (error) {
console.error("Error updating Milvus data:", error);
throw error;
}
}
이 함수는 Strapi에서 최신 데이터를 가져오고 해당 해시를 계산하는 것으로 시작합니다. 해시가 lastDataHash와 일치하면 데이터가 변경되지 않은 것이므로 업데이트가 필요하지 않습니다. 그렇지 않으면 함수는 업데이트된 문서를 처리하고 delete 메서드를 사용하여 Milvus의 기존 컬렉션을 비웁니다. 그런 다음 새 문서 청크가 배치 단위로 추가되어, 진행 상황 추적을 위한 로깅을 유지하면서 효율적인 저장을 보장합니다. 마지막으로, 향후 검사에서 데이터 변경을 정확하게 감지할 수 있도록 lastDataHash를 업데이트합니다.
7. 사용자 쿼리 처리
Milvus에 데이터를 채운 후 다음 단계는 사용자 쿼리를 처리하는 것입니다. 시스템은 사용자의 쿼리와의 의미적 유사성을 기반으로 Milvus에서 가장 관련성 높은 문서를 검색합니다. 그런 다음 이러한 문서는 OpenAI의 GPT-3.5 대규모 언어 모델을 사용하여 컨텍스트 인식 응답을 생성하는 데 사용됩니다.
async function handleQuery(chatHistory, input) {
const store = await getMilvusStore();
try {
await updateMilvusData();
} catch (error) {
console.warn("Failed to check for updates:", error);
}
const results = await store.similaritySearchWithScore(
input,
CONFIG.TOP_K
);
console.log('\n=== Retrieved Documents from Milvus ===');
results.forEach(([doc, score], index) => {
console.log(`\nDocument ${index + 1} (score: ${score}):`);
console.log('Title:', doc.metadata.title);
console.log('Content:', doc.pageContent.substring(0, 150) + '...');
});
const chain = await createStuffDocumentsChain({
llm: model,
prompt: ChatPromptTemplate.fromMessages([
[
"system",
`You are an AI assistant specializing in Milvus and Zilliz, vector database technologies. Your goal is to provide accurate and helpful answers based on the provided context.
**Guidelines:**
1. **Milvus/Zilliz-Related Queries:**
- If the query is about Milvus or Zilliz and the context includes relevant information, provide a detailed and structured response.
- Use proper Markdown formatting:
- \`\`\` for code blocks
- **Bold** for emphasis
- Bullet points for lists
- ### Headings for sections
- If no relevant information exists in the context, respond with: *"I don't have that information yet."*
2. **Unrelated Queries:**
- If the query is not about Milvus or Zilliz, respond with:
*"This topic is outside my expertise. I specialize in Milvus and Zilliz. Please ask questions related to these technologies."*
**Context:**
{context}
**User Query:**
{input}`
],
...chatHistory,
["user", "{input}"]
]),
documentPrompt: ChatPromptTemplate.fromTemplate("Content: {page_content}\n\n")
});
const response = await chain.invoke({
input,
context: results.map(([doc]) => doc)
});
return {
answer: response,
context: results.map(([doc]) => ({
content: doc.pageContent,
title: doc.metadata.title,
id: doc.metadata.id,
documentId: doc.metadata.documentId
}))
};
}
이 함수는 먼저 Milvus 벡터 저장소가 최신 상태인지 확인하는 것으로 시작합니다. updateMilvusData 함수는 쿼리 프로세스를 중단하지 않고 잠재적인 오류를 처리하기 위해 try-catch 블록 내에서 호출됩니다. 그런 다음 쿼리는 Milvus의 similaritySearchWithScore 메서드로 전달되며, 이 메서드는 데이터베이스에서 가장 유사한 상위 k개의 문서를 검색합니다. 검색된 문서들은 로그로 기록됩니다. 각 문서에는 제목 및 ID와 같은 메타데이터뿐만 아니라 콘텐츠의 잘린 미리보기도 포함됩니다. 이를 통해 RAG pipeline이 올바른 문서를 검색하고 있는지 파악할 수 있습니다.
다음으로, 문서들은 제공된 컨텍스트만을 기반으로 답변하도록 모델에 지시하는 프롬프트를 사용하여 LangChain createStuffDocumentsChain에 입력됩니다. 이를 통해 OpenAI의 GPT-3.5가 외부 지식에 의존하지 않고 검색된 콘텐츠에 기반한 응답을 생성하도록 하여 정확성과 관련성을 유지합니다. 마지막으로, 이 함수는 생성된 응답과 검색된 문서의 컨텍스트를 모두 반환합니다.
8. API 엔드포인트 설정
이제 백엔드가 사용자 쿼리를 처리하고 Milvus와 상호작용할 수 있으므로, 다음 단계는 이 기능을 API 엔드포인트를 통해 노출하는 것입니다. 이러한 엔드포인트는 백엔드와 프론트엔드(또는 다른 클라이언트 애플리케이션) 사이의 다리 역할을 합니다.
app.post("/chat", async (req, res) => {
try {
const { chatHistory, input } = req.body;
if (!input?.trim()) {
return res.status(400).json({
error: "Invalid input",
message: "No input provided"
});
}
const formattedHistory = Array.isArray(chatHistory)
? chatHistory.map(msg =>
msg.role === "user"
? new HumanMessage(msg.content)
: new AIMessage(msg.content)
)
: [];
const response = await handleQuery(formattedHistory, input);
res.json(response);
} catch (error) {
console.error("Chat request error:", error);
res.status(500).json({
error: "Internal server error",
message: process.env.NODE_ENV === 'production'
? "An unexpected error occurred"
: error.message
});
}
});
app.get("/health", async (req, res) => {
try {
const store = await getMilvusStore();
res.json({
status: "ok",
timestamp: new Date().toISOString(),
milvusInitialized: !!store,
config: CONFIG
});
} catch (error) {
res.status(500).json({
status: "error",
error: error.message
});
}
});
/chat 엔드포인트는 사용자의 쿼리와 채팅 기록이 포함된 수신 POST 요청을 처리합니다. 채팅 기록은 컨텍스트를 유지하기 위해 HumanMessage 및 AIMessage 객체로 형식화됩니다. 쿼리가 유효하지 않은 경우(예: 비어 있음), 엔드포인트는 400 Bad Request 상태와 적절한 오류 메시지로 응답합니다. 쿼리가 유효한 경우, handleQuery 함수가 호출되어 입력을 처리하고, Milvus에서 관련 문서를 검색하며, 응답을 생성합니다. 결과는 JSON 객체로 반환됩니다. 오류가 발생하면 로그에 기록되고, 서버는 500 Internal Server Error로 응답합니다.
반면, /health 엔드포인트는 서버 상태를 모니터링하는 방법을 제공합니다. Milvus store가 초기화되었는지 확인하고, 서버 구성 및 현재 타임스탬프와 함께 ok 또는 error 상태로 응답합니다.
9. 서버 시작하기
마지막 단계는 서버를 시작하고 요청을 처리할 수 있도록 만드는 것입니다.
app.listen(CONFIG.PORT, () => {
console.log(`Server running on http://localhost:${CONFIG.PORT}`);
console.log('Configuration:', CONFIG);
});
app.listen 메서드는 구성에 지정된 포트, 이 경우 30080 포트에서 서버를 시작합니다. 서버가 실행되면, 현재 구성뿐만 아니라 접근할 수 있는 URL을 나타내는 메시지를 로그에 기록합니다. 이를 통해 백엔드가 요청을 처리할 준비가 되었음을 확인할 수 있습니다.
그림- 현재 구성을 로그에 기록하면서 실행 중인 RAG 시스템 백엔드 서버
그림: 현재 구성을 로그에 기록하면서 실행 중인 RAG 시스템 백엔드 서버
서버를 실행하려면 터미널로 이동하여 이 명령을 실행하세요.
node .\server.mjs
이제 RAG 시스템의 백엔드 생성이 완료되었습니다.
RAG 시스템의 프론트엔드 만들기
RAG 시스템의 백엔드가 완전히 작동하므로, 다음 단계는 사용자가 시스템과 상호작용할 수 있도록 프론트엔드를 만드는 것입니다. 프론트엔드는 백엔드에 연결되는 chatbot 인터페이스가 되어, 사용자가 쿼리를 입력하고 AI가 생성한 응답을 받을 수 있게 합니다.
사용자 인터페이스 만들기
먼저 src 디렉터리에 ChatbotUI.js라는 파일을 생성하세요. 이 파일은 chatbot 컴포넌트를 정의하고 사용자 쿼리와 백엔드 응답의 흐름을 관리합니다.
import React, { useState, useEffect, useRef } from 'react';
import ReactMarkdown from 'react-markdown';
import axios from 'axios';
import './ChatbotUI.css';
const ChatbotUI = () => {
const [chatHistory, setChatHistory] = useState([]);
const [userInput, setUserInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const chatContainerRef = useRef(null);
useEffect(() => {
// Scroll to the latest message when chat history updates
chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight;
}, [chatHistory]);
const handleUserInput = (e) => setUserInput(e.target.value);
const handleSendMessage = async () => {
if (userInput.trim() !== '') {
const newMessage = { role: 'user', content: userInput };
const updatedChatHistory = [...chatHistory, newMessage];
setChatHistory(updatedChatHistory);
setUserInput('');
setIsLoading(true);
try {
const response = await axios.post('http://localhost:30080/chat', {
chatHistory: updatedChatHistory,
input: userInput,
});
const botMessage = {
role: 'assistant',
content: response.data.answer || 'Sorry, I didn’t quite get that.',
};
setTimeout(() => {
setChatHistory((prevMessages) => [...prevMessages, botMessage]);
}, 1000); // Simulate typing delay
} catch (err) {
console.error('Error:', err);
setError('Unable to connect to the server.');
} finally {
setIsLoading(false);
}
}
};
return (
<div className="chatbot-ui">
{/* Header */}
<div className="chat-header">
<img src="/milvus_logo.png" alt="Assistant" className="assistant-logo" />
<h2>Chat Assistant</h2>
</div>
{/* Chat Messages */}
<div className="chat-body" ref={chatContainerRef}>
{chatHistory.map((message, index) => (
<div
key={index}
className={`message ${message.role === 'user' ? 'user-message' : 'bot-message'}`}
>
<ReactMarkdown>{message.content}</ReactMarkdown>
</div>
))}
{isLoading && (
<div className="bot-message typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
)}
{error && <div className="error-message">{error}</div>}
</div>
{/* Footer */}
<div className="chat-footer">
<input
type="text"
placeholder="Ask me anything..."
value={userInput}
onChange={handleUserInput}
onKeyPress={(e) => {
if (e.key === 'Enter') handleSendMessage();
}}
disabled={isLoading}
/>
<button onClick={handleSendMessage} disabled={isLoading}>
<span className="send-icon">✈</span>
</button>
</div>
</div>
);
};
export default ChatbotUI;
이 컴포넌트는 React의 useState를 사용하여 채팅 기록 및 사용자 입력과 같은 상태를 관리합니다. useEffect 훅은 채팅 컨테이너가 자동으로 최신 메시지로 스크롤되도록 합니다. 사용자가 쿼리를 제출하면 axios POST 요청을 통해 http://localhost:30080/chat 백엔드 API 엔드포인트로 전송됩니다. 그런 다음 백엔드 응답이 채팅 인터페이스에 표시됩니다.
챗봇 스타일링하기
다음으로 src 디렉터리에 ChatbotUI.css라는 파일을 만듭니다. 이 파일은 챗봇 인터페이스의 스타일을 지정합니다.
/* Full-Screen Gradient Background */
.chatbot-ui {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100vh;
background: linear-gradient(135deg, #8358ff, #00a6ff); /* Milvus color palette */
font-family: 'Roboto', sans-serif;
color: #fff;
}
/* Chat Header */
.chat-header {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(90deg, #1e1e2d, #28293e);
width: 100%;
padding: 15px;
border-radius: 15px 15px 0 0;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
}
.assistant-logo {
width: 50px;
height: 50px;
margin-right: 15px;
border-radius: 50%;
background: radial-gradient(circle, #8358ff, #00a6ff); /* Dynamic logo glow */
animation: pulse 2s infinite;
}
.chat-header h2 {
font-size: 1.8rem;
font-weight: bold;
color: #ffffff;
}
.chat-header p {
font-size: 0.9rem;
color: #a0a0b1;
margin-top: 5px;
}
/* Chat Body */
.chat-body {
flex: 1;
width: 100%;
padding: 15px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
background: #1e1e2d;
color: #ffffff;
border-radius: 0 0 15px 15px;
}
.message {
max-width: 80%;
padding: 12px 15px;
border-radius: 10px;
font-size: 1rem;
line-height: 1.5;
animation: fadeIn 0.3s ease-in-out;
}
.user-message {
align-self: flex-end;
background: linear-gradient(90deg, #8358ff, #00a6ff);
color: #ffffff;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
}
.bot-message {
align-self: flex-start;
background: #29293f;
color: #d4d4e5;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
}
/* Typing Indicator */
.typing-indicator {
display: flex;
justify-content: flex-start;
gap: 5px;
}
.typing-indicator span {
width: 8px;
height: 8px;
background-color: #00a6ff;
border-radius: 50%;
animation: blink 1.2s infinite;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
/* Footer */
.chat-footer {
display: flex;
align-items: center;
width: 100%;
padding: 10px 15px;
background: #1e1e2d;
border-top: 1px solid #28293e;
border-radius: 0 0 15px 15px;
box-shadow: 0 -4px 10px rgba(0, 0, 0, 0.2);
}
.chat-footer input {
flex: 1;
padding: 12px 15px;
border-radius: 30px;
border: 1px solid #8358ff;
font-size: 1rem;
outline: none;
background: #29293f;
color: #ffffff;
transition: border-color 0.3s ease;
}
.chat-footer input:focus {
border-color: #00a6ff;
}
.chat-footer button {
margin-left: 10px;
padding: 12px 20px;
border: none;
border-radius: 30px;
background: linear-gradient(90deg, #8358ff, #00a6ff);
color: white;
font-size: 1.2rem;
cursor: pointer;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.chat-footer button:hover {
transform: scale(1.1);
box-shadow: 0 6px 15px rgba(0, 0, 0, 0.4);
}
.chat-footer button:disabled {
background: #29293f;
color: #a0a0b1;
cursor: not-allowed;
}
@keyframes blink {
0%, 80%, 100% {
opacity: 0;
}
40% {
opacity: 1;
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
box-shadow: 0 0 10px rgba(131, 88, 255, 0.5);
}
50% {
transform: scale(1.1);
box-shadow: 0 0 20px rgba(0, 166, 255, 0.7);
}
}
CSS 스타일은 Milvus 색상 팔레트에서 영감을 받은 챗봇 인터페이스를 만듭니다. 배경은 Milvus의 브랜딩을 반영하는 짙은 보라색과 시안 색조의 그라데이션을 사용합니다. 채팅 헤더는 어두운 그라데이션 스타일을 따르며, 사용자 메시지는 Milvus의 기본 색상과 어울리는 밝은 그라데이션으로 강조됩니다. 봇 메시지는 더 어둡고 차분한 테마로 스타일링됩니다. 이 디자인은 Milvus의 색상을 활용해 통일성 있고 기능적인 사용자 인터페이스를 제공합니다.
그림- RAG 시스템 완성 사용자 인터페이스
그림: RAG 시스템 완성 사용자 인터페이스
이것이 최종 UI의 모습입니다.
챗봇을 애플리케이션에 통합하기
마지막으로, src 디렉터리의 App.js 파일을 업데이트하여 ChatbotUI 컴포넌트를 렌더링하세요.
import React from 'react';
import ChatbotUI from './ChatbotUI';
const App = () => {
return (
<div>
<ChatbotUI />
</div>
);
};
export default App;
이렇게 하면 애플리케이션이 시작될 때 챗봇이 메인 컴포넌트로 렌더링됩니다. App 컴포넌트는 React 애플리케이션의 진입점 역할을 합니다.
RAG 시스템 테스트하기
백엔드와 프런트엔드 설정을 완료한 후에는 전체 RAG 파이프라인이 예상대로 작동하는지 테스트할 차례입니다. 진행 방법은 다음과 같습니다.
백엔드 시작하기
터미널을 열고 프로젝트 디렉터리로 이동한 다음 백엔드 서버를 시작하세요.
node server.mjs
백엔드가 http://localhost:30080에서 실행 중인지 확인하세요. 로그를 확인하여 Milvus가 초기화되었고 쿼리를 처리할 준비가 되었는지 검증하세요.
프런트엔드 실행하기
별도의 터미널을 열고 rag-app 디렉터리로 이동한 다음 React 프런트엔드를 시작하세요.
npm start
그러면 기본 브라우저에서 http://localhost:3000으로 애플리케이션이 실행됩니다.
챗봇 인터페이스 테스트하기
챗봇 인터페이스에서 Milvus 또는 Zilliz와 관련된 질문을 입력하세요. 또한, 이러한 주제에서 벗어난 질문을 입력해 시스템이 적절한 메시지로 응답하는지 확인해 보세요. 마지막으로, Strapi 지식 베이스의 범위를 벗어날 수 있는 질문도 입력해 보세요. 다음은 샘플 결과입니다:
그림- Milvus와 관련된 쿼리에 대한 RAG 시스템 결과
그림: Milvus와 관련된 쿼리에 대한 RAG 시스템 결과
그림- RAG 시스템 테스트 결과
그림: RAG 시스템 테스트 결과
위 스크린샷은 우리의 RAG 시스템이 예상대로 실행되고 있으며 올바른 결과를 표시하고 있음을 보여줍니다. 또한 지식 베이스를 기반으로 사용하기 때문에 Milvus의 창립자를 알지 못하는데, 이는 이 정보가 Strapi에 존재하지 않기 때문입니다.
계속해서 지식 베이스에 더 많은 콘텐츠를 추가하고 시스템을 테스트해 보세요.
결론
LangChain, Milvus, Strapi를 통합하여 검색 증강 생성(Retrieval-Augmented Generation, RAG) 시스템을 구축하는 것은 AI가 실제 최신 지식에 기반한 정확하고 도메인 특화된 응답을 제공할 수 있는 방법을 보여줍니다. 이 아키텍처는 고객 지원, 지식 관리, 교육 도구와 같은 애플리케이션에 이상적입니다. 아키텍처에 대한 명확한 이해와 이 단계별 가이드를 바탕으로, 이제 특정 요구 사항에 맞춘 RAG 시스템을 만들 수 있습니다.
관련 리소스
계속 읽기

Zilliz Cloud Delivers Better Performance and Lower Costs with Arm Neoverse-based AWS Graviton
Zilliz Cloud adopts Arm-based AWS Graviton3 CPUs to cut costs, speed up AI vector search, and power billion-scale RAG and semantic search workloads.

Zilliz Cloud Introduces Advanced BYOC-I Solution for Ultimate Enterprise Data Sovereignty
Explore Zilliz Cloud BYOC-I, the solution that balances AI innovation with data control, enabling secure deployments in finance, healthcare, and education sectors.

Demystifying the Milvus Sizing Tool
Explore how to use the Sizing Tool to select the optimal configuration for your Milvus deployment.


