구글이나 Perplexity.ai를 사용해 본 적이 있으신가요? 어떻게 최신 검색 결과를 보여주고 온라인 기사 링크까지 함께 제공할 수 있는지 궁금하신 적이 있으신가요? 이 가이드에서는 그러한 시스템을 직접 구축하는 방법을 단계별로 배워봅니다. 추가하는 기사 링크들이 점점 커지는 지식 저장소에 축적되고, 그 지식을 기반으로 개인화된 추천 결과를 생성하는 시스템을 만들어 보겠습니다.
사전 준비 사항
이 가이드를 따라 하려면 다음 항목들이 필요합니다.
- Node.js 18 이상
- Upstash 계정
- OpenAI 계정
- Fly.io 계정
기술 스택
| 기술 | 설명 |
|---|---|
| Upstash | 서버리스 데이터베이스 플랫폼. 벡터 임베딩과 메타데이터 저장을 위해 Upstash Vector를 사용합니다. |
| Remix | 웹 표준에 중점을 두고 풀스택 웹 애플리케이션을 구축하는 프레임워크입니다. |
| OpenAI | 첨단 AI 기술 개발에 집중하는 인공지능 연구 기업입니다. |
| LangChain | 언어 모델 기반 애플리케이션 개발을 위한 프레임워크입니다. |
| Vercel AI SDK | AI 기반 사용자 인터페이스 구축을 위한 오픈소스 라이브러리입니다. |
| TailwindCSS | 커스텀 디자인 구축을 위한 CSS 프레임워크입니다. |
| Fly.io | 풀스택 앱과 데이터베이스를 사용자 근처에서 실행할 수 있는 플랫폼입니다. |
| Prettier | 일관된 코드 스타일을 유지하는 코드 포맷터입니다. |
진행 순서
이 가이드를 완료하고 자신만의 기사 추천 시스템을 배포하려면 다음 단계를 따르세요.
- OpenAI 토큰 생성하기
- Upstash Vector 인덱스 생성하기
- 프로젝트 설정하기
- OpenAI API 클라이언트 인스턴스 생성하기
- OpenAI API 임베딩 클라이언트 만들기
- Upstash Vector 클라이언트 만들기
- 컨텍스트(Context) API 엔드포인트 만들기
- 채팅(Chat) API 엔드포인트 만들기
- Fly.io에 배포하기
- 참고 자료
- 마무리
OpenAI 토큰 생성하기
OpenAI API를 사용하면 기사의 벡터 임베딩을 얻을 수 있고, AI를 활용한 챗봇 응답도 생성할 수 있습니다. OpenAI API에 대한 모든 요청에는 인증 토큰이 필요합니다. 토큰을 발급받으려면 OpenAI 계정의 API Keys 페이지로 이동한 후 Create new secret key 버튼을 클릭하세요. 발급된 토큰을 복사하여 안전하게 보관하고, 나중에 OPENAI_API_KEY 환경 변수로 사용하시면 됩니다.
Upstash Vector 인덱스 생성하기
Upstash 계정을 생성하고 로그인한 후, Vector 탭으로 이동하여 Create Index 버튼을 클릭해 벡터 인덱스 생성을 시작합니다.
원하는 인덱스 이름(예: article)을 입력하고 벡터 차원은 1536으로 설정합니다.
그다음 아래로 스크롤하여 Connect 섹션으로 이동한 뒤 .env 버튼을 클릭하세요. 내용을 복사해서 안전한 곳에 저장해 두면, 이후 애플리케이션에서 활용할 수 있습니다.
프로젝트 설정하기
설정을 시작하려면 앱 저장소를 클론하고, 이 가이드를 따라가며 코드의 모든 내용을 학습하세요. 프로젝트를 클론하려면 터미널에서 다음 명령어를 실행합니다.
# Clone the project
git clone https://github.com/rishi-raj-jain/article-recommendation-system
cd article-recommendation-system
# Install the dependencies
pnpm install
저장소를 클론한 후 .env 파일을 생성하고, 앞서 발급받은 시크릿 키들을 추가합니다.
.env 파일에는 다음과 같은 키들이 포함되어야 합니다.
# .env
# OpenAI API Key
OPENAI_API_KEY="sk-..."
# Upstash Vector Keys
UPSTASH_VECTOR_REST_URL="https://...-us1-vector.upstash.io"
UPSTASH_VECTOR_REST_TOKEN="...="
여기까지 완료되면 설정 작업은 끝납니다. 이제 터미널에서 아래 명령어를 실행하고 localhost:3000에 접속하면 애플리케이션이 실제로 동작하는 모습을 확인할 수 있습니다.
pnpm run build && pnpm run start
계속 진행하며 자신만의 기사 추천 시스템을 성공적으로 구축할 수 있게 해주는 핵심 코드들을 살펴보겠습니다.
OpenAI API 클라이언트 인스턴스 생성하기
openai 패키지를 사용하면 몇 줄의 코드만으로 OpenAI REST API와 손쉽게 상호작용할 수 있습니다. 아래 코드로 OpenAI API 클라이언트 라이브러리의 인스턴스를 생성하여, 이후 채팅 완성(chat completion) 응답 생성에 활용합니다.
// File: app/lib/openai/completion.server.ts
import OpenAI from 'openai'
// Instantiate class to generate text completion using the OpenAI API
export default new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
})
참고: Remix에서 파일 이름 끝에 .server.ts를 붙이면 해당 코드가 클라이언트 사이드 번들에서 확실히 제외됩니다.
OpenAI API 임베딩 클라이언트 만들기
@langchain/openai 패키지를 사용하면 OpenAIEmbeddings 클래스로 주어진 텍스트의 벡터 임베딩을 생성할 수 있습니다. 이 클래스를 LangChain 벡터 스토어와 함께 사용하면, 각 벡터 임베딩을 직접 만들고 삽입하는 번거로운 과정을 생략할 수 있습니다. 아래 코드로 OpenAIEmbeddings 클래스의 인스턴스를 생성하여, 이후 임베딩 생성을 내부적으로 처리하도록 합니다.
// File: app/lib/openai/embedding.server.ts
import { OpenAIEmbeddings } from '@langchain/openai'
// Instantiate class to generate embeddings using the OpenAI API
export default new OpenAIEmbeddings({
modelName: 'text-embedding-3-small',
openAIApiKey: process.env.OPENAI_API_KEY,
})
Upstash Vector 클라이언트 만들기
@upstash/vector와 @langchain/community/vectorstores/upstash 패키지를 사용하면 Remix 애플리케이션에서 커넥션리스(connectionless) 클라이언트를 만들어, Upstash Vector 인덱스에 벡터 임베딩을 저장·삭제·조회할 수 있습니다.
// File: app/lib/upstash/vectorStore.server.ts
import embeddings from '~/lib/openai/embedding.server'
import { Index as UpstashIndex } from '@upstash/vector'
import { UpstashVectorStore } from '@langchain/community/vectorstores/upstash'
// Instantiate the Upstash Vector Index
const index = new UpstashIndex({
url: process.env.UPSTASH_VECTOR_REST_URL as string,
token: process.env.UPSTASH_VECTOR_REST_TOKEN as string,
})
// Instantiate the Upstash Vector Store that'll create and save embeddings
export default new UpstashVectorStore(embeddings, { index })
컨텍스트(Context) API 엔드포인트 만들기
Remix 애플리케이션을 실행하면 여러 개의 기사 URL을 입력받는 텍스트 박스가 표시됩니다. 이 기사들은 챗봇의 지식으로 추가되어, 이후 사용자 검색 시 개인화된 응답을 생성하는 데 활용됩니다. 이 섹션에서는 컨텍스트 엔드포인트(app/routes/api_.context.tsx)가 여러 기사 URL을 받아 콘텐츠를 가져오고, 벡터 임베딩을 생성한 후, 이를 동적으로 Upstash Vector 인덱스에 저장하는 방식을 살펴봅니다.
// File: app/routes/api_.context.tsx
import { Document } from 'langchain/document'
import { ActionFunctionArgs } from '@remix-run/node'
import vectorServer from '~/lib/vector/vectorStore.server'
import { CheerioWebBaseLoader } from 'langchain/document_loaders/web/cheerio'
export const action = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData()
// Check if any article link are present in the form submission
const articlesToEmbed = formData.get('articles') as string
if (articlesToEmbed) {
// Create the documents to be added to the Upstash Vector Store
const documents: any[] = []
await Promise.all(
articlesToEmbed.split(',').map(async (link) => {
// Use the link to render in the search results
// Parse the link using Cheerio
const loader = new CheerioWebBaseLoader(link.trim())
const scraper = await loader.scrape()
// Get the content of title tag to render in the search results
const name = scraper('title').html()
// Get the page content as string
const pageContent = scraper.text()
// Create metadata object to be inserted in the vector store
const metadata = { link, name }
documents.push(new Document({ pageContent, metadata }))
}),
)
// Creating embeddings from the provided documents along with metadata
// and add them to Upstash database
await vectorServer.addDocuments(documents.filter(Boolean))
}
}
위 Remix 액션에서는 컨텍스트 엔드포인트(/api/context)로 전송된 POST 요청의 폼 데이터를 파싱합니다. 그런 다음 콤마(,)로 구분된 기사 링크 목록을 순회하면서 다음 작업을 수행합니다.
- 기사 웹페이지에서 가져온 텍스트 콘텐츠로
pageContent변수를 생성합니다. - 기사 웹페이지의 제목으로
name변수를 생성합니다. - 텍스트 콘텐츠, 참조 링크, 기사 이름을 담은 LangChain Document를 생성합니다(
new Document({ pageContent, metadata })). - 각 문서를 전역
documents배열에 추가합니다.
마지막으로 전역 documents 배열에 저장된 모든 문서가 Upstash Vector 인덱스에 삽입됩니다. 이때 각 문서의 벡터 임베딩은 해당 문서의 pageContent 속성을 기반으로 자동 생성됩니다.
채팅(Chat) API 엔드포인트 만들기
이 섹션에서는 채팅 API 엔드포인트(app/routes/api_.chat.tsx)가 사용자 검색과 관련된 기사를 추천하는 검색 엔진 스타일의 응답을 생성하도록 구성하는 방법을 알아봅니다. 검색과 관련된 기사는 특정 벡터 인덱스에서 가장 가까운 top-K 벡터를 찾아 식별됩니다. 이후 해당 벡터들의 메타데이터에 있는 제목과 링크가 컨텍스트로 OpenAI API에 전달됩니다. 덕분에 챗봇은 사용자 검색에 응답하면서 관련 기사를 외부 링크로 함께 제공할 수 있습니다. 설명을 위해 몇 부분으로 나누어 살펴보겠습니다.
유사도 검색으로 관련 벡터 임베딩 찾기
매번 사용자 검색마다 기사 지식 저장소 전체를 다시 훑어보는 것은 비용이 큰 작업입니다. 사용자 검색과 (있다면) 매우 관련성이 높은 상위 3개 기사로 범위를 좁히려면, Upstash Vector 인덱스에 있는 기존 벡터들을 조회하세요. 그리고 사용자 검색의 벡터 임베딩과 최소 70% 이상의 유사도 점수를 가진 벡터만 필터링하여 남깁니다.
// File: app/routes/api_.chat.tsx
import vectorServer from '~/lib/upstash/vectorStore.server'
import type { ActionFunctionArgs } from '@remix-run/node'
export const action = async ({ request }: ActionFunctionArgs) => {
// Set of messages between user and chatbot
const { messages = [] } = await request.json()
// Get the latest question stored in the last message of the chat array
const searchQuery = messages[messages.length - 1].content
// Perform Similarity Search using the Upstash Vector Store
const queryResult = await vectorServer.similaritySearchWithScore(searchQuery, 3)
// Filter the records with confidence score > 70% and
// set the metadata as response to render search results
const results = queryResult.filter((i) => i[1] >= 0.7).map((i) => i[0].metadata)
// Proceed to create a response
}
챗봇을 위한 시스템 컨텍스트와 지침 만들기
이제 관련성 높은 벡터들을 확보했으니, 챗봇이 사용자 검색에 응답하기 전에 해당 기사들을 인지하고 참조하도록 만들어야 합니다. OpenAI의 gpt-3.5-turbo 모델로 이를 구현하려면 role 속성을 system으로 설정한 메시지 객체를 만들고, content 속성에 다음 지침들을 포함시킵니다.
- 챗봇은 구글처럼 응답해야 합니다.
- 응답은 반드시 마크다운(markdown) 형식이어야 합니다.
- 응답에는 기사로 연결되는 하이퍼링크가 포함되어야 합니다.
- 챗봇은 단순히 기사 참조를 넘어 더 유용한 정보를 제공해야 합니다.
// File: app/routes/api_.train.tsx
import { OpenAIStream, StreamingTextResponse } from 'ai'
import completionServer from '~/lib/openai/completion.server'
export const action = async ({ request }: ActionFunctionArgs) => {
// ...
// Now use OpenAI Text Completion with relevant articles as context
const completionResponse = await completionServer.chat.completions.create({
stream: true,
model: 'gpt-3.5-turbo',
messages: [
{
// create a system content message to be added as
// the open ai text completion will supply it as the context with the API
role: 'system',
content: `Behave like a Google. You have the knowledge of the following articles: ${JSON.stringify(results)}. Each response should be in 100% markdown compatible format and should have hyperlinks in it. Be precise. Do add some general text in the response related to the query.`,
},
// also, pass the whole conversation!
...messages,
],
})
// Convert the response into a friendly text-stream
const stream = OpenAIStream(completionResponse)
// Respond with the stream
return new StreamingTextResponse(stream)
}
위 코드를 통해 사용자 검색과 관련성이 높다고 판단된 기사를 추천하는, 컨텍스트를 인식하는 OpenAI 스트리밍 응답을 구현할 수 있습니다.
배울 내용이 정말 많았습니다! 이제 모든 준비가 끝났습니다 ✨
Fly.io에 배포하기
이 저장소에는 Fly.io 배포를 위한 설정이 미리 포함되어 있습니다. 구체적으로 다음 파일들입니다.
- Dockerfile
- fly.toml
- .dockerignore
Fly.io 계정이 있다면, 프로젝트 루트 디렉터리에서 터미널에 다음 명령어를 실행해 Fly.io에 앱을 생성할 수 있습니다.
# Create an app based on the baked-in configuration in your account
# This will result only in the change of app name in existing fly.toml
fly launch
그리고 다음 명령어를 실행하여 Fly.io에 배포합니다.
# Deploy the app based on the configuration created above
fly deploy
참고 자료
더 자세한 내용이 궁금하다면 이 가이드에서 활용한 아래 자료들을 살펴보세요.
- GitHub 저장소
- LangChain과 Upstash Vector Store 통합
- OpenAI Chat Completions API의 시스템 지침(System Instructions)
- LangChain에서 Cheerio로 웹페이지 데이터 불러오기
- React 앱에서 AI 채팅 UI 만들기
마무리
이 가이드에서는 벡터 임베딩과 OpenAI Completion API를 활용하고, 동적으로 생성된 시스템 컨텍스트를 결합하여 기사 추천 시스템을 구축하는 방법을 배웠습니다. Upstash Vector와 LangChain을 사용하면 벡터를 인덱스에 저장하고, top-K 벡터 검색 쿼리를 수행하며, 사용자 검색마다 관련 컨텍스트를 생성하는 모든 작업을 몇 줄의 코드로 처리할 수 있습니다.
질문이나 의견이 있다면 GitHub를 통해 언제든지 연락해 주세요.