├── .env.example
├── .gitignore
├── LICENSE
├── README.md
├── biome.json
├── components.json
├── next.config.ts
├── package.json
├── pnpm-lock.yaml
├── postcss.config.mjs
├── prisma
├── migrations
│ ├── 0_init
│ │ └── migration.sql
│ ├── 20241118143807_add_oai_table
│ │ └── migration.sql
│ └── 20241118144227_add_dummy_field_to_oai
│ │ └── migration.sql
└── schema.prisma
├── public
├── file.svg
├── globe.svg
├── next.svg
├── nextrag.png
├── tech
│ ├── next.svg
│ ├── next.webp
│ ├── postgres.png
│ └── prisma.svg
├── vercel.svg
└── window.svg
├── src
├── app
│ ├── (chat)
│ │ ├── chat
│ │ │ └── page.tsx
│ │ └── layout.tsx
│ ├── (docs)
│ │ ├── docs
│ │ │ └── page.tsx
│ │ └── layout.tsx
│ ├── api
│ │ ├── chat
│ │ │ └── route.ts
│ │ ├── healthcheck
│ │ │ └── route.ts
│ │ ├── inngest
│ │ │ ├── events
│ │ │ │ └── route.ts
│ │ │ └── route.ts
│ │ └── search
│ │ │ └── route.ts
│ ├── favicon.ico
│ ├── fonts
│ │ ├── GeistMonoVF.woff
│ │ └── GeistVF.woff
│ ├── globals.css
│ ├── layout.tsx
│ ├── opengraph-image.png
│ ├── page.tsx
│ └── twitter-image.png
├── components
│ ├── navbar.tsx
│ ├── tabs
│ │ ├── chat-tab.tsx
│ │ ├── ingest-tab.tsx
│ │ └── search-tab.tsx
│ ├── tech-icons.tsx
│ ├── theme-provider.tsx
│ ├── theme-toggle.tsx
│ └── ui
│ │ ├── alert.tsx
│ │ ├── badge.tsx
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── code-block.tsx
│ │ ├── dot-pattern.tsx
│ │ ├── dropdown-menu.tsx
│ │ ├── input.tsx
│ │ ├── scroll-area.tsx
│ │ ├── sonner.tsx
│ │ ├── tabs.tsx
│ │ └── textarea.tsx
├── inngest
│ ├── client.ts
│ └── functions
│ │ ├── embedding.ts
│ │ └── retrieval.ts
└── lib
│ ├── actions
│ └── search.ts
│ ├── db
│ ├── client.ts
│ ├── config.ts
│ ├── pg.ts
│ └── vector.ts
│ ├── env.mjs
│ └── utils.ts
├── tailwind.config.ts
└── tsconfig.json
/.env.example:
--------------------------------------------------------------------------------
1 | # For the LLM of your choice, e.g. OpenAI, based on the Vercel AI SDK
2 | OPENAI_API_KEY=...
3 |
4 | # For Prisma
5 | POSTGRES_URL=...
6 |
7 | # For node-pg
8 | PGUSER=...
9 | PGPASSWORD=...
10 | PGHOST=...
11 | PGPORT=...
12 | PGDATABASE=...
13 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.*
7 | .yarn/*
8 | !.yarn/patches
9 | !.yarn/plugins
10 | !.yarn/releases
11 | !.yarn/versions
12 |
13 | # testing
14 | /coverage
15 |
16 | # next.js
17 | /.next/
18 | /out/
19 |
20 | # production
21 | /build
22 |
23 | # misc
24 | .DS_Store
25 | *.pem
26 |
27 | # debug
28 | npm-debug.log*
29 | yarn-debug.log*
30 | yarn-error.log*
31 |
32 | # env files (can opt-in for committing if needed)
33 | !env.example
34 | .env
35 | .env.local
36 |
37 | # vercel
38 | .vercel
39 |
40 | # typescript
41 | *.tsbuildinfo
42 | next-env.d.ts
43 |
44 | # postgres
45 | postgres_data
46 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Hamed Mohammadpour
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # NextRag: Next.js RAG with PGVector
2 |
3 | 
4 | A production-ready implementation of Retrieval Augmented Generation (RAG) using Next.js, PostgreSQL + pgvector (with `node-pg`), Prisma, and Vercel AI SDK.
5 |
6 | ## Introduction
7 |
8 | This project demonstrates how to implement RAG (Retrieval Augmented Generation) using PostgreSQL's vector similarity search capabilities. It's designed as a reference implementation that you can adapt for your specific use case.
9 |
10 | ### Key Concepts
11 |
12 | 1. **Vector Embeddings**: Text is converted into high-dimensional vectors that capture semantic meaning using OpenAI's embedding models.
13 |
14 | 2. **Similarity Search**: pgvector enables efficient similarity search between these vectors, helping find relevant content.
15 |
16 | 3. **Chunking Strategies**: Documents are broken down into manageable pieces using different strategies (sentence, paragraph, or fixed-size) to optimize retrieval.
17 |
18 | 4. **Metadata Tracking**: Each chunk maintains metadata about its source, creation time, and relationship to other chunks.
19 |
20 | 5. **Background Processing**: Long-running tasks like document ingestion are handled asynchronously using Inngest.
21 |
22 | ### Sample Implementation
23 |
24 | This codebase provides:
25 |
26 | - A flexible `VectorDB` class for vector operations
27 | - Multiple chunking strategies
28 | - Configurable similarity metrics
29 | - Type-safe database operations
30 | - RAG-powered chat interface
31 |
32 | ## Features
33 |
34 | - 🔍 Semantic search with pgvector
35 | - 🤖 RAG-powered chat interface
36 | - 📝 Multiple text chunking strategies
37 | - 🔄 Background processing with Inngest
38 | - 🎯 Flexible similarity metrics
39 | - 📊 Rich metadata support
40 | - 🔒 Type-safe database operations
41 |
42 | ## Tech Stack
43 |
44 | - **Next.js 15** - React framework
45 | - **PostgreSQL + pgvector** - Vector similarity search
46 | - **Vercel AI SDK** - AI/LLM utilities
47 | - **Prisma** - Type-safe database schema
48 | - **node-pg** - SQL query sanitization
49 | - **Inngest** - Background job processing
50 | - **OpenAI** - Embeddings and chat completion
51 | - **Tailwind CSS** - Styling
52 | - **TypeScript** - Type safety
53 |
54 | ## Quick Start
55 |
56 | ## 1. **Clone and Install**
57 |
58 | ```bash
59 | git clone https://github.com/hamedmp/nextrag
60 | cd nextrag
61 | pnpm install
62 | ```
63 |
64 | ## 2. **Environment Setup**
65 |
66 | You need environment variables for the LLM of your choice and the Database
67 |
68 | ```bash
69 | cp .env.example .env
70 | ```
71 |
72 | Required environment variables:
73 |
74 | ```bash
75 | # Database (Vercel Postgres or Neon)
76 | POSTGRES_URL="postgres://..."
77 |
78 | # OpenAI
79 | OPENAI_API_KEY="sk-..."
80 |
81 | # node-pg
82 | PGUSER=...
83 | PGPASSWORD=...
84 | PGHOST=...
85 | PGPORT=...
86 | PGDATABASE=...
87 |
88 | # Inngest (optional, for background jobs)
89 | INNGEST_EVENT_KEY="..."
90 | INNGEST_SIGNING_KEY="..."
91 | ```
92 |
93 | ## 3. **Database Setup**
94 |
95 | ### Option 1: Enable pgvector extension manually
96 |
97 | Enable pgvector extension in your PostgreSQL database:
98 |
99 | ```sql
100 | CREATE EXTENSION IF NOT EXISTS vector;
101 | ```
102 |
103 | ### Option 2: Enable pgvector extension with Prisma migrations
104 |
105 | Alternatively, you can do it with the generated Prisma migrations:
106 |
107 | Prisma doesn't natively support pgvector's vector type, but we can use the `Unsupported` scalar:
108 |
109 | ```prisma
110 | generator client {
111 | provider = "prisma-client-js"
112 | }
113 |
114 | datasource db {
115 | provider = "postgresql"
116 | url = env("POSTGRES_URL")
117 | }
118 |
119 | model documents {
120 | id BigInt @id @default(autoincrement())
121 | content String?
122 | embedding Unsupported("vector")?
123 | metadata Json? @default("{}")
124 | createdAt DateTime @default(now())
125 | updatedAt DateTime @updatedAt
126 | }
127 | ```
128 |
129 | #### Migrations
130 |
131 | 1. Create a migration:
132 |
133 | ```bash
134 | pnpm prisma migrate dev --name add_vector_support
135 | ```
136 |
137 | 2. In the generated migration file, add pgvector setup:
138 |
139 | ```sql
140 | -- Enable pgvector extension
141 | CREATE EXTENSION IF NOT EXISTS vector;
142 |
143 | -- CreateTable
144 | CREATE TABLE "documents" (
145 | "id" BIGSERIAL NOT NULL,
146 | "content" TEXT,
147 | "embedding" vector(1536),
148 | "metadata" JSONB DEFAULT '{}',
149 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
150 | "updatedAt" TIMESTAMP(3) NOT NULL,
151 | CONSTRAINT "documents_pkey" PRIMARY KEY ("id")
152 | );
153 |
154 | -- Create HNSW index for faster similarity search
155 | CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops);
156 | ```
157 |
158 | 3. Apply the migration:
159 |
160 | ```bash
161 | pnpm prisma migrate deploy
162 | ```
163 |
164 | ## 4. **Run Development Server and Inngest server**
165 |
166 | In a separate terminal, run the Inngest server:
167 |
168 | ```bash
169 | pnpm run inngest
170 | ```
171 |
172 | The server will start on `http://127.0.0.1:8288`.
173 |
174 | In another separate terminal, run the Next.js development server:
175 |
176 | ```bash
177 | pnpm dev
178 | ```
179 |
180 | Visit `http://localhost:3000` to see the app.
181 |
182 | ## VectorDB Usage
183 |
184 | The `VectorDB` class provides a flexible interface for vector operations:
185 |
186 | ```typescript
187 | // Initialize with table configuration
188 | const vectorDB = new VectorDB(
189 | {
190 | tableName: 'documents',
191 | columns: {
192 | id: 'id',
193 | vector: 'embedding',
194 | content: 'text',
195 | metadata: 'metadata',
196 | createdAt: 'createdAt',
197 | },
198 | },
199 | {
200 | // Optional custom configuration
201 | embedding: {
202 | model: 'text-embedding-3-small',
203 | dimensions: 1536,
204 | distance: 'cosine',
205 | },
206 | chunking: {
207 | method: 'paragraph',
208 | fixedSize: 500,
209 | },
210 | search: {
211 | defaultLimit: 5,
212 | },
213 | }
214 | );
215 |
216 | // Add text with chunking and metadata
217 | await vectorDB.addText('Your content here', {
218 | chunkingMethod: 'paragraph',
219 | metadata: {
220 | source: 'documentation',
221 | category: 'setup',
222 | },
223 | });
224 |
225 | // Search with filters and custom options
226 | const results = await vectorDB.searchSimilar('your query', {
227 | limit: 10,
228 | distance: 'cosine',
229 | filter: { category: 'setup' },
230 | select: ['content', 'metadata->category as category'],
231 | });
232 | ```
233 |
234 | ## Chunking Methods
235 |
236 | Three text chunking strategies are available:
237 |
238 | ```typescript
239 | // 1. Sentence-based chunking
240 | const chunks = vectorDB.chunkText(text, 'sentence');
241 |
242 | // 2. Paragraph-based chunking (split by double newline)
243 | const chunks = vectorDB.chunkText(text, 'paragraph');
244 |
245 | // 3. Fixed-size chunking (with word boundaries)
246 | const chunks = vectorDB.chunkText(text, 'fixed');
247 | ```
248 |
249 | ## Similarity Metrics
250 |
251 | PGVector supports multiple similarity metrics:
252 |
253 | ```typescript
254 | // Cosine similarity (normalized, recommended for OpenAI embeddings)
255 | await vectorDB.searchSimilar(query, { distance: 'cosine' });
256 |
257 | // Euclidean distance
258 | await vectorDB.searchSimilar(query, { distance: 'euclidean' });
259 |
260 | // Inner product
261 | await vectorDB.searchSimilar(query, { distance: 'inner_product' });
262 | ```
263 |
264 | ## Project Structure
265 |
266 | ```
267 | src/
268 | ├── app/ # Next.js App Router pages
269 | │ ├── (chat)/ # Playground
270 | │ ├── api/ # API routes
271 | │ └── docs/ # Documentation pages
272 | ├── components/ # UI components
273 | ├── lib/
274 | │ ├── db/ # Database utilities
275 | │ │ ├── vector.ts # VectorDB class
276 | │ │ └── config.ts # Configuration
277 | │ └── actions/ # Server actions
278 | └── inngest/ # Background jobs
279 | ```
280 |
281 | ## Development
282 |
283 | ### Prerequisites
284 |
285 | - Node.js 18+
286 | - PostgreSQL 15+ with pgvector extension
287 | - OpenAI API key
288 | - Vercel account (for deployment)
289 |
290 | ### Database Indexes
291 |
292 | For better search performance, create appropriate indexes:
293 |
294 | ```sql
295 | -- For cosine similarity (recommended)
296 | CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops);
297 |
298 | -- For L2 distance
299 | CREATE INDEX ON documents USING hnsw (embedding vector_l2_ops);
300 | ```
301 |
302 | ## Contributing
303 |
304 | 1. Fork the repository
305 | 2. Create your feature branch (`git checkout -b feature/amazing-feature`)
306 | 3. Commit your changes (`git commit -m 'Add amazing feature'`)
307 | 4. Push to the branch (`git push origin feature/amazing-feature`)
308 | 5. Open a Pull Request
309 |
310 | ## License
311 |
312 | MIT License - see [LICENSE](LICENSE) file for details.
313 |
314 | ## Acknowledgments
315 |
316 | - [Vercel AI SDK](https://sdk.vercel.ai/docs)
317 | - [pgvector](https://github.com/pgvector/pgvector)
318 | - [Inngest](https://www.inngest.com/)
319 | - [Prisma](https://www.prisma.io/)
320 |
321 | ---
322 |
323 | Built with ❤️ using Next.js and pgvector
324 |
--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
3 | "vcs": { "enabled": false, "clientKind": "git", "useIgnoreFile": false },
4 | "files": { "ignoreUnknown": false, "ignore": [] },
5 | "formatter": { "enabled": true, "indentStyle": "tab" },
6 | "organizeImports": { "enabled": true },
7 | "linter": {
8 | "enabled": true,
9 | "rules": {
10 | "recommended": false,
11 | "complexity": {
12 | "noUselessThisAlias": "error",
13 | "noUselessTypeConstraint": "error"
14 | },
15 | "correctness": {
16 | "noUnusedVariables": "error",
17 | "useArrayLiterals": "off"
18 | },
19 | "style": { "noNamespace": "error", "useAsConstAssertion": "error" },
20 | "suspicious": {
21 | "noExplicitAny": "error",
22 | "noExtraNonNullAssertion": "error",
23 | "noMisleadingInstantiator": "error",
24 | "noUnsafeDeclarationMerging": "error",
25 | "useNamespaceKeyword": "error"
26 | }
27 | }
28 | },
29 | "javascript": { "formatter": { "quoteStyle": "double" } },
30 | "overrides": [
31 | {
32 | "include": ["*.ts", "*.tsx", "*.mts", "*.cts"],
33 | "linter": {
34 | "rules": {
35 | "correctness": {
36 | "noConstAssign": "off",
37 | "noGlobalObjectCalls": "off",
38 | "noInvalidBuiltinInstantiation": "off",
39 | "noInvalidConstructorSuper": "off",
40 | "noNewSymbol": "off",
41 | "noSetterReturn": "off",
42 | "noUndeclaredVariables": "off",
43 | "noUnreachable": "off",
44 | "noUnreachableSuper": "off"
45 | },
46 | "style": {
47 | "noArguments": "error",
48 | "noVar": "error",
49 | "useConst": "error"
50 | },
51 | "suspicious": {
52 | "noClassAssign": "off",
53 | "noDuplicateClassMembers": "off",
54 | "noDuplicateObjectKeys": "off",
55 | "noDuplicateParameters": "off",
56 | "noFunctionAssign": "off",
57 | "noImportAssign": "off",
58 | "noRedeclare": "off",
59 | "noUnsafeNegation": "off",
60 | "useGetterReturn": "off"
61 | }
62 | }
63 | }
64 | },
65 | {
66 | "include": ["*.ts", "*.tsx", "*.mts", "*.cts"],
67 | "linter": {
68 | "rules": {
69 | "correctness": {
70 | "noConstAssign": "off",
71 | "noGlobalObjectCalls": "off",
72 | "noInvalidBuiltinInstantiation": "off",
73 | "noInvalidConstructorSuper": "off",
74 | "noNewSymbol": "off",
75 | "noSetterReturn": "off",
76 | "noUndeclaredVariables": "off",
77 | "noUnreachable": "off",
78 | "noUnreachableSuper": "off"
79 | },
80 | "style": {
81 | "noArguments": "error",
82 | "noVar": "error",
83 | "useConst": "error"
84 | },
85 | "suspicious": {
86 | "noClassAssign": "off",
87 | "noDuplicateClassMembers": "off",
88 | "noDuplicateObjectKeys": "off",
89 | "noDuplicateParameters": "off",
90 | "noFunctionAssign": "off",
91 | "noImportAssign": "off",
92 | "noRedeclare": "off",
93 | "noUnsafeNegation": "off",
94 | "useGetterReturn": "off"
95 | }
96 | }
97 | }
98 | }
99 | ]
100 | }
101 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "src/app/globals.css",
9 | "baseColor": "neutral",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/components/ui",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | },
20 | "iconLibrary": "lucide"
21 | }
22 |
--------------------------------------------------------------------------------
/next.config.ts:
--------------------------------------------------------------------------------
1 | import type { NextConfig } from 'next';
2 |
3 | const nextConfig: NextConfig = {
4 | typescript: {
5 | ignoreBuildErrors: true,
6 | },
7 | };
8 |
9 | export default nextConfig;
10 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nextjs-rag-postgres",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev --turbopack",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "inngest": "pnpm dlx inngest-cli@latest dev --no-discovery -u localhost:3000/api/inngest"
11 | },
12 | "dependencies": {
13 | "@ai-sdk/openai": "^0.0.72",
14 | "@prisma/client": "5.22.0",
15 | "@radix-ui/react-dropdown-menu": "^2.1.2",
16 | "@radix-ui/react-scroll-area": "^1.2.1",
17 | "@radix-ui/react-slot": "^1.1.0",
18 | "@radix-ui/react-tabs": "^1.1.1",
19 | "@t3-oss/env-nextjs": "^0.11.1",
20 | "@tailwindcss/typography": "^0.5.15",
21 | "ai": "^3.4.33",
22 | "class-variance-authority": "^0.7.0",
23 | "clsx": "^2.1.1",
24 | "framer-motion": "^11.11.17",
25 | "geist": "^1.3.1",
26 | "inngest": "^3.25.1",
27 | "lucide-react": "^0.460.0",
28 | "next": "15.0.3",
29 | "next-themes": "^0.4.3",
30 | "pg": "^8.13.1",
31 | "react": "19.0.0-rc-66855b96-20241106",
32 | "react-dom": "19.0.0-rc-66855b96-20241106",
33 | "react-markdown": "^9.0.1",
34 | "react-syntax-highlighter": "^15.6.1",
35 | "sonner": "^1.7.0",
36 | "tailwind-merge": "^2.5.4",
37 | "tailwindcss-animate": "^1.0.7",
38 | "zod": "^3.23.8"
39 | },
40 | "devDependencies": {
41 | "@biomejs/biome": "1.9.4",
42 | "@types/node": "^20",
43 | "@types/pg": "^8.11.10",
44 | "@types/react": "^18",
45 | "@types/react-dom": "^18",
46 | "@types/react-syntax-highlighter": "^15.5.13",
47 | "eslint": "^8",
48 | "eslint-config-next": "15.0.3",
49 | "postcss": "^8",
50 | "prisma": "^5.22.0",
51 | "tailwindcss": "^3.4.1",
52 | "typescript": "^5"
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/prisma/migrations/0_init/migration.sql:
--------------------------------------------------------------------------------
1 | CREATE EXTENSION IF NOT EXISTS vector;
2 |
3 | -- CreateTable
4 | CREATE TABLE "items" (
5 | "id" BIGSERIAL NOT NULL,
6 | "embedding" vector,
7 |
8 | CONSTRAINT "items_pkey" PRIMARY KEY ("id")
9 | );
--------------------------------------------------------------------------------
/prisma/migrations/20241118143807_add_oai_table/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE "oai" (
3 | "id" BIGSERIAL NOT NULL,
4 | "chunk" TEXT,
5 | "embedding" vector,
6 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
7 | "metadata" JSONB DEFAULT '{}',
8 | "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
9 |
10 | CONSTRAINT "oai_pkey" PRIMARY KEY ("id")
11 | );
12 |
--------------------------------------------------------------------------------
/prisma/migrations/20241118144227_add_dummy_field_to_oai/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE "oai" ADD COLUMN "dummy_field" TEXT;
3 |
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | generator client {
2 | provider = "prisma-client-js"
3 | }
4 |
5 | datasource db {
6 | provider = "postgresql"
7 | url = env("POSTGRES_URL")
8 | }
9 |
10 | model items {
11 | id BigInt @id @default(autoincrement())
12 | embedding Unsupported("vector")?
13 | }
14 |
15 | model oai {
16 | id BigInt @id @default(autoincrement())
17 | chunk String?
18 | embedding Unsupported("vector")?
19 | createdAt DateTime @default(now())
20 | metadata Json? @default("{}")
21 | updatedAt DateTime @default(now())
22 | dummy_field String?
23 | }
24 |
--------------------------------------------------------------------------------
/public/file.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/globe.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/nextrag.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/HamedMP/NextRag/2c7e85018220e7b001710a28b88d27338d9327e6/public/nextrag.png
--------------------------------------------------------------------------------
/public/tech/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/tech/next.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/HamedMP/NextRag/2c7e85018220e7b001710a28b88d27338d9327e6/public/tech/next.webp
--------------------------------------------------------------------------------
/public/tech/postgres.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/HamedMP/NextRag/2c7e85018220e7b001710a28b88d27338d9327e6/public/tech/postgres.png
--------------------------------------------------------------------------------
/public/tech/prisma.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/window.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/app/(chat)/chat/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
4 | import { IngestTab } from "@/components/tabs/ingest-tab";
5 | import { SearchTab } from "@/components/tabs/search-tab";
6 | import { ChatTab } from "@/components/tabs/chat-tab";
7 | import { Card } from "@/components/ui/card";
8 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
9 | import { Lightbulb, AlertTriangle } from "lucide-react";
10 |
11 | export default function PlaygroundPage() {
12 | const isProduction = process.env.NODE_ENV === "production";
13 |
14 | if (isProduction) {
15 | return (
16 |
17 |
18 |
19 | Demo Environment Only
20 |
21 | This playground is configured to work only in development
22 | environment with your local database setup. To use this feature,
23 | please run the application locally after setting up your own
24 | database and environment variables.
25 |
26 |
27 |
28 | );
29 | }
30 |
31 | return (
32 |
33 |
34 |
RAG Playground
35 |
36 | Experiment with vector search and RAG capabilities in this interactive
37 | playground. Upload documents, test similarity search, and chat with
38 | your data.
39 |
40 |
41 |
42 |
43 |
44 | Local Development Only
45 |
46 | This playground works with your local database setup. Make sure you
47 | have configured your environment variables and database connections
48 | correctly before using these features.
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 | Ingest Data
57 |
58 |
59 | Search
60 |
61 |
62 | Chat
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 | Note: This is a development environment playground. For production
85 | use, please deploy your own instance with proper database
86 | configuration.
87 |
88 |
89 |
90 | );
91 | }
92 |
--------------------------------------------------------------------------------
/src/app/(chat)/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Navbar } from "@/components/navbar";
2 |
3 | export default function PlaygroundLayout({
4 | children,
5 | }: {
6 | children: React.ReactNode;
7 | }) {
8 | return (
9 |
10 |
11 | {children}
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/src/app/(docs)/docs/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Card } from "@/components/ui/card";
4 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
5 | import { CodeBlock } from "@/components/ui/code-block";
6 | import { AlertCircle } from "lucide-react";
7 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
8 | import { Badge } from "@/components/ui/badge";
9 |
10 | export default function DocsPage() {
11 | return (
12 |
13 |
14 |
Documentation
15 |
16 | Learn how to integrate vector search and RAG capabilities into your
17 | Next.js application.
18 |
19 |
20 |
21 |
22 |
23 | Quick Start
24 |
25 | Follow the setup instructions below to get started with vector search
26 | in minutes. Make sure you have PostgreSQL with pgvector extension
27 | enabled.
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | Setup
36 |
37 |
38 | PGVector
39 |
40 |
41 | VectorDB
42 |
43 |
44 | Examples
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 | Getting Started
53 |
54 |
55 | Complete these steps to set up vector search in your
56 | application.
57 |
58 |
59 |
60 |
61 |
62 |
63 | Step 1
64 |
Database Setup
65 |
66 |
67 |
Create a Postgres database with either:
68 |
69 | Vercel Postgres (recommended for quick start)
70 | NeonDB (recommended for production)
71 |
72 |
73 |
74 |
75 |
76 |
77 | Step 2
78 |
79 | Environment Variables
80 |
81 |
82 |
83 | {`
84 | # Database
85 | POSTGRES_URL="postgres://..."
86 |
87 | # OpenAI
88 | OPENAI_API_KEY="sk-..."
89 |
90 | # Inngest (optional, for background jobs)
91 | INNGEST_EVENT_KEY="..."
92 | INNGEST_SIGNING_KEY="..."
93 | `}
94 |
95 |
96 |
97 |
98 |
99 | Step 3
100 |
Enable pgvector
101 |
102 |
103 |
Connect to your database and run:
104 |
{`CREATE EXTENSION vector;`}
105 |
106 |
107 |
108 |
109 |
110 | Step 4
111 |
Create Tables
112 |
113 |
114 |
Using Prisma schema:
115 |
{`
116 | model items {
117 | id BigInt @id @default(autoincrement())
118 | embedding Unsupported("vector")?
119 | metadata Json? @default("{}")
120 | createdAt DateTime @default(now())
121 | updatedAt DateTime @updatedAt
122 | }
123 | `}
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 | PGVector Setup
133 |
134 |
135 | Configure and optimize your vector database for similarity
136 | search.
137 |
138 |
139 |
140 |
141 |
142 |
Indexing
143 |
144 |
Create an index for faster similarity search:
145 |
{`
146 | -- For cosine similarity (recommended for OpenAI embeddings)
147 | CREATE INDEX ON items USING hnsw (embedding vector_cosine_ops);
148 |
149 | -- For L2 distance
150 | CREATE INDEX ON items USING hnsw (embedding vector_l2_ops);
151 |
152 | -- For inner product
153 | CREATE INDEX ON items USING hnsw (embedding vector_ip_ops);
154 | `}
155 |
156 |
157 |
158 |
159 |
Index Options
160 |
161 |
Customize HNSW parameters:
162 |
{`
163 | CREATE INDEX ON items
164 | USING hnsw (embedding vector_cosine_ops)
165 | WITH (m = 16, ef_construction = 64);
166 | `}
167 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 | VectorDB Usage
176 |
177 |
178 | Configure and use the VectorDB library for efficient vector
179 | storage and retrieval.
180 |
181 |
182 |
183 |
184 |
185 |
Configuration
186 |
187 | {`
188 | const vectorDB = new VectorDB({
189 | tableName: 'items',
190 | columns: {
191 | id: 'id',
192 | vector: 'embedding',
193 | content: 'text',
194 | metadata: 'metadata',
195 | createdAt: 'createdAt',
196 | }
197 | }, {
198 | embedding: {
199 | model: 'text-embedding-3-small',
200 | dimensions: 1536,
201 | distance: 'cosine',
202 | },
203 | chunking: {
204 | method: 'paragraph',
205 | fixedSize: 500,
206 | },
207 | search: {
208 | defaultLimit: 5,
209 | reranking: false,
210 | }
211 | });
212 | `}
213 |
214 |
215 |
216 |
217 |
Basic Operations
218 |
219 | {`
220 | // Add text with automatic chunking
221 | await vectorDB.addText(text, {
222 | chunkingMethod: 'paragraph',
223 | metadata: { source: 'docs' }
224 | });
225 |
226 | // Search similar chunks
227 | const results = await vectorDB.searchSimilar(query, {
228 | limit: 5,
229 | distance: 'cosine',
230 | filter: { source: 'docs' }
231 | });
232 | `}
233 |
234 |
235 |
236 |
237 |
238 |
239 |
240 |
241 | Example Usage
242 |
243 |
244 | Explore examples of integrating vector search and RAG
245 | capabilities into your Next.js application.
246 |
247 |
248 |
249 |
250 |
251 |
Chat with Documents
252 |
253 |
254 | See the chat implementation in src/app/(chat)
{" "}
255 | for a complete example of:
256 |
257 |
258 | Document ingestion with chunking
259 | Semantic search with metadata filtering
260 | Streaming chat responses with context
261 |
262 |
263 |
264 |
265 |
266 |
Chunking Methods
267 |
268 | {`
269 | // Sentence-based chunking
270 | const chunks = vectorDB.chunkText(text, 'sentence');
271 |
272 | // Paragraph-based chunking
273 | const chunks = vectorDB.chunkText(text, 'paragraph');
274 |
275 | // Fixed-size chunking
276 | const chunks = vectorDB.chunkText(text, 'fixed');
277 | `}
278 |
279 |
280 |
281 |
282 |
Similarity Metrics
283 |
284 | {`
285 | // Cosine similarity (normalized vectors)
286 | const results = await vectorDB.searchSimilar(query, {
287 | distance: 'cosine'
288 | });
289 |
290 | // Euclidean distance
291 | const results = await vectorDB.searchSimilar(query, {
292 | distance: 'euclidean'
293 | });
294 |
295 | // Inner product
296 | const results = await vectorDB.searchSimilar(query, {
297 | distance: 'inner_product'
298 | });
299 | `}
300 |
301 |
302 |
303 |
304 |
305 |
306 |
307 |
308 |
320 |
321 | );
322 | }
323 |
--------------------------------------------------------------------------------
/src/app/(docs)/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Navbar } from "@/components/navbar";
2 |
3 | export default function DocsLayout({
4 | children,
5 | }: {
6 | children: React.ReactNode;
7 | }) {
8 | return (
9 |
10 |
11 | {children}
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/src/app/api/chat/route.ts:
--------------------------------------------------------------------------------
1 | import { openai } from '@ai-sdk/openai';
2 | import { streamText, StreamData } from 'ai';
3 | import { searchSimilarChunks } from '@/lib/actions/search';
4 | import { QueryResultRow } from '@vercel/postgres';
5 |
6 | export const maxDuration = 30;
7 |
8 | export async function POST(req: Request) {
9 | const { messages } = await req.json();
10 | const lastMessage = messages[messages.length - 1];
11 |
12 | // Create StreamData instance
13 | const data = new StreamData();
14 |
15 | // Get search results
16 | const searchResults = await searchSimilarChunks(lastMessage.content);
17 |
18 | // Format context with metadata
19 | const contextDetails = searchResults?.length
20 | ? searchResults.map((r: QueryResultRow) => ({
21 | chunk: r.chunk,
22 | metadata: {
23 | distance: r.distance.toFixed(3),
24 | createdAt: new Date(r.createdAt).toLocaleDateString(),
25 | ...r.metadata,
26 | },
27 | }))
28 | : [];
29 |
30 | // Append context to stream data
31 | data.append({ contextDetails });
32 |
33 | const context = contextDetails.length
34 | ? `Relevant context:\n${contextDetails
35 | .map(
36 | (r) =>
37 | `${r.chunk}\n(Distance: ${r.metadata.distance}, Created: ${r.metadata.createdAt}, Method: ${r.metadata.chunkingMethod}, Index: ${r.metadata.chunkIndex}/${r.metadata.totalChunks})`
38 | )
39 | .join('\n\n')}\n\n`
40 | : '';
41 |
42 | const result = await streamText({
43 | model: openai('gpt-4o-mini'),
44 | messages: [
45 | {
46 | role: 'system',
47 | content:
48 | 'You are a helpful AI assistant. Use the provided context to answer questions when available. Always start your response with a brief mention of which context you used, if any.',
49 | },
50 | ...(context
51 | ? [
52 | {
53 | role: 'system',
54 | content: context,
55 | },
56 | ]
57 | : []),
58 | ...messages,
59 | ],
60 | onFinish: () => {
61 | // Close the stream when done
62 | data.close();
63 | },
64 | });
65 |
66 | // Return the response with the additional stream data
67 | return result.toDataStreamResponse({ data });
68 | }
69 |
--------------------------------------------------------------------------------
/src/app/api/healthcheck/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { healthCheck } from '@/lib/db/pg';
3 |
4 | export async function GET() {
5 | console.log('in pg route');
6 | try {
7 | const isHealthy = await healthCheck();
8 | if (!isHealthy) {
9 | return NextResponse.json(
10 | { error: 'Database is not healthy' },
11 | { status: 500 }
12 | );
13 | } else {
14 | console.log('Database is healthy');
15 | return NextResponse.json({ message: 'Database is healthy' });
16 | }
17 | } catch (error: any) {
18 | console.error('Database error details:', {
19 | code: error.code,
20 | message: error.message,
21 | stack: error.stack,
22 | });
23 |
24 | const errorMessage =
25 | error.code === 'ECONNREFUSED'
26 | ? 'Unable to connect to database - please check if PostgreSQL is running'
27 | : 'Failed to fetch data';
28 |
29 | return NextResponse.json({ error: errorMessage }, { status: 500 });
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/app/api/inngest/events/route.ts:
--------------------------------------------------------------------------------
1 | import { inngest } from '@/inngest/client';
2 | import { NextResponse } from 'next/server';
3 |
4 | export async function GET() {
5 | try {
6 | // Fetch recent events from Inngest
7 | const events = await inngest.listEvents({
8 | limit: 20,
9 | orderBy: 'desc',
10 | });
11 |
12 | return NextResponse.json({ events });
13 | } catch (error) {
14 | console.error('Failed to fetch Inngest events:', error);
15 | return NextResponse.json(
16 | { error: 'Failed to fetch events' },
17 | { status: 500 }
18 | );
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/app/api/inngest/route.ts:
--------------------------------------------------------------------------------
1 | import { serve } from 'inngest/next';
2 | import { inngest } from '@/inngest/client';
3 | import { embedText } from '@/inngest/functions/embedding';
4 | import { retrieveSimilar } from '@/inngest/functions/retrieval';
5 |
6 | export const { GET, POST, PUT } = serve({
7 | client: inngest,
8 | functions: [embedText, retrieveSimilar],
9 | });
10 |
--------------------------------------------------------------------------------
/src/app/api/search/route.ts:
--------------------------------------------------------------------------------
1 | import { searchSimilarChunks } from '@/lib/actions/search';
2 | import { NextResponse } from 'next/server';
3 |
4 | export async function POST(req: Request) {
5 | try {
6 | const { query } = await req.json();
7 | if (!query) {
8 | return NextResponse.json({ error: 'Query is required' }, { status: 400 });
9 | }
10 |
11 | const results = await searchSimilarChunks(query);
12 | return NextResponse.json({ results });
13 | } catch (error) {
14 | console.error('Search error:', error);
15 | return NextResponse.json({ error: 'Failed to search' }, { status: 500 });
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/HamedMP/NextRag/2c7e85018220e7b001710a28b88d27338d9327e6/src/app/favicon.ico
--------------------------------------------------------------------------------
/src/app/fonts/GeistMonoVF.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/HamedMP/NextRag/2c7e85018220e7b001710a28b88d27338d9327e6/src/app/fonts/GeistMonoVF.woff
--------------------------------------------------------------------------------
/src/app/fonts/GeistVF.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/HamedMP/NextRag/2c7e85018220e7b001710a28b88d27338d9327e6/src/app/fonts/GeistVF.woff
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 240 10% 3.9%;
9 | --card: 0 0% 100%;
10 | --card-foreground: 240 10% 3.9%;
11 | --popover: 0 0% 100%;
12 | --popover-foreground: 240 10% 3.9%;
13 | --primary: 240 5.9% 10%;
14 | --primary-foreground: 0 0% 98%;
15 | --secondary: 240 4.8% 95.9%;
16 | --secondary-foreground: 240 5.9% 10%;
17 | --muted: 240 4.8% 95.9%;
18 | --muted-foreground: 240 3.8% 46.1%;
19 | --accent: 240 4.8% 95.9%;
20 | --accent-foreground: 240 5.9% 10%;
21 | --destructive: 0 84.2% 60.2%;
22 | --destructive-foreground: 0 0% 98%;
23 | --border: 240 5.9% 90%;
24 | --input: 240 5.9% 90%;
25 | --ring: 240 5.9% 10%;
26 | --radius: 0.5rem;
27 | }
28 |
29 | .dark {
30 | --background: 240 10% 3.9%;
31 | --foreground: 0 0% 98%;
32 | --card: 240 10% 3.9%;
33 | --card-foreground: 0 0% 98%;
34 | --popover: 240 10% 3.9%;
35 | --popover-foreground: 0 0% 98%;
36 | --primary: 0 0% 98%;
37 | --primary-foreground: 240 5.9% 10%;
38 | --secondary: 240 3.7% 15.9%;
39 | --secondary-foreground: 0 0% 98%;
40 | --muted: 240 3.7% 15.9%;
41 | --muted-foreground: 240 5% 64.9%;
42 | --accent: 240 3.7% 15.9%;
43 | --accent-foreground: 0 0% 98%;
44 | --destructive: 0 62.8% 30.6%;
45 | --destructive-foreground: 0 0% 98%;
46 | --border: 240 3.7% 15.9%;
47 | --input: 240 3.7% 15.9%;
48 | --ring: 240 4.9% 83.9%;
49 | }
50 | }
51 |
52 | @layer base {
53 | * {
54 | @apply border-border;
55 | }
56 | body {
57 | @apply bg-background text-foreground;
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import { GeistMono } from "geist/font/mono";
2 | import { GeistSans } from "geist/font/sans";
3 | import type { Metadata } from "next";
4 | import "./globals.css";
5 | import { ThemeProvider } from "@/components/theme-provider";
6 | import { Toaster } from "sonner";
7 |
8 | export const metadata: Metadata = {
9 | title: "RAG Engineering Demo",
10 | description: "Vector Search & RAG Implementation with Next.js and PostgreSQL",
11 | };
12 |
13 | export default function RootLayout({
14 | children,
15 | }: {
16 | children: React.ReactNode;
17 | }) {
18 | return (
19 |
20 |
23 |
29 |
30 | {children}
31 |
32 |
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/src/app/opengraph-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/HamedMP/NextRag/2c7e85018220e7b001710a28b88d27338d9327e6/src/app/opengraph-image.png
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import { Navbar } from "@/components/navbar";
2 | import { TechIcons } from "@/components/tech-icons";
3 | import { Button } from "@/components/ui/button";
4 | import { Card } from "@/components/ui/card";
5 | import { DotPattern } from "@/components/ui/dot-pattern";
6 | import { env } from "@/lib/env.mjs";
7 | import { cn } from "@/lib/utils";
8 | import { Braces, Database, GitBranch, Terminal } from "lucide-react";
9 | import Link from "next/link";
10 |
11 | export default function Home() {
12 | return (
13 |
14 |
15 |
16 |
17 |
18 | NextRAG
19 |
20 |
21 | Production-ready implementation of Retrieval Augmented Generation
22 | with vector search capabilities using PostgreSQL + pgvector in
23 | Next.js.
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
Core Stack
34 |
35 |
36 | PostgreSQL + pgvector
37 |
38 |
39 | Inngest pipelines
40 |
41 |
42 | Next.js 15
43 |
44 |
45 |
46 |
47 | View Documentation →
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 | // Try it out
57 |
58 |
RAG Playground
59 |
60 |
61 |
62 |
69 | Open Playground →
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
90 |
91 | );
92 | }
93 |
--------------------------------------------------------------------------------
/src/app/twitter-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/HamedMP/NextRag/2c7e85018220e7b001710a28b88d27338d9327e6/src/app/twitter-image.png
--------------------------------------------------------------------------------
/src/components/navbar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ThemeToggle } from "@/components/theme-toggle";
4 | import Link from "next/link";
5 | import { usePathname } from "next/navigation";
6 |
7 | const navItems = [
8 | { href: "/", label: "Home" },
9 | { href: "/docs", label: "Documentation" },
10 | { href: "/chat", label: "Playground" },
11 | ];
12 |
13 | export function Navbar() {
14 | const pathname = usePathname();
15 |
16 | return (
17 |
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/src/components/tabs/chat-tab.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
3 | import { ScrollArea } from "@/components/ui/scroll-area";
4 | import { useChat } from "ai/react";
5 | import { toast } from "sonner";
6 | import ReactMarkdown from "react-markdown";
7 | import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
8 | import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism";
9 | import type { SyntaxHighlighterProps } from "react-syntax-highlighter";
10 |
11 | export function ChatTab() {
12 | const isProduction = process.env.NODE_ENV === "production";
13 | const { messages, input, handleInputChange, handleSubmit, data } = useChat({
14 | api: "/api/chat",
15 | });
16 |
17 | const handleChatSubmit = async (e: React.FormEvent) => {
18 | e.preventDefault();
19 | if (isProduction) {
20 | toast.error("Chat is disabled in production. Please run locally.");
21 | return;
22 | }
23 | if (!input.trim()) {
24 | toast.error("Please enter a question");
25 | return;
26 | }
27 |
28 | handleSubmit(e);
29 | };
30 |
31 | return (
32 |
33 |
34 | Chat
35 |
36 |
37 |
38 |
39 | {messages.map((m) => (
40 |
46 |
53 | {m.role === "user" ? (
54 | m.content
55 | ) : (
56 | <>
57 | {/* Show context details if available */}
58 | {data?.length > 0 &&
59 | data[data.length - 1].contextDetails?.length > 0 && (
60 |
61 |
62 | Context Used:
63 |
64 | {data[data.length - 1].contextDetails.map(
65 | (context, i) => (
66 |
67 |
68 | {context.chunk}
69 |
70 |
71 | Distance: {context.metadata.distance} |
72 | Created: {context.metadata.createdAt} |
73 | Method: {context.metadata.chunkingMethod} |
74 | Chunk: {context.metadata.chunkIndex + 1}/
75 | {context.metadata.totalChunks}
76 |
77 |
78 | ),
79 | )}
80 |
81 | )}
82 |
95 | {String(children).replace(/\n$/, "")}
96 |
97 | ) : (
98 |
99 | {children}
100 |
101 | );
102 | },
103 | }}
104 | className="prose prose-invert max-w-none"
105 | >
106 | {m.content}
107 |
108 | >
109 | )}
110 |
111 |
112 | ))}
113 |
114 |
115 |
116 |
132 |
133 |
134 | );
135 | }
136 |
--------------------------------------------------------------------------------
/src/components/tabs/ingest-tab.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
3 | import { Textarea } from "@/components/ui/textarea";
4 | import { inngest } from "@/inngest/client";
5 | import { Loader2 } from "lucide-react";
6 | import { useState } from "react";
7 | import { toast } from "sonner";
8 |
9 | export function IngestTab() {
10 | const [text, setText] = useState("");
11 | const [loading, setLoading] = useState(false);
12 |
13 | const handleEmbed = async () => {
14 | if (!text) {
15 | toast.error("Please enter some text to embed");
16 | return;
17 | }
18 |
19 | setLoading(true);
20 | const toastId = toast.loading("Processing your text...");
21 |
22 | try {
23 | await inngest.send({
24 | name: "embed/text",
25 | data: {
26 | text,
27 | chunkingMethod: "paragraph",
28 | },
29 | });
30 |
31 | toast.success("Text successfully processed and stored!", { id: toastId });
32 | setText("");
33 | } catch (error) {
34 | console.error("Failed to process:", error);
35 | toast.error("Failed to process text. Please try again.", { id: toastId });
36 | } finally {
37 | setLoading(false);
38 | }
39 | };
40 |
41 | return (
42 |
43 |
44 | Add Knowledge
45 |
46 |
47 |
64 |
65 | );
66 | }
67 |
--------------------------------------------------------------------------------
/src/components/tabs/search-tab.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
3 | import { Input } from "@/components/ui/input";
4 | import { ScrollArea } from "@/components/ui/scroll-area";
5 | import { useState } from "react";
6 | import { toast } from "sonner";
7 |
8 | type SearchResult = {
9 | chunk: string;
10 | distance: number;
11 | };
12 |
13 | export function SearchTab() {
14 | const [query, setQuery] = useState("");
15 | const [loading, setLoading] = useState(false);
16 | const [searchResults, setSearchResults] = useState([]);
17 |
18 | const handleSearch = async (e: React.FormEvent) => {
19 | e.preventDefault();
20 | if (!query.trim()) {
21 | toast.error("Please enter a search query");
22 | return;
23 | }
24 |
25 | setLoading(true);
26 | try {
27 | const response = await fetch("/api/search", {
28 | method: "POST",
29 | headers: { "Content-Type": "application/json" },
30 | body: JSON.stringify({ query }),
31 | });
32 |
33 | const { results } = await response.json();
34 | setSearchResults(results);
35 | } catch (error) {
36 | console.error("Failed to search:", error);
37 | toast.error("Failed to perform search. Please try again.");
38 | } finally {
39 | setLoading(false);
40 | }
41 | };
42 |
43 | return (
44 |
45 |
46 | Search Knowledge Base
47 |
48 |
49 |
60 |
61 |
62 | {searchResults.length > 0 ? (
63 |
64 | {searchResults.map((result, i) => (
65 |
66 |
67 |
68 |
69 | Similarity: {(1 - result.distance).toFixed(3)}
70 |
71 |
72 | {result.chunk}
73 |
74 |
75 |
76 |
77 | ))}
78 |
79 | ) : (
80 |
81 | Search results will appear here
82 |
83 | )}
84 |
85 |
86 |
87 | );
88 | }
89 |
--------------------------------------------------------------------------------
/src/components/tech-icons.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { motion } from "framer-motion";
4 | import Image from "next/image";
5 |
6 | const technologies = [
7 | {
8 | name: "Next.js",
9 | icon: "/tech/next.webp",
10 | url: "https://nextjs.org/",
11 | },
12 | {
13 | name: "PostgreSQL",
14 | icon: "/tech/postgres.png",
15 | url: "https://www.postgresql.org/",
16 | },
17 | {
18 | name: "Prisma",
19 | icon: "/tech/prisma.svg",
20 | url: "https://www.prisma.io/",
21 | },
22 | ];
23 |
24 | export function TechIcons() {
25 | return (
26 |
27 | {technologies.map((tech, i) => (
28 |
39 |
40 |
47 | {tech.name}
48 |
49 | ))}
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/src/components/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | ThemeProvider as NextThemesProvider,
5 | type ThemeProviderProps,
6 | } from "next-themes";
7 | import type * as React from "react";
8 |
9 | export function ThemeProvider({
10 | children,
11 | ...props
12 | }: React.PropsWithChildren) {
13 | return {children} ;
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/theme-toggle.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Moon, Sun } from "lucide-react";
4 | import { useTheme } from "next-themes";
5 | import * as React from "react";
6 |
7 | import { Button } from "@/components/ui/button";
8 | import {
9 | DropdownMenu,
10 | DropdownMenuContent,
11 | DropdownMenuItem,
12 | DropdownMenuTrigger,
13 | } from "@/components/ui/dropdown-menu";
14 |
15 | export function ThemeToggle() {
16 | const { setTheme } = useTheme();
17 |
18 | return (
19 |
20 |
21 |
22 |
23 |
24 | Toggle theme
25 |
26 |
27 |
28 | setTheme("light")}>
29 | Light
30 |
31 | setTheme("dark")}>
32 | Dark
33 |
34 | setTheme("system")}>
35 | System
36 |
37 |
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/src/components/ui/alert.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const alertVariants = cva(
7 | "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
8 | {
9 | variants: {
10 | variant: {
11 | default: "bg-background text-foreground",
12 | destructive:
13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
14 | },
15 | },
16 | defaultVariants: {
17 | variant: "default",
18 | },
19 | }
20 | )
21 |
22 | const Alert = React.forwardRef<
23 | HTMLDivElement,
24 | React.HTMLAttributes & VariantProps
25 | >(({ className, variant, ...props }, ref) => (
26 |
32 | ))
33 | Alert.displayName = "Alert"
34 |
35 | const AlertTitle = React.forwardRef<
36 | HTMLParagraphElement,
37 | React.HTMLAttributes
38 | >(({ className, ...props }, ref) => (
39 |
44 | ))
45 | AlertTitle.displayName = "AlertTitle"
46 |
47 | const AlertDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ))
57 | AlertDescription.displayName = "AlertDescription"
58 |
59 | export { Alert, AlertTitle, AlertDescription }
60 |
--------------------------------------------------------------------------------
/src/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const badgeVariants = cva(
7 | "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
13 | secondary:
14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
15 | destructive:
16 | "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
17 | outline: "text-foreground",
18 | },
19 | },
20 | defaultVariants: {
21 | variant: "default",
22 | },
23 | }
24 | )
25 |
26 | export interface BadgeProps
27 | extends React.HTMLAttributes,
28 | VariantProps {}
29 |
30 | function Badge({ className, variant, ...props }: BadgeProps) {
31 | return (
32 |
33 | )
34 | }
35 |
36 | export { Badge, badgeVariants }
37 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import { Slot } from "@radix-ui/react-slot";
2 | import { type VariantProps, cva } from "class-variance-authority";
3 | import * as React from "react";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
16 | outline:
17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
20 | ghost: "hover:bg-accent hover:text-accent-foreground",
21 | link: "text-primary underline-offset-4 hover:underline",
22 | },
23 | size: {
24 | default: "h-9 px-4 py-2",
25 | sm: "h-8 rounded-md px-3 text-xs",
26 | lg: "h-10 rounded-md px-8",
27 | icon: "h-9 w-9",
28 | },
29 | },
30 | defaultVariants: {
31 | variant: "default",
32 | size: "default",
33 | },
34 | },
35 | );
36 |
37 | export interface ButtonProps
38 | extends React.ButtonHTMLAttributes,
39 | VariantProps {
40 | asChild?: boolean;
41 | }
42 |
43 | const Button = React.forwardRef(
44 | ({ className, variant, size, asChild = false, ...props }, ref) => {
45 | const Comp = asChild ? Slot : "button";
46 | return (
47 |
52 | );
53 | },
54 | );
55 | Button.displayName = "Button";
56 |
57 | export { Button, buttonVariants };
58 |
--------------------------------------------------------------------------------
/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ));
18 | Card.displayName = "Card";
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ));
30 | CardHeader.displayName = "CardHeader";
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLDivElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
41 | ));
42 | CardTitle.displayName = "CardTitle";
43 |
44 | const CardDescription = React.forwardRef<
45 | HTMLDivElement,
46 | React.HTMLAttributes
47 | >(({ className, ...props }, ref) => (
48 |
53 | ));
54 | CardDescription.displayName = "CardDescription";
55 |
56 | const CardContent = React.forwardRef<
57 | HTMLDivElement,
58 | React.HTMLAttributes
59 | >(({ className, ...props }, ref) => (
60 |
61 | ));
62 | CardContent.displayName = "CardContent";
63 |
64 | const CardFooter = React.forwardRef<
65 | HTMLDivElement,
66 | React.HTMLAttributes
67 | >(({ className, ...props }, ref) => (
68 |
73 | ));
74 | CardFooter.displayName = "CardFooter";
75 |
76 | export {
77 | Card,
78 | CardHeader,
79 | CardFooter,
80 | CardTitle,
81 | CardDescription,
82 | CardContent,
83 | };
84 |
--------------------------------------------------------------------------------
/src/components/ui/code-block.tsx:
--------------------------------------------------------------------------------
1 | import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
2 | import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism";
3 | import { Button } from "./button";
4 | import { Check, Copy } from "lucide-react";
5 | import { useState } from "react";
6 |
7 | interface CodeBlockProps {
8 | children: string;
9 | language?: string;
10 | showLineNumbers?: boolean;
11 | }
12 |
13 | export function CodeBlock({
14 | children,
15 | language = "typescript",
16 | showLineNumbers = true,
17 | }: CodeBlockProps) {
18 | const [copied, setCopied] = useState(false);
19 |
20 | const copyToClipboard = () => {
21 | navigator.clipboard.writeText(children);
22 | setCopied(true);
23 | setTimeout(() => setCopied(false), 2000);
24 | };
25 |
26 | return (
27 |
28 |
34 | {copied ? : }
35 |
36 |
46 | {children.trim()}
47 |
48 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/src/components/ui/dot-pattern.tsx:
--------------------------------------------------------------------------------
1 | import { useId } from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | interface DotPatternProps {
6 | width?: any;
7 | height?: any;
8 | x?: any;
9 | y?: any;
10 | cx?: any;
11 | cy?: any;
12 | cr?: any;
13 | className?: string;
14 | [key: string]: any;
15 | }
16 | export function DotPattern({
17 | width = 16,
18 | height = 16,
19 | x = 0,
20 | y = 0,
21 | cx = 1,
22 | cy = 1,
23 | cr = 1,
24 | className,
25 | ...props
26 | }: DotPatternProps) {
27 | const id = useId();
28 |
29 | return (
30 |
38 |
39 |
48 |
49 |
50 |
51 |
52 |
53 | );
54 | }
55 |
56 | export default DotPattern;
57 |
--------------------------------------------------------------------------------
/src/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
4 | import { Check, ChevronRight, Circle } from "lucide-react";
5 | import * as React from "react";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | const DropdownMenu = DropdownMenuPrimitive.Root;
10 |
11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
12 |
13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group;
14 |
15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
16 |
17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub;
18 |
19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
20 |
21 | const DropdownMenuSubTrigger = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef & {
24 | inset?: boolean;
25 | }
26 | >(({ className, inset, children, ...props }, ref) => (
27 |
36 | {children}
37 |
38 |
39 | ));
40 | DropdownMenuSubTrigger.displayName =
41 | DropdownMenuPrimitive.SubTrigger.displayName;
42 |
43 | const DropdownMenuSubContent = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef
46 | >(({ className, ...props }, ref) => (
47 |
55 | ));
56 | DropdownMenuSubContent.displayName =
57 | DropdownMenuPrimitive.SubContent.displayName;
58 |
59 | const DropdownMenuContent = React.forwardRef<
60 | React.ElementRef,
61 | React.ComponentPropsWithoutRef
62 | >(({ className, sideOffset = 4, ...props }, ref) => (
63 |
64 |
74 |
75 | ));
76 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
77 |
78 | const DropdownMenuItem = React.forwardRef<
79 | React.ElementRef,
80 | React.ComponentPropsWithoutRef & {
81 | inset?: boolean;
82 | }
83 | >(({ className, inset, ...props }, ref) => (
84 | svg]:size-4 [&>svg]:shrink-0",
88 | inset && "pl-8",
89 | className,
90 | )}
91 | {...props}
92 | />
93 | ));
94 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
95 |
96 | const DropdownMenuCheckboxItem = React.forwardRef<
97 | React.ElementRef,
98 | React.ComponentPropsWithoutRef
99 | >(({ className, children, checked, ...props }, ref) => (
100 |
109 |
110 |
111 |
112 |
113 |
114 | {children}
115 |
116 | ));
117 | DropdownMenuCheckboxItem.displayName =
118 | DropdownMenuPrimitive.CheckboxItem.displayName;
119 |
120 | const DropdownMenuRadioItem = React.forwardRef<
121 | React.ElementRef,
122 | React.ComponentPropsWithoutRef
123 | >(({ className, children, ...props }, ref) => (
124 |
132 |
133 |
134 |
135 |
136 |
137 | {children}
138 |
139 | ));
140 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
141 |
142 | const DropdownMenuLabel = React.forwardRef<
143 | React.ElementRef,
144 | React.ComponentPropsWithoutRef & {
145 | inset?: boolean;
146 | }
147 | >(({ className, inset, ...props }, ref) => (
148 |
157 | ));
158 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
159 |
160 | const DropdownMenuSeparator = React.forwardRef<
161 | React.ElementRef,
162 | React.ComponentPropsWithoutRef
163 | >(({ className, ...props }, ref) => (
164 |
169 | ));
170 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
171 |
172 | const DropdownMenuShortcut = ({
173 | className,
174 | ...props
175 | }: React.HTMLAttributes) => {
176 | return (
177 |
181 | );
182 | };
183 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
184 |
185 | export {
186 | DropdownMenu,
187 | DropdownMenuTrigger,
188 | DropdownMenuContent,
189 | DropdownMenuItem,
190 | DropdownMenuCheckboxItem,
191 | DropdownMenuRadioItem,
192 | DropdownMenuLabel,
193 | DropdownMenuSeparator,
194 | DropdownMenuShortcut,
195 | DropdownMenuGroup,
196 | DropdownMenuPortal,
197 | DropdownMenuSub,
198 | DropdownMenuSubContent,
199 | DropdownMenuSubTrigger,
200 | DropdownMenuRadioGroup,
201 | };
202 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Input = React.forwardRef>(
6 | ({ className, type, ...props }, ref) => {
7 | return (
8 |
17 | )
18 | }
19 | )
20 | Input.displayName = "Input"
21 |
22 | export { Input }
23 |
--------------------------------------------------------------------------------
/src/components/ui/scroll-area.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const ScrollArea = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, children, ...props }, ref) => (
12 |
17 |
18 | {children}
19 |
20 |
21 |
22 |
23 | ))
24 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
25 |
26 | const ScrollBar = React.forwardRef<
27 | React.ElementRef,
28 | React.ComponentPropsWithoutRef
29 | >(({ className, orientation = "vertical", ...props }, ref) => (
30 |
43 |
44 |
45 | ))
46 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
47 |
48 | export { ScrollArea, ScrollBar }
49 |
--------------------------------------------------------------------------------
/src/components/ui/sonner.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useTheme } from "next-themes"
4 | import { Toaster as Sonner } from "sonner"
5 |
6 | type ToasterProps = React.ComponentProps
7 |
8 | const Toaster = ({ ...props }: ToasterProps) => {
9 | const { theme = "system" } = useTheme()
10 |
11 | return (
12 |
28 | )
29 | }
30 |
31 | export { Toaster }
32 |
--------------------------------------------------------------------------------
/src/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as TabsPrimitive from "@radix-ui/react-tabs"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Tabs = TabsPrimitive.Root
9 |
10 | const TabsList = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ))
23 | TabsList.displayName = TabsPrimitive.List.displayName
24 |
25 | const TabsTrigger = React.forwardRef<
26 | React.ElementRef,
27 | React.ComponentPropsWithoutRef
28 | >(({ className, ...props }, ref) => (
29 |
37 | ))
38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
39 |
40 | const TabsContent = React.forwardRef<
41 | React.ElementRef,
42 | React.ComponentPropsWithoutRef
43 | >(({ className, ...props }, ref) => (
44 |
52 | ))
53 | TabsContent.displayName = TabsPrimitive.Content.displayName
54 |
55 | export { Tabs, TabsList, TabsTrigger, TabsContent }
56 |
--------------------------------------------------------------------------------
/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Textarea = React.forwardRef<
6 | HTMLTextAreaElement,
7 | React.ComponentProps<"textarea">
8 | >(({ className, ...props }, ref) => {
9 | return (
10 |
18 | )
19 | })
20 | Textarea.displayName = "Textarea"
21 |
22 | export { Textarea }
23 |
--------------------------------------------------------------------------------
/src/inngest/client.ts:
--------------------------------------------------------------------------------
1 | import { Inngest } from "inngest";
2 |
3 | // Create a client to send and receive events
4 | export const inngest = new Inngest({ id: "nextjs-rag" });
5 |
--------------------------------------------------------------------------------
/src/inngest/functions/embedding.ts:
--------------------------------------------------------------------------------
1 | import { inngest } from '../client';
2 | import { oaiVectorDB as vectorDB } from '@/lib/db/vector';
3 | import { ChunkMetadata, DB_CONFIG } from '@/lib/db/config';
4 |
5 | export const embedText = inngest.createFunction(
6 | { name: 'Embed Text', id: 'embed/text' },
7 | { event: 'embed/text' },
8 | async ({ event, step }) => {
9 | const { text, chunkingMethod } = event.data;
10 |
11 | const result = await step.run('Add Text', () =>
12 | vectorDB.addText(text, { chunkingMethod })
13 | );
14 |
15 | return {
16 | numChunks: result.count,
17 | success: true,
18 | };
19 | }
20 | );
21 |
22 | export const embedTextWithMetadata = inngest.createFunction(
23 | { name: 'Embed Text with Metadata', id: 'embed/text-with-metadata' },
24 | { event: 'embed/text-with-metadata' },
25 | async ({ event, step }) => {
26 | const {
27 | text,
28 | chunkingMethod = DB_CONFIG.chunking.defaultMethod,
29 | metadata = {},
30 | } = event.data;
31 |
32 | const customMetadata: Partial = {
33 | ...metadata,
34 | chunkingMethod,
35 | };
36 |
37 | const result = await step.run('Add Text with Metadata', () =>
38 | vectorDB.addText(text, {
39 | chunkingMethod,
40 | metadata: customMetadata,
41 | })
42 | );
43 |
44 | return {
45 | numChunks: result.count,
46 | metadata: customMetadata,
47 | success: true,
48 | };
49 | }
50 | );
51 |
52 | export const embedBatchTexts = inngest.createFunction(
53 | { name: 'Embed Batch Texts', id: 'embed/batch-texts' },
54 | { event: 'embed/batch-texts' },
55 | async ({ event, step }) => {
56 | const { texts, chunkingMethod, metadata = {} } = event.data;
57 |
58 | const results = await step.run('Process Batch', async () => {
59 | const promises = texts.map((text: string) =>
60 | vectorDB.addText(text, {
61 | chunkingMethod,
62 | metadata: {
63 | ...metadata,
64 | batchId: event.id,
65 | processedAt: new Date().toISOString(),
66 | },
67 | })
68 | );
69 |
70 | return Promise.all(promises);
71 | });
72 |
73 | return {
74 | totalProcessed: results.reduce((acc, r) => acc + r.count, 0),
75 | batchId: event.id,
76 | success: true,
77 | };
78 | }
79 | );
80 |
--------------------------------------------------------------------------------
/src/inngest/functions/retrieval.ts:
--------------------------------------------------------------------------------
1 | import { inngest } from '../client';
2 | import { searchSimilarChunks } from '@/lib/actions/search';
3 |
4 | export const retrieveSimilar = inngest.createFunction(
5 | { name: 'Retrieve Similar', id: 'retrieve/similar' },
6 | { event: 'retrieve/similar' },
7 | async ({ event, step }) => {
8 | const { query, limit = 5 } = event.data;
9 |
10 | const results = await step.run('Search Similar', () =>
11 | searchSimilarChunks(query, limit)
12 | );
13 |
14 | return {
15 | results,
16 | success: true,
17 | };
18 | }
19 | );
20 |
--------------------------------------------------------------------------------
/src/lib/actions/search.ts:
--------------------------------------------------------------------------------
1 | import { oaiVectorDB } from '@/lib/db/vector';
2 | import { DB_CONFIG } from '@/lib/db/config';
3 |
4 | export async function searchSimilarChunks(query: string, limit = 5) {
5 | return oaiVectorDB.searchSimilar(query, { limit });
6 | }
7 |
8 | export async function searchWithOptions(
9 | query: string,
10 | options?: {
11 | limit?: number;
12 | distance?: typeof DB_CONFIG.embedding.distance;
13 | filter?: {
14 | chunkingMethod?: typeof DB_CONFIG.chunking.defaultMethod;
15 | date?: string;
16 | [key: string]: unknown;
17 | };
18 | }
19 | ) {
20 | return oaiVectorDB.searchSimilar(query, options);
21 | }
22 |
23 | export async function searchByMetadata(
24 | query: string,
25 | metadata: {
26 | chunkingMethod?: typeof DB_CONFIG.chunking.defaultMethod;
27 | date?: string;
28 | [key: string]: unknown;
29 | },
30 | limit = 5
31 | ) {
32 | return oaiVectorDB.searchSimilar(query, {
33 | limit,
34 | filter: metadata,
35 | });
36 | }
37 |
--------------------------------------------------------------------------------
/src/lib/db/client.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from '@prisma/client';
2 |
3 | import { env } from '@/lib/env.mjs';
4 |
5 | declare global {
6 | // eslint-disable-next-line no-var
7 | var prisma: PrismaClient | undefined;
8 | }
9 |
10 | export const prisma =
11 | global.prisma ||
12 | new PrismaClient({
13 | log:
14 | env.NODE_ENV === 'development'
15 | ? ['query', 'error', 'warn', 'info']
16 | : ['error'],
17 | });
18 |
19 | if (env.NODE_ENV !== 'production') {
20 | global.prisma = prisma;
21 | }
22 |
--------------------------------------------------------------------------------
/src/lib/db/config.ts:
--------------------------------------------------------------------------------
1 | export const DB_CONFIG = {
2 | embedding: {
3 | model: 'text-embedding-3-small',
4 | dimensions: 1536,
5 | distance: 'cosine' as 'cosine' | 'euclidean' | 'inner_product',
6 | },
7 | chunking: {
8 | defaultMethod: 'sentence' as 'sentence' | 'paragraph' | 'fixed',
9 | fixedSize: 500,
10 | },
11 | search: {
12 | defaultLimit: 5,
13 | reranking: false,
14 | },
15 | } as const;
16 |
17 | export type VectorTableConfig = {
18 | tableName: string;
19 | columns: {
20 | id: string;
21 | vector: string;
22 | content?: string;
23 | metadata?: string;
24 | createdAt?: string;
25 | updatedAt?: string;
26 | };
27 | };
28 |
29 | export type VectorDBConfig = {
30 | embedding?: {
31 | model?: string;
32 | dimensions?: number;
33 | distance?: typeof DB_CONFIG.embedding.distance;
34 | };
35 | chunking?: {
36 | method?: typeof DB_CONFIG.chunking.defaultMethod;
37 | fixedSize?: number;
38 | };
39 | search?: {
40 | defaultLimit?: number;
41 | reranking?: boolean;
42 | };
43 | };
44 |
45 | export type ChunkMetadata = {
46 | date: string;
47 | embeddingModel: string;
48 | chunkingMethod: typeof DB_CONFIG.chunking.defaultMethod;
49 | sourceText?: string;
50 | chunkIndex: number;
51 | totalChunks: number;
52 | [key: string]: unknown;
53 | };
54 |
--------------------------------------------------------------------------------
/src/lib/db/pg.ts:
--------------------------------------------------------------------------------
1 | import { Pool, PoolClient, QueryResult } from 'pg';
2 |
3 | // Creates a global connection pool
4 | const pool = new Pool({
5 | sslmode: 'require',
6 | ssl: {
7 | rejectUnauthorized: false, // For development. In production, you might want this true
8 | },
9 | });
10 |
11 | // Better type for query params
12 | export type QueryParams =
13 | | string
14 | | number
15 | | boolean
16 | | null
17 | | undefined
18 | | Buffer
19 | | Date
20 | | QueryParams[];
21 |
22 | // Generic query function with better typing
23 | export async function query>(
24 | text: string,
25 | params: QueryParams[] = []
26 | ): Promise> {
27 | const client = await pool.connect();
28 | console.log('client query', text);
29 | try {
30 | return await client.query(text, params);
31 | } catch (error) {
32 | console.error('Database query error:', error);
33 | throw error;
34 | } finally {
35 | client.release();
36 | }
37 | }
38 |
39 | // Get client with automatic release on error
40 | export async function withClient(
41 | callback: (client: PoolClient) => Promise
42 | ): Promise {
43 | const client = await pool.connect();
44 | try {
45 | return await callback(client);
46 | } finally {
47 | client.release();
48 | }
49 | }
50 |
51 | // Graceful shutdown
52 | export async function end(): Promise {
53 | await pool.end();
54 | }
55 |
56 | // Health check
57 | export async function healthCheck(): Promise {
58 | try {
59 | await query('SELECT 1');
60 | return true;
61 | } catch (error) {
62 | console.error('Health check failed:', error);
63 | return false;
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/src/lib/db/vector.ts:
--------------------------------------------------------------------------------
1 | import { query } from './pg';
2 | import { openai } from '@ai-sdk/openai';
3 | import { embed, embedMany } from 'ai';
4 |
5 | import {
6 | DB_CONFIG,
7 | ChunkMetadata,
8 | VectorTableConfig,
9 | VectorDBConfig,
10 | } from './config';
11 |
12 | type ChunkingMethod = 'sentence' | 'paragraph' | 'fixed';
13 |
14 | interface VectorDBConfigType {
15 | embedding: {
16 | model: string;
17 | dimensions: number;
18 | distance: 'cosine' | 'euclidean' | 'inner_product';
19 | };
20 | chunking: {
21 | defaultMethod: ChunkingMethod;
22 | fixedSize: number;
23 | };
24 | search: {
25 | defaultLimit: number;
26 | reranking: boolean;
27 | };
28 | }
29 |
30 | export class VectorDB {
31 | private embeddingModel;
32 | private tableConfig: VectorTableConfig;
33 | private config: VectorDBConfigType;
34 |
35 | constructor(tableConfig: VectorTableConfig, config?: VectorDBConfig) {
36 | this.tableConfig = tableConfig;
37 | this.config = {
38 | embedding: {
39 | ...DB_CONFIG.embedding,
40 | ...config?.embedding,
41 | },
42 | chunking: {
43 | ...DB_CONFIG.chunking,
44 | ...config?.chunking,
45 | },
46 | search: {
47 | ...DB_CONFIG.search,
48 | ...config?.search,
49 | },
50 | };
51 |
52 | this.embeddingModel = openai.embedding(this.config.embedding.model);
53 | }
54 |
55 | /**
56 | *
57 | * Adds chunks to the database with their embeddings
58 | */
59 | async addChunks(chunks: string[], metadata?: Partial) {
60 | try {
61 | const { embeddings } = await embedMany({
62 | model: this.embeddingModel,
63 | values: chunks,
64 | });
65 |
66 | const baseMetadata: ChunkMetadata = {
67 | date: new Date().toISOString(),
68 | embeddingModel: this.config.embedding.model,
69 | chunkingMethod: this.config.chunking.defaultMethod,
70 | chunkIndex: 0,
71 | totalChunks: chunks.length,
72 | ...metadata,
73 | };
74 |
75 | for (let i = 0; i < chunks.length; i++) {
76 | await query(
77 | `INSERT INTO ${this.tableConfig.tableName} (
78 | "${this.tableConfig.columns.content}",
79 | "${this.tableConfig.columns.vector}",
80 | "${this.tableConfig.columns.metadata}"
81 | )
82 | VALUES ($1, $2::vector, $3)`,
83 | [
84 | chunks[i],
85 | JSON.stringify(embeddings[i]),
86 | JSON.stringify({ ...baseMetadata, chunkIndex: i }),
87 | ]
88 | );
89 | }
90 |
91 | return { count: chunks.length };
92 | } catch (error) {
93 | console.error('Error in addChunks:', error);
94 | throw error;
95 | }
96 | }
97 |
98 | /**
99 | * Searches for similar chunks using different distance metrics
100 | */
101 | async searchSimilar(
102 | searchQuery: string,
103 | options?: {
104 | limit?: number;
105 | distance?: typeof DB_CONFIG.embedding.distance;
106 | filter?: Record;
107 | select?: string[];
108 | }
109 | ) {
110 | const { embedding } = await embed({
111 | model: this.embeddingModel,
112 | value: searchQuery,
113 | });
114 |
115 | const distanceOp = {
116 | cosine: '<=>',
117 | euclidean: '<->',
118 | inner_product: '<#>',
119 | }[options?.distance || this.config.embedding.distance];
120 |
121 | const columns = this.tableConfig.columns;
122 | const selectColumns =
123 | options?.select?.map((col) => `"${col}"`) ||
124 | [columns.content, columns.metadata, columns.createdAt]
125 | .filter(Boolean)
126 | .map((col) => `"${col}"`);
127 |
128 | let filterClause = '';
129 | if (options?.filter && columns.metadata) {
130 | filterClause =
131 | 'WHERE ' +
132 | Object.entries(options.filter)
133 | .map(
134 | ([key, value]) => `"${columns.metadata}"->>'${key}' = '${value}'`
135 | )
136 | .join(' AND ');
137 | }
138 |
139 | const { rows } = await query(
140 | `SELECT
141 | ${selectColumns.join(', ')},
142 | "${columns.vector}" ${distanceOp} $1::vector AS distance
143 | FROM "${this.tableConfig.tableName}"
144 | ${filterClause}
145 | ORDER BY distance ASC
146 | LIMIT $2`,
147 | [
148 | JSON.stringify(embedding),
149 | options?.limit || this.config.search.defaultLimit,
150 | ]
151 | );
152 |
153 | return rows;
154 | }
155 |
156 | /**
157 | * Utility function to chunk text
158 | */
159 | chunkText(
160 | text: string,
161 | method = this.config.chunking.defaultMethod
162 | ): string[] {
163 | switch (method) {
164 | case 'sentence':
165 | return text
166 | .trim()
167 | .split('.')
168 | .filter(Boolean)
169 | .map((s) => s.trim());
170 | case 'paragraph':
171 | return text
172 | .trim()
173 | .split('\n\n')
174 | .filter(Boolean)
175 | .map((p) => p.trim());
176 | case 'fixed':
177 | const chunks: string[] = [];
178 | const words = text.split(' ');
179 | let currentChunk = '';
180 |
181 | for (const word of words) {
182 | if (
183 | currentChunk.length + word.length >
184 | this.config.chunking.fixedSize
185 | ) {
186 | chunks.push(currentChunk.trim());
187 | currentChunk = word;
188 | } else {
189 | currentChunk += ' ' + word;
190 | }
191 | }
192 | if (currentChunk) chunks.push(currentChunk.trim());
193 | return chunks;
194 | }
195 | return [];
196 | }
197 |
198 | /**
199 | * Adds text by first chunking it
200 | */
201 | async addText(
202 | text: string,
203 | options?: {
204 | chunkingMethod?: ChunkingMethod;
205 | metadata?: Partial;
206 | }
207 | ) {
208 | const chunks = this.chunkText(text, options?.chunkingMethod);
209 | return this.addChunks(chunks, {
210 | ...options?.metadata,
211 | sourceText: text.slice(0, 100) + '...',
212 | chunkingMethod:
213 | options?.chunkingMethod || this.config.chunking.defaultMethod,
214 | });
215 | }
216 |
217 | async select(
218 | options: {
219 | limit?: number;
220 | filter?: Record;
221 | orderBy?: string;
222 | order?: 'ASC' | 'DESC';
223 | } = {}
224 | ) {
225 | const limit = options.limit || 10;
226 | const orderBy = options.orderBy
227 | ? `ORDER BY ${options.orderBy} ${options.order || 'ASC'}`
228 | : '';
229 |
230 | const { rows } = await query(
231 | `SELECT ${Object.values(this.tableConfig.columns)
232 | .filter(Boolean)
233 | .map((col) => `"${col}"`)
234 | .join(', ')}
235 | FROM "${this.tableConfig.tableName}"
236 | ${orderBy}
237 | LIMIT $1`,
238 | [limit]
239 | );
240 |
241 | return rows;
242 | }
243 | }
244 |
245 | // Example instances for different tables
246 | export const oaiVectorDB = new VectorDB({
247 | tableName: 'oai',
248 | columns: {
249 | id: 'id',
250 | vector: 'embedding',
251 | content: 'chunk',
252 | metadata: 'metadata',
253 | createdAt: 'createdAt',
254 | updatedAt: 'updatedAt',
255 | },
256 | });
257 |
258 | export const itemsVectorDB = new VectorDB({
259 | tableName: 'items',
260 | columns: {
261 | id: 'id',
262 | vector: 'embedding',
263 | },
264 | });
265 |
--------------------------------------------------------------------------------
/src/lib/env.mjs:
--------------------------------------------------------------------------------
1 | // src/env.mjs
2 | import { createEnv } from '@t3-oss/env-nextjs';
3 | import { z } from 'zod';
4 |
5 | export const env = createEnv({
6 | /*
7 | * Serverside Environment variables, not available on the client.
8 | * Will throw if you access these variables on the client.
9 | */
10 | server: {
11 | NODE_ENV: z
12 | .enum(['development', 'test', 'preview', 'production'])
13 | .default('development'),
14 | DATABASE_URL: z.string().url(),
15 | // DATABASE_URL: z.string().url(),
16 | // OPENAI_API_KEY: z.string().min(1),
17 | },
18 | /*
19 | * Environment variables available on the client (and server).
20 | *
21 | * 💡 You'll get type errors if these are not prefixed with NEXT_PUBLIC_.
22 | */
23 | client: {
24 | // NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string().min(1),
25 | },
26 | /*
27 | * Due to how Next.js bundles environment variables on Edge and Client,
28 | * we need to manually destructure them to make sure all are included in bundle.
29 | *
30 | * 💡 You'll get type errors if not all variables from `server` & `client` are included here.
31 | */
32 | runtimeEnv: {
33 | NODE_ENV: process.env.NODE_ENV,
34 | DATABASE_URL: process.env.DATABASE_URL,
35 | // DATABASE_URL: process.env.DATABASE_URL,
36 | // OPEN_AI_API_KEY: process.env.OPEN_AI_API_KEY,
37 | // NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:
38 | // process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
39 | },
40 | });
41 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from 'tailwindcss';
2 |
3 | export default {
4 | darkMode: ['class'],
5 | content: [
6 | './src/pages/**/*.{js,ts,jsx,tsx,mdx}',
7 | './src/components/**/*.{js,ts,jsx,tsx,mdx}',
8 | './src/app/**/*.{js,ts,jsx,tsx,mdx}',
9 | ],
10 | theme: {
11 | extend: {
12 | colors: {
13 | background: 'hsl(var(--background))',
14 | foreground: 'hsl(var(--foreground))',
15 | card: {
16 | DEFAULT: 'hsl(var(--card))',
17 | foreground: 'hsl(var(--card-foreground))',
18 | },
19 | popover: {
20 | DEFAULT: 'hsl(var(--popover))',
21 | foreground: 'hsl(var(--popover-foreground))',
22 | },
23 | primary: {
24 | DEFAULT: 'hsl(var(--primary))',
25 | foreground: 'hsl(var(--primary-foreground))',
26 | },
27 | secondary: {
28 | DEFAULT: 'hsl(var(--secondary))',
29 | foreground: 'hsl(var(--secondary-foreground))',
30 | },
31 | muted: {
32 | DEFAULT: 'hsl(var(--muted))',
33 | foreground: 'hsl(var(--muted-foreground))',
34 | },
35 | accent: {
36 | DEFAULT: 'hsl(var(--accent))',
37 | foreground: 'hsl(var(--accent-foreground))',
38 | },
39 | destructive: {
40 | DEFAULT: 'hsl(var(--destructive))',
41 | foreground: 'hsl(var(--destructive-foreground))',
42 | },
43 | border: 'hsl(var(--border))',
44 | input: 'hsl(var(--input))',
45 | ring: 'hsl(var(--ring))',
46 | chart: {
47 | '1': 'hsl(var(--chart-1))',
48 | '2': 'hsl(var(--chart-2))',
49 | '3': 'hsl(var(--chart-3))',
50 | '4': 'hsl(var(--chart-4))',
51 | '5': 'hsl(var(--chart-5))',
52 | },
53 | },
54 | borderRadius: {
55 | lg: 'var(--radius)',
56 | md: 'calc(var(--radius) - 2px)',
57 | sm: 'calc(var(--radius) - 4px)',
58 | },
59 | },
60 | },
61 | plugins: [],
62 | } satisfies Config;
63 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./src/*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------