Computer >> 컴퓨터 >  >> 프로그래밍 >> Redis

Mastra와 Upstash로 AI 논문 리서치 어시스턴트 구축하기

AI 리서치 에이전트란?

학술 연구의 흐름은 그야말로 빠릅니다. arXiv를 비롯한 프리프린트 서버에는 매일 수많은 새 논문이 등록되기 때문에, 이를 사람이 직접 따라가는 것은 거의 불가능에 가깝습니다. 이번 가이드에서는 다음과 같은 기능을 갖춘 AI 리서치 어시스턴트를 만들어 보겠습니다.

  1. 연구자의 자연어 질문을 이해한다.
  2. arXiv 초록으로 구성된 벡터 데이터베이스에서 가장 관련성 높은 논문을 찾아낸다.
  3. 논문의 핵심 인사이트를 요약하고, 해당 내용이 질문에 어떻게 답변하는지 설명한다.
  4. 더 깊이 읽을 수 있도록 PDF 원문 링크를 제공한다.

이를 위해 오픈소스 TypeScript 기반 AI 에이전트 프레임워크인 Mastra와, 서버리스 Redis 및 벡터 스토리지를 제공하는 Upstash를 활용합니다. 아래는 AI 연구 분야에 특화된 이 아티클 에이전트의 실제 데모 화면으로, Vercel에 배포되어 누구나 바로 사용해 볼 수 있습니다.

Mastra와 Upstash로 AI 논문 리서치 어시스턴트 구축하기

Mastra란 무엇인가?

Mastra는 프로덕션급 AI 에이전트를 손쉽게 만들 수 있도록 필요한 모든 기능을 갖춘 '배터리 포함(batteries-included)' 프레임워크입니다.

  • 에이전트 & 워크플로우 — 에이전트, 도구, 멀티스텝 워크플로우를 자유롭게 조합
  • RAG(검색 증강 생성) — 메모리와 벡터 스토어를 기본 내장
  • 멀티 LLM 지원 — OpenAI, Claude 등 다양한 모델과 호환

이번 프로젝트에서는 Upstash Redis를 메모리 저장소로 사용하는 에이전트를 만듭니다. 여기에 사전에 Upstash Vector 데이터베이스에 임베딩해 둔 연구 논문을 검색하는 도구도 함께 연결합니다. 더 자세한 내용은 Mastra 공식 문서를 참고하시기 바랍니다.

프로젝트 기술 스택

  • Mastra 프레임워크: AI 에이전트와 도구 생성
  • Upstash Redis: 에이전트에 대화 메모리 부여
  • Upstash Vector: 연구 논문 초록의 임베딩 저장
  • Next.js & Vercel: 웹 애플리케이션 개발 및 배포

또한 데모 애플리케이션의 과부하를 막기 위해 Upstash Ratelimit로 요청 횟수를 제한할 예정입니다.

구현 과정 살펴보기

이 애플리케이션은 크게 두 가지 핵심 컴포넌트로 구성됩니다. 바로 Mastra 서버와 웹 애플리케이션입니다. 하나의 프로젝트에 함께 두어도 되지만, 분리해 관리하는 것이 훨씬 깔끔합니다. 먼저 Mastra 서버부터 시작해 보겠습니다.

Mastra 프로젝트 생성

새 Mastra 프로젝트를 만들려면 터미널에서 다음 명령어를 실행합니다.

npm create mastra@latest

몇 가지 질문이 나오는데, 이 프로젝트에서는 기본 설정을 그대로 사용해도 충분합니다.

에이전트와 도구 만들기

에이전트 설정의 첫 단계는 이름, 목적, 사용할 도구를 정의하는 것입니다. 주어진 작업에 적합한 언어 모델을 선택하는 것도 중요합니다. 이 프로젝트에서는 하나의 에이전트와 하나의 도구를 사용합니다.

export const articleAgent = new Agent({
 name: "articleAgent",
 instructions: instruction,
 model: openai('gpt-4o'),
 tools: { articleQueryTool },
 memory: memory
});

에이전트 설정은 위처럼 간단합니다. 시스템 프롬프트 역할을 하는 instruction, 전용 tools, model, 그리고 중요한 또 하나의 요소인 memory까지 정의하면 됩니다.

에이전트의 메모리

Mastra는 에이전트에게 채팅 기록(chat history)과 의미 기반 회상(semantic recall) 기능을 모두 제공합니다. 스토리지에 메모리를 유지하면 에이전트가 더 개인화되고 정확한 답변을 줄 수 있습니다. 이번 프로젝트의 메모리 설정을 살펴보겠습니다.

export const memory = new Memory({
 storage: myUpstashStore,
 options: {
 lastMessages: 10,
 semanticRecall: false,
 threads: {
 generateTitle: true
 }
 }
});

채팅 기록을 활성화하기 위해 Upstash Redis를 스토리지로 사용합니다. MastraStorage를 확장한 UpstashStore 객체로 초기화하면 Mastra 에이전트와 매끄럽게 연동됩니다.

export const myUpstashStore = new UpstashStore({
 url: process.env.UPSTASH_REDIS_REST_URL!,
 token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

앞서 에이전트에 시맨틱 리콜 기능을 추가한다고 언급했습니다. 이 기능은 현재 대화 맥락과 관련된 과거 메시지를 참조할 수 있게 해줍니다. 이를 위해서는 에이전트에 벡터 데이터베이스와 메시지를 처리할 임베더(embedder)가 필요합니다. 다만 우리의 공개 데모는 개인용이 아니며 스레드 간 메시지를 기억할 필요가 없기 때문에 이 기능은 사용하지 않지만, 필요하다면 아래와 같이 구현할 수 있습니다.

export const myUpstashVector = new UpstashVector({
 url: process.env.UPSTASH_VECTOR_REST_URL!,
 token: process.env.UPSTASH_VECTOR_REST_TOKEN!,
});
 
export const memory = new Memory({
 storage: myUpstashStore,
 vector: myUpstashVector,
 embedder: openai.embedding("text-embedding-3-small"),
 options: {
 lastMessages: 10,
 semanticRecall: {
 topK: 3,
 messageRange: 2,
 scope: 'resource'
 },
 threads: {
 generateTitle: true
 }
 }
});

시맨틱 리콜 설정에서 topK는 검색할 유사 메시지의 개수를, messageRange는 각 매칭 결과에 포함할 주변 맥락의 범위를 의미합니다. scope를 'resource'로 지정하면 해당 사용자('resource')와 연결된 모든 스레드를 대상으로 검색하게 됩니다. 이러한 교차 스레드 메모리는 Upstash가 제공하는 강력한 기능입니다.

도구(Tools)

도구 생성 역시 에이전트 생성만큼 간단합니다. 이름, 설명, 입출력 스키마, 그리고 에이전트가 도구의 기능을 필요로 할 때 실행될 함수를 정의하면 됩니다.

export const articleQueryTool = createTool({
 id: 'get-relevant-article',
 description: 'Get relevant article information',
 inputSchema: z.object({
 question: z.string().describe('the question about the field'),
 }),
 outputSchema: z.object({
 bestOption: z.object({
 abstract: z.string().describe('the abstract of the article'),
 title: z.string().describe('the title of the article'),
 pdfUrl: z.string().describe('the PDF URL of the article')
 })
 }),
 execute: async ({ context }) => {
 return await querySimilar(context.question);
 },
 });

입력과 출력 스키마 검증에는 Zod를 사용합니다. 이를 통해 응답 형식의 일관성을 유지하고 LLM으로 인한 잠재적 오류를 최소화할 수 있습니다. 우리의 도구는 arXiv API를 통해 주기적으로 갱신되고 Upstash Vector 데이터베이스에 임베딩된 방대한 논문 컬렉션을 조회합니다.

const querySimilar = async (query: string) => {
 
 const { embedding } = await embed({
 value: query,
 model: openai.embedding("text-embedding-3-small"),
 });
 
 const results = await myMastraUpstashVector.query({
 indexName: "arxiv",
 queryVector: embedding,
 topK: 3,
 });
 
 if (results && results.length > 0) {
 const bestMatch = results[0];
 const metadata = bestMatch.metadata as ArxivPaper;
 
 return {
 bestOption: {
 abstract: metadata.abstract,
 title: metadata.title,
 pdfUrl: metadata.pdfUrl
 }
 };
 }
 throw new Error("No relevant information found");
}

MastraVector를 확장한 UpstashVector 인스턴스를 통해 벡터 데이터베이스에 간단한 연산을 수행할 수 있습니다. 위 코드는 사전에 임베딩해 둔 논문 초록 중 질의와 유사한 항목을 검색해 최적의 결과를 도구에 반환합니다. 주의할 점은 질의에도 논문 임베딩 때와 동일한 임베딩 모델을 사용해야 한다는 것입니다. 논문 임베딩 방법은 뒤에서 더 자세히 설명하겠습니다.

Mastra 인스턴스

export const mastra = new Mastra({
 storage: myMastraUpstashStore,
 agents: { articleAgent },
 deployer: new VercelDeployer()
});

사용할 에이전트를 지정하기만 하면 Mastra 객체가 완성됩니다. 인메모리 저장소를 넘어 데이터를 영속화하기 위한 스토리지도 함께 제공합니다. 배포 환경 역시 여러 옵션 중 선택할 수 있으며, 여기서는 Vercel을 사용합니다.

create-mastra-app의 기본 옵션만으로도 필요한 파일 구조는 이미 갖춰져 있습니다.

.
└── mastra
 ├── agents
 │ └── index.ts
 ├── tools
 │ └── index.ts
 └── index.ts
 

배포 전 마지막 단계는 환경 변수 설정입니다.

OPENAI_API_KEY=
 
UPSTASH_VECTOR_REST_URL=
UPSTASH_VECTOR_REST_TOKEN=
 
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=

로컬 개발 시에는 .env.local 파일에 작성하고, 배포 환경에는 해당 변수들을 등록하면 됩니다.

이제 Mastra 서버를 빌드하고 배포할 준비가 되었습니다.

npm run build && vercel --prod

배포 방법은 Vercel 문서를 참고하세요.

개발 중에는 Mastra Playground를 활용하면 서버 출력을 손쉽게 확인할 수 있습니다. 다음 명령어를 실행해 보세요.

npm run dev

웹 인터페이스 링크가 제공되며, 여기서 에이전트와 대화하거나 도구를 직접 실행하며 서버의 기능을 탐색할 수 있습니다.

이제 애플리케이션의 나머지 부분을 살펴볼 차례입니다.

Next.js 서버

Mastra 서버 구축이 끝나면 남은 작업은 세 가지입니다. UI 구현, Mastra 서버와의 통신, 그리고 arXiv API와 통신해 초록을 Upstash Vector에 임베딩하는 아티클 서비스입니다. Mastra는 서버의 기능을 외부에 노출하는 클라이언트 SDK를 제공하며, 이를 통해 에이전트, 도구, 메모리 등에 접근할 수 있습니다. 사용법은 매우 직관적이지만 몇 가지 예시를 소개하겠습니다. 자세한 내용은 공식 문서를 확인하시기 바랍니다. Next.js 프로젝트에서는 클라이언트 SDK를 간단히 설치해 사용할 수 있습니다.

npm install @mastra/client-js@latest

코드에서 MastraClient 인스턴스를 생성해 프로젝트에서 사용하면 됩니다.

import { MastraClient } from "@mastra/client-js";
 
export const mastra_sdk = new MastraClient({
 baseUrl: process.env.NEXT_PUBLIC_MASTRA_API!,
 retries: 3,
 });

NEXT_PUBLIC_MASTRA_API에는 Mastra 서버의 주소를 설정해야 합니다. 로컬에서 개발할 경우 localhost 주소가 될 텐데, 3000번 포트에서 충돌이 발생할 가능성이 높으므로 로컬 실행 시 Mastra 서버 설정을 다음과 같이 변경할 수 있습니다.

export const mastra = new Mastra({
 storage: myMastraUpstashStore,
 agents: { articleAgent },
 server: {
 port: 4111, 
 timeout: 10000, 
 }
});

이제 npm run dev로 Mastra 서버를 로컬에서 실행하면 4111번 포트에서 서비스됩니다. Next.js 프로젝트를 로컬에서 실행할 때는 NEXT_PUBLIC_MASTRA_APIhttps://localhost:4111로 설정하면 됩니다.

그럼 Mastra 클라이언트 SDK의 활용 예시를 보겠습니다.

export const MASTRA_CONFIG = {
 resourceId: process.env.NEXT_PUBLIC_RESOURCE_ID || "articleAgent",
 agentId: "articleAgent", 
 baseUrl: process.env.NEXT_PUBLIC_MASTRA_API || "https://localhost:4111",
 retries: 3,
}; // 코드베이스 어디에서든 사용할 수 있도록 별도 파일로 export합니다.
 
// 에이전트를 가져온 뒤 에이전트 객체를 통해 응답을 스트리밍합니다.
const agent = mastra_sdk.getAgent(MASTRA_CONFIG.agentId);
 
const response = await agent.stream({
 messages: [message],
 resourceId: MASTRA_CONFIG.resourceId,
 threadId: threadId
});

도구와 에이전트를 가져온 후에는 클라이언트 SDK를 통해 실제 객체로 할 수 있는 거의 모든 작업을 수행할 수 있습니다.

이 데모 프로젝트는 공개될 예정이므로 에이전트에 과도한 부하가 걸리지 않도록 하는 것이 중요합니다. 바로 이 지점에서 Upstash Ratelimit가 빛을 발합니다. 모든 스트림 요청 전에 사용자가 요청 한도를 초과했는지 확인하는 것입니다. 레이트 리밋 설정에는 Upstash Redis가 필요한데, Mastra 에이전트용으로 사용 중인 동일한 Redis 데이터베이스를 재활용할 수 있습니다.

import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
 
// 프로젝트 전반에서 동일한 Redis DB를 사용합니다.
export const rateLimit = new Ratelimit({
 redis: new Redis({
 url: process.env.UPSTASH_REDIS_MEMORY_URL!,
 token: process.env.UPSTASH_REDIS_MEMORY_TOKEN!
 }),
 limiter: Ratelimit.slidingWindow(10, '10s'),
 prefix: 'upstash-ratelimit',
});
 
// 모든 스트림 요청 전에 아래 함수를 호출합니다.
export async function isRateLimited(id: string): Promise<boolean> {
 const { success } = await rateLimit.limit(id);
 return !success; 
}

이렇게 하면 엔드포인트가 과부하에 노출되지 않도록 보장할 수 있습니다.

Mastra로 채팅 에이전트를 만들 때 스레드 생성 관련 기능 몇 가지를 알아두면 유용합니다. 앞서 에이전트 메모리를 설정할 때 threads 객체 안의 generateTitletrue로 지정했습니다. 이렇게 하면 Mastra가 새로 생성된 스레드에 자동으로 제목을 붙여줍니다. 그런데 여기에 함정이 있습니다. 스레드를 명시적으로 생성할 수도 있지만, 그 경우 자동 제목 생성이 트리거되지 않습니다. 일반적으로 새 스레드는 다음과 같이 생성합니다.

const thread = await mastraClient.createMemoryThread({
 title: "New Conversation",
 metadata: { category: "support" },
 resourceId: "resource-1",
 agentId: "agent-1",
});

하지만 이렇게 하면 제목을 직접 지정하는 것이므로 에이전트의 자동 제목 생성 기능이 무의미해집니다. title 필드를 비워두는 것 역시 통하지 않습니다. 이럴 때는 Playground의 동작 방식을 살펴보면 답이 나옵니다. 개발 중 서버의 기능을 체험할 수 있게 Mastra가 제공하는 그 Playground 말입니다. 브라우저 개발자 도구의 네트워크 탭을 열어보면, 새 스레드 생성 시점에 실제로는 API 요청을 보내지 않는다는 것을 알 수 있습니다. 대신 사용자가 첫 번째 메시지를 제출하기를 기다린 후, 새로 생성된 스레드 ID와 함께 스트림 요청을 보냅니다. Mastra는 해당 ID의 스레드가 존재하지 않음을 인지하고 새 스레드를 생성하며, generateTitle이 true라면 첫 번째 메시지를 기반으로 제목까지 자동 생성합니다.

이제 프로젝트의 마지막 컴포넌트인 arXiv 논문 파트로 넘어가겠습니다.

arXiv 논문 수집

arXiv는 다양한 분야의 약 240만 편에 달하는 연구 논문을 담은 오픈 액세스 아카이브입니다. articleQueryTool이 조회하는 Upstash Vector 데이터베이스는 arXiv API를 통해 수집된 논문으로 채워집니다. API 사용법은 간단하며, 자세한 내용은 공식 문서에서 확인할 수 있습니다.

이 프로젝트에서는 매일 논문을 수집해 저장합니다. 서버가 처음 실행될 때 지정된 카테고리에서 약 30,000편의 논문을 가져오고, 이후에는 전날 발표된 신규 논문만 수집합니다. 논문 카테고리와 초기 대량 수집 여부는 환경 변수로 지정합니다. 카테고리는 arXiv의 분류 체계(taxonomy)에 따라 쉼표로 구분해 입력하면 됩니다. 카테고리 목록은 공식 페이지에서 확인할 수 있습니다.

CATEGORIES=cs.AI
RUN_BEGINNING_STACK=false

더 포괄적인 데이터베이스를 원한다면 arXiv의 벌크 데이터 접근(bulk data access)을 이용할 수 있습니다. 이를 사용하지 않으면 API 질의당 30,000편으로 제한되지만, 우리 목적에는 충분한 규모입니다.

arXiv에 대한 기본적인 질의는 다음과 같습니다.

const categories = process.env.CATEGORIES?.split(',') || []; // 원하는 카테고리를 가져와 질의용으로 분할합니다.
const searchQuery = categories.length === 1 ? `cat:${categories[0]}` : `(${categories.map(c => `cat:${c}`).join(" OR ")})`;
const query = `search_query=${searchQuery}&sortBy=submittedDate&sortOrder=descending`;
const url = `https://export.arxiv.org/api/query?${query}`;
const response = await axios.get(url); // 구성된 URL로 API를 호출합니다.

매일 최신 논문을 가져오거나 초기 대량 데이터를 수집할 때도 유사한 호출을 사용합니다.

논문을 수집한 후에는 정규화(normalize)하여 Upstash Vector에 저장할 수 있도록 임베딩합니다. 이때 반드시 Mastra 도구가 사용하는 것과 동일한 벡터 데이터베이스여야 합니다. 여기서 '정규화'란 수집한 논문을 코드베이스 전반에서 사용할 표준 타입인 ArxivPaper 형태로 파싱하는 것을 의미합니다.

export interface ArxivPaper {
 id: string;
 title: string;
 abstract: string;
 authors: string[];
 published: string;
 pdfUrl: string;
 category: string;
}// 코드베이스 전체에서 사용하는 논문 타입입니다.
 
async function storeAbstracts(papers: ArxivPaper[]) {
 const embeddingModel = openai.embedding("text-embedding-3-small"); // Mastra 쪽 질의에서 사용한 것과 동일한 모델입니다.
 
 const embeddings = await embedArticles(papers, embeddingModel)
 
 // 임베딩을 메타데이터와 함께 필요한 형태로 변환합니다.
 const vectorsToUpsert = getVectorsToUpsert(embeddings, papers) 
 
 for (let j = 0; j < vectorsToUpsert.length; j++) { 
 await vectorStore.upsert(vectorsToUpsert[j], { namespace: "arxiv" }); // 임베딩과 메타데이터를 Upstash Vector에 업서트합니다.
 }
}

데이터베이스를 최신 연구 동향에 맞게 유지하기 위해, 예약된 작업 실행을 위한 Upstash QStash를 도입했습니다. Vercel에 배포된 환경에서는 긴 처리 시간으로 인한 함수 타임아웃이 발생할 수 있으므로, 서버에 공개 API 엔드포인트를 노출해 QStash 인스턴스가 매일 데이터베이스 갱신 함수를 안정적으로 트리거하도록 했습니다.

// src/app/api/arxiv_reneval/route.ts
import { verifySignatureAppRouter } from "@upstash/qstash/nextjs"
import { fetchAndUpsertYesterday} from "@/services/arxiv"
 
async function handler(request: Request) {
 console.log("Fetching and upserting yesterday's papers...")
 await fetchAndUpsertYesterday()
 console.log("Fetching and upserting yesterday's papers completed")
 return Response.json({ success: true })
}
 
export const POST = verifySignatureAppRouter(handler)

Upstash 콘솔에서 스케줄러를 설정하면 매일 UTC 기준 오전 6시에 이 엔드포인트로 요청을 자동 발송할 수 있습니다.

Mastra와 Upstash로 AI 논문 리서치 어시스턴트 구축하기

이 스케줄러 설정 덕분에 서버는 매일 아침 데이터베이스를 자동으로 갱신하며, 끊임없이 신선한 데이터를 유지합니다.

QStash 인스턴스의 인증 정보도 함께 제공해야 하며, 필요한 모든 환경 변수는 예시 env 파일에 정리되어 있습니다.

대략적인 구현은 여기까지입니다. 원하신다면 직접 코드를 수정해 보며 실험해 보세요. 저장소를 포크한 뒤 개발을 시작하면 됩니다. Mastra 파트 저장소와 나머지 파트 저장소에 각각 방문해 포크한 후 다음 순서로 진행하세요.

  • 두 저장소를 로컬 머신에 클론합니다.
  • 환경 변수를 채워 넣습니다(예시 .env 파일이 제공됩니다).
  • 별도의 터미널에서 각 프로젝트의 루트 디렉터리로 이동합니다.
  • 다음 명령어를 실행합니다.
npm install
npm run dev

이제 https://localhost:3000에서 애플리케이션을 확인할 수 있습니다.

Mastra를 활용하면 RAG, 워크플로우, 네트워크 같은 다른 템플릿을 통해 훨씬 복잡한 시스템도 구축할 수 있습니다. 흥미롭게도 이 모든 용도에서 메모리와 스토리지가 핵심적인 역할을 하는데, 바로 이 지점에서 Upstash가 진가를 발휘합니다.