voyageai-cli 1.20.5 → 1.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +142 -26
- package/README.md +153 -2
- package/package.json +1 -1
- package/src/cli.js +12 -0
- package/src/commands/about.js +39 -3
- package/src/commands/doctor.js +325 -0
- package/src/commands/eval.js +420 -10
- package/src/commands/generate.js +220 -0
- package/src/commands/playground.js +93 -0
- package/src/commands/purge.js +271 -0
- package/src/commands/quickstart.js +203 -0
- package/src/commands/refresh.js +322 -0
- package/src/commands/scaffold.js +217 -0
- package/src/lib/codegen.js +313 -0
- package/src/lib/explanations.js +163 -1
- package/src/lib/scaffold-structure.js +114 -0
- package/src/lib/templates/nextjs/README.md.tpl +106 -0
- package/src/lib/templates/nextjs/env.example.tpl +8 -0
- package/src/lib/templates/nextjs/layout.jsx.tpl +29 -0
- package/src/lib/templates/nextjs/lib-mongo.js.tpl +111 -0
- package/src/lib/templates/nextjs/lib-voyage.js.tpl +103 -0
- package/src/lib/templates/nextjs/package.json.tpl +33 -0
- package/src/lib/templates/nextjs/page-search.jsx.tpl +147 -0
- package/src/lib/templates/nextjs/route-ingest.js.tpl +114 -0
- package/src/lib/templates/nextjs/route-search.js.tpl +97 -0
- package/src/lib/templates/nextjs/theme.js.tpl +84 -0
- package/src/lib/templates/python/README.md.tpl +145 -0
- package/src/lib/templates/python/app.py.tpl +221 -0
- package/src/lib/templates/python/chunker.py.tpl +127 -0
- package/src/lib/templates/python/env.example.tpl +12 -0
- package/src/lib/templates/python/mongo_client.py.tpl +125 -0
- package/src/lib/templates/python/requirements.txt.tpl +10 -0
- package/src/lib/templates/python/voyage_client.py.tpl +124 -0
- package/src/lib/templates/vanilla/README.md.tpl +156 -0
- package/src/lib/templates/vanilla/client.js.tpl +103 -0
- package/src/lib/templates/vanilla/connection.js.tpl +126 -0
- package/src/lib/templates/vanilla/env.example.tpl +11 -0
- package/src/lib/templates/vanilla/ingest.js.tpl +231 -0
- package/src/lib/templates/vanilla/package.json.tpl +31 -0
- package/src/lib/templates/vanilla/retrieval.js.tpl +100 -0
- package/src/lib/templates/vanilla/search-api.js.tpl +175 -0
- package/src/lib/templates/vanilla/server.js.tpl +81 -0
- package/src/lib/zip.js +130 -0
- package/src/playground/index.html +772 -33
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# {{projectName}}
|
|
2
|
+
|
|
3
|
+
A semantic search application powered by Voyage AI embeddings, MongoDB Atlas Vector Search, and Next.js with Material UI.
|
|
4
|
+
|
|
5
|
+
## Configuration
|
|
6
|
+
|
|
7
|
+
| Setting | Value |
|
|
8
|
+
|---------|-------|
|
|
9
|
+
| Embedding Model | `{{model}}` |
|
|
10
|
+
| Dimensions | {{dimensions}} |
|
|
11
|
+
| Database | `{{db}}` |
|
|
12
|
+
| Collection | `{{collection}}` |
|
|
13
|
+
| Vector Index | `{{index}}` |
|
|
14
|
+
{{#if rerank}}
|
|
15
|
+
| Rerank Model | `{{rerankModel}}` |
|
|
16
|
+
{{/if}}
|
|
17
|
+
|
|
18
|
+
## Setup
|
|
19
|
+
|
|
20
|
+
### 1. Install dependencies
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### 2. Configure environment
|
|
27
|
+
|
|
28
|
+
Copy `.env.example` to `.env.local` and fill in your credentials:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
cp .env.example .env.local
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Required variables:
|
|
35
|
+
- `VOYAGE_API_KEY` - Your Voyage AI API key from [dash.voyageai.com](https://dash.voyageai.com)
|
|
36
|
+
- `MONGODB_URI` - Your MongoDB Atlas connection string
|
|
37
|
+
|
|
38
|
+
### 3. Create vector index
|
|
39
|
+
|
|
40
|
+
In MongoDB Atlas, create a vector search index on your collection:
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{
|
|
44
|
+
"fields": [
|
|
45
|
+
{
|
|
46
|
+
"type": "vector",
|
|
47
|
+
"path": "{{field}}",
|
|
48
|
+
"numDimensions": {{dimensions}},
|
|
49
|
+
"similarity": "cosine"
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Name the index `{{index}}`.
|
|
56
|
+
|
|
57
|
+
### 4. Start the development server
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
npm run dev
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Open [http://localhost:3000](http://localhost:3000) to see the search interface.
|
|
64
|
+
|
|
65
|
+
## Project Structure
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
{{projectName}}/
|
|
69
|
+
├── app/
|
|
70
|
+
│ ├── layout.jsx # Root layout with MUI theme
|
|
71
|
+
│ ├── page.jsx # Home page
|
|
72
|
+
│ ├── search/
|
|
73
|
+
│ │ └── page.jsx # Search page (MUI)
|
|
74
|
+
│ └── api/
|
|
75
|
+
│ ├── search/route.js # Search endpoint
|
|
76
|
+
│ └── ingest/route.js # Ingest endpoint
|
|
77
|
+
├── lib/
|
|
78
|
+
│ ├── voyage.js # Voyage AI client
|
|
79
|
+
│ ├── mongodb.js # MongoDB connection
|
|
80
|
+
│ └── theme.js # MUI theme
|
|
81
|
+
├── .env.example
|
|
82
|
+
├── package.json
|
|
83
|
+
└── README.md
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## API Endpoints
|
|
87
|
+
|
|
88
|
+
### POST /api/search
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
curl -X POST http://localhost:3000/api/search \
|
|
92
|
+
-H "Content-Type: application/json" \
|
|
93
|
+
-d '{"query": "How does vector search work?", "limit": 5}'
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### POST /api/ingest
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
curl -X POST http://localhost:3000/api/ingest \
|
|
100
|
+
-H "Content-Type: application/json" \
|
|
101
|
+
-d '{"text": "Your document content...", "metadata": {"source": "api"}}'
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
Generated by [vai](https://github.com/mrlynn/voyageai-cli) v{{vaiVersion}}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Root Layout with MUI Theme
|
|
3
|
+
* Generated by vai v{{vaiVersion}} on {{generatedAt}}
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { AppRouterCacheProvider } from '@mui/material-nextjs/v14-appRouter';
|
|
7
|
+
import { ThemeProvider } from '@mui/material/styles';
|
|
8
|
+
import CssBaseline from '@mui/material/CssBaseline';
|
|
9
|
+
import { theme } from '@/lib/theme';
|
|
10
|
+
|
|
11
|
+
export const metadata = {
|
|
12
|
+
title: '{{projectName}}',
|
|
13
|
+
description: 'Semantic search powered by Voyage AI',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export default function RootLayout({ children }) {
|
|
17
|
+
return (
|
|
18
|
+
<html lang="en">
|
|
19
|
+
<body>
|
|
20
|
+
<AppRouterCacheProvider>
|
|
21
|
+
<ThemeProvider theme={theme}>
|
|
22
|
+
<CssBaseline />
|
|
23
|
+
{children}
|
|
24
|
+
</ThemeProvider>
|
|
25
|
+
</AppRouterCacheProvider>
|
|
26
|
+
</body>
|
|
27
|
+
</html>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MongoDB Connection Helper (Next.js)
|
|
3
|
+
* Generated by vai v{{vaiVersion}} on {{generatedAt}}
|
|
4
|
+
*
|
|
5
|
+
* Uses connection caching for serverless environments.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { MongoClient } from 'mongodb';
|
|
9
|
+
|
|
10
|
+
const MONGODB_URI = process.env.MONGODB_URI;
|
|
11
|
+
|
|
12
|
+
if (!MONGODB_URI) {
|
|
13
|
+
throw new Error('MONGODB_URI environment variable is required');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Cache the client promise for connection reuse in serverless
|
|
17
|
+
let cached = global.mongo;
|
|
18
|
+
|
|
19
|
+
if (!cached) {
|
|
20
|
+
cached = global.mongo = { conn: null, promise: null };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get a cached MongoDB client connection.
|
|
25
|
+
* Reuses existing connection in serverless environment.
|
|
26
|
+
*/
|
|
27
|
+
export async function getMongoClient() {
|
|
28
|
+
if (cached.conn) {
|
|
29
|
+
return cached.conn;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!cached.promise) {
|
|
33
|
+
cached.promise = MongoClient.connect(MONGODB_URI);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
cached.conn = await cached.promise;
|
|
37
|
+
return cached.conn;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get the database instance.
|
|
42
|
+
*/
|
|
43
|
+
export async function getDb() {
|
|
44
|
+
const client = await getMongoClient();
|
|
45
|
+
return client.db('{{db}}');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Get the documents collection.
|
|
50
|
+
*/
|
|
51
|
+
export async function getCollection() {
|
|
52
|
+
const db = await getDb();
|
|
53
|
+
return db.collection('{{collection}}');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Perform a vector search.
|
|
58
|
+
*/
|
|
59
|
+
export async function vectorSearch(embedding, options = {}) {
|
|
60
|
+
const collection = await getCollection();
|
|
61
|
+
const limit = options.limit || 10;
|
|
62
|
+
const numCandidates = options.numCandidates || limit * 10;
|
|
63
|
+
|
|
64
|
+
const pipeline = [
|
|
65
|
+
{
|
|
66
|
+
$vectorSearch: {
|
|
67
|
+
index: '{{index}}',
|
|
68
|
+
path: '{{field}}',
|
|
69
|
+
queryVector: embedding,
|
|
70
|
+
numCandidates,
|
|
71
|
+
limit,
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
$project: {
|
|
76
|
+
_id: 1,
|
|
77
|
+
text: 1,
|
|
78
|
+
metadata: 1,
|
|
79
|
+
score: { $meta: 'vectorSearchScore' },
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
if (options.filter) {
|
|
85
|
+
pipeline[0].$vectorSearch.filter = options.filter;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const results = await collection.aggregate(pipeline).toArray();
|
|
89
|
+
return results.map(doc => ({
|
|
90
|
+
document: doc,
|
|
91
|
+
score: doc.score,
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Insert documents with embeddings.
|
|
97
|
+
*/
|
|
98
|
+
export async function insertDocuments(docs) {
|
|
99
|
+
const collection = await getCollection();
|
|
100
|
+
|
|
101
|
+
const documents = docs.map(doc => ({
|
|
102
|
+
text: doc.text,
|
|
103
|
+
{{field}}: doc.embedding,
|
|
104
|
+
metadata: doc.metadata || {},
|
|
105
|
+
_embeddedAt: new Date(),
|
|
106
|
+
_model: '{{model}}',
|
|
107
|
+
}));
|
|
108
|
+
|
|
109
|
+
const result = await collection.insertMany(documents);
|
|
110
|
+
return { insertedCount: result.insertedCount };
|
|
111
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Voyage AI API Client (Next.js)
|
|
3
|
+
* Generated by vai v{{vaiVersion}} on {{generatedAt}}
|
|
4
|
+
*
|
|
5
|
+
* Model: {{model}}
|
|
6
|
+
* Dimensions: {{dimensions}}
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const VOYAGE_API_URL = process.env.VOYAGE_API_URL || 'https://api.voyageai.com/v1';
|
|
10
|
+
const VOYAGE_API_KEY = process.env.VOYAGE_API_KEY;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Generate embeddings for text(s) using Voyage AI.
|
|
14
|
+
*/
|
|
15
|
+
export async function embed(input, options = {}) {
|
|
16
|
+
if (!VOYAGE_API_KEY) {
|
|
17
|
+
throw new Error('VOYAGE_API_KEY environment variable is required');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const texts = Array.isArray(input) ? input : [input];
|
|
21
|
+
|
|
22
|
+
const response = await fetch(`${VOYAGE_API_URL}/embeddings`, {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
headers: {
|
|
25
|
+
'Content-Type': 'application/json',
|
|
26
|
+
'Authorization': `Bearer ${VOYAGE_API_KEY}`,
|
|
27
|
+
},
|
|
28
|
+
body: JSON.stringify({
|
|
29
|
+
model: options.model || '{{model}}',
|
|
30
|
+
input: texts,
|
|
31
|
+
input_type: options.inputType || '{{inputType}}',
|
|
32
|
+
output_dimension: options.outputDimension || {{dimensions}},
|
|
33
|
+
}),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
if (!response.ok) {
|
|
37
|
+
const error = await response.text();
|
|
38
|
+
throw new Error(`Voyage AI API error: ${response.status} ${error}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const data = await response.json();
|
|
42
|
+
return {
|
|
43
|
+
embeddings: data.data.map(d => d.embedding),
|
|
44
|
+
usage: data.usage,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Embed a single query.
|
|
50
|
+
*/
|
|
51
|
+
export async function embedQuery(query, options = {}) {
|
|
52
|
+
const result = await embed(query, { ...options, inputType: 'query' });
|
|
53
|
+
return result.embeddings[0];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Embed multiple documents.
|
|
58
|
+
*/
|
|
59
|
+
export async function embedDocuments(documents, options = {}) {
|
|
60
|
+
const result = await embed(documents, { ...options, inputType: 'document' });
|
|
61
|
+
return result.embeddings;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
{{#if rerank}}
|
|
65
|
+
/**
|
|
66
|
+
* Rerank documents by relevance to a query.
|
|
67
|
+
*/
|
|
68
|
+
export async function rerank(query, documents, options = {}) {
|
|
69
|
+
if (!VOYAGE_API_KEY) {
|
|
70
|
+
throw new Error('VOYAGE_API_KEY environment variable is required');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const response = await fetch(`${VOYAGE_API_URL}/rerank`, {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: {
|
|
76
|
+
'Content-Type': 'application/json',
|
|
77
|
+
'Authorization': `Bearer ${VOYAGE_API_KEY}`,
|
|
78
|
+
},
|
|
79
|
+
body: JSON.stringify({
|
|
80
|
+
model: options.model || '{{rerankModel}}',
|
|
81
|
+
query,
|
|
82
|
+
documents,
|
|
83
|
+
top_k: options.topK,
|
|
84
|
+
return_documents: true,
|
|
85
|
+
}),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (!response.ok) {
|
|
89
|
+
const error = await response.text();
|
|
90
|
+
throw new Error(`Voyage AI rerank error: ${response.status} ${error}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const data = await response.json();
|
|
94
|
+
return {
|
|
95
|
+
results: data.data.map(d => ({
|
|
96
|
+
index: d.index,
|
|
97
|
+
relevanceScore: d.relevance_score,
|
|
98
|
+
document: d.document,
|
|
99
|
+
})),
|
|
100
|
+
usage: data.usage,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
{{/if}}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{projectName}}",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "next dev",
|
|
7
|
+
"build": "next build",
|
|
8
|
+
"start": "next start",
|
|
9
|
+
"lint": "next lint"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@emotion/cache": "^11.11.0",
|
|
13
|
+
"@emotion/react": "^11.11.3",
|
|
14
|
+
"@emotion/styled": "^11.11.0",
|
|
15
|
+
"@mui/icons-material": "^5.15.0",
|
|
16
|
+
"@mui/material": "^5.15.0",
|
|
17
|
+
"@mui/material-nextjs": "^5.15.0",
|
|
18
|
+
"mongodb": "^6.3.0",
|
|
19
|
+
"next": "^14.1.0",
|
|
20
|
+
"react": "^18.2.0",
|
|
21
|
+
"react-dom": "^18.2.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"eslint": "^8.56.0",
|
|
25
|
+
"eslint-config-next": "^14.1.0"
|
|
26
|
+
},
|
|
27
|
+
"generated": {
|
|
28
|
+
"by": "vai",
|
|
29
|
+
"version": "{{vaiVersion}}",
|
|
30
|
+
"model": "{{model}}",
|
|
31
|
+
"at": "{{generatedAt}}"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search Page (MUI)
|
|
3
|
+
* Generated by vai v{{vaiVersion}} on {{generatedAt}}
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use client';
|
|
7
|
+
|
|
8
|
+
import { useState } from 'react';
|
|
9
|
+
import {
|
|
10
|
+
Box,
|
|
11
|
+
Container,
|
|
12
|
+
TextField,
|
|
13
|
+
Button,
|
|
14
|
+
Card,
|
|
15
|
+
CardContent,
|
|
16
|
+
Typography,
|
|
17
|
+
List,
|
|
18
|
+
ListItem,
|
|
19
|
+
Chip,
|
|
20
|
+
CircularProgress,
|
|
21
|
+
Alert,
|
|
22
|
+
InputAdornment,
|
|
23
|
+
} from '@mui/material';
|
|
24
|
+
import SearchIcon from '@mui/icons-material/Search';
|
|
25
|
+
|
|
26
|
+
export default function SearchPage() {
|
|
27
|
+
const [query, setQuery] = useState('');
|
|
28
|
+
const [results, setResults] = useState([]);
|
|
29
|
+
const [loading, setLoading] = useState(false);
|
|
30
|
+
const [error, setError] = useState(null);
|
|
31
|
+
const [meta, setMeta] = useState(null);
|
|
32
|
+
|
|
33
|
+
const handleSearch = async (e) => {
|
|
34
|
+
e.preventDefault();
|
|
35
|
+
if (!query.trim()) return;
|
|
36
|
+
|
|
37
|
+
setLoading(true);
|
|
38
|
+
setError(null);
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const response = await fetch('/api/search', {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
headers: { 'Content-Type': 'application/json' },
|
|
44
|
+
body: JSON.stringify({ query, limit: 5 }),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (!response.ok) {
|
|
48
|
+
const data = await response.json();
|
|
49
|
+
throw new Error(data.error || 'Search failed');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const data = await response.json();
|
|
53
|
+
setResults(data.results);
|
|
54
|
+
setMeta(data.meta);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
setError(err.message);
|
|
57
|
+
setResults([]);
|
|
58
|
+
} finally {
|
|
59
|
+
setLoading(false);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<Container maxWidth="md" sx={{ py: 4 }}>
|
|
65
|
+
<Typography variant="h4" component="h1" gutterBottom>
|
|
66
|
+
Semantic Search
|
|
67
|
+
</Typography>
|
|
68
|
+
|
|
69
|
+
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
|
70
|
+
Search your documents using {{model}} embeddings
|
|
71
|
+
{{#if rerank}} with {{rerankModel}} reranking{{/if}}.
|
|
72
|
+
</Typography>
|
|
73
|
+
|
|
74
|
+
<Box component="form" onSubmit={handleSearch} sx={{ mb: 4 }}>
|
|
75
|
+
<TextField
|
|
76
|
+
fullWidth
|
|
77
|
+
value={query}
|
|
78
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
79
|
+
placeholder="Enter your search query..."
|
|
80
|
+
variant="outlined"
|
|
81
|
+
InputProps={{
|
|
82
|
+
startAdornment: (
|
|
83
|
+
<InputAdornment position="start">
|
|
84
|
+
<SearchIcon />
|
|
85
|
+
</InputAdornment>
|
|
86
|
+
),
|
|
87
|
+
endAdornment: (
|
|
88
|
+
<InputAdornment position="end">
|
|
89
|
+
<Button
|
|
90
|
+
type="submit"
|
|
91
|
+
variant="contained"
|
|
92
|
+
disabled={loading || !query.trim()}
|
|
93
|
+
>
|
|
94
|
+
{loading ? <CircularProgress size={24} /> : 'Search'}
|
|
95
|
+
</Button>
|
|
96
|
+
</InputAdornment>
|
|
97
|
+
),
|
|
98
|
+
}}
|
|
99
|
+
/>
|
|
100
|
+
</Box>
|
|
101
|
+
|
|
102
|
+
{error && (
|
|
103
|
+
<Alert severity="error" sx={{ mb: 3 }}>
|
|
104
|
+
{error}
|
|
105
|
+
</Alert>
|
|
106
|
+
)}
|
|
107
|
+
|
|
108
|
+
{meta && (
|
|
109
|
+
<Box sx={{ mb: 2, display: 'flex', gap: 1 }}>
|
|
110
|
+
<Chip label={`${results.length} results`} size="small" />
|
|
111
|
+
<Chip label={`${meta.took}ms`} size="small" variant="outlined" />
|
|
112
|
+
<Chip label={meta.model} size="small" variant="outlined" />
|
|
113
|
+
</Box>
|
|
114
|
+
)}
|
|
115
|
+
|
|
116
|
+
<List>
|
|
117
|
+
{results.map((result, index) => (
|
|
118
|
+
<ListItem key={index} sx={{ px: 0 }}>
|
|
119
|
+
<Card sx={{ width: '100%' }}>
|
|
120
|
+
<CardContent>
|
|
121
|
+
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
|
122
|
+
<Typography variant="subtitle2" color="text.secondary">
|
|
123
|
+
{result.metadata?.source || 'Document'}
|
|
124
|
+
</Typography>
|
|
125
|
+
<Chip
|
|
126
|
+
label={`Score: ${result.score.toFixed(3)}`}
|
|
127
|
+
size="small"
|
|
128
|
+
color={result.score > 0.8 ? 'success' : 'default'}
|
|
129
|
+
/>
|
|
130
|
+
</Box>
|
|
131
|
+
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
|
|
132
|
+
{result.text}
|
|
133
|
+
</Typography>
|
|
134
|
+
</CardContent>
|
|
135
|
+
</Card>
|
|
136
|
+
</ListItem>
|
|
137
|
+
))}
|
|
138
|
+
</List>
|
|
139
|
+
|
|
140
|
+
{results.length === 0 && !loading && query && !error && (
|
|
141
|
+
<Typography color="text.secondary" align="center">
|
|
142
|
+
No results found for "{query}"
|
|
143
|
+
</Typography>
|
|
144
|
+
)}
|
|
145
|
+
</Container>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ingest API Route Handler
|
|
3
|
+
* Generated by vai v{{vaiVersion}} on {{generatedAt}}
|
|
4
|
+
*
|
|
5
|
+
* POST /api/ingest
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { NextResponse } from 'next/server';
|
|
9
|
+
import { embed } from '@/lib/voyage';
|
|
10
|
+
import { insertDocuments } from '@/lib/mongodb';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Chunk text using recursive splitting.
|
|
14
|
+
*/
|
|
15
|
+
function chunkText(text, size = {{chunkSize}}, overlap = {{chunkOverlap}}) {
|
|
16
|
+
const separators = ['\n\n', '\n', '. ', ' '];
|
|
17
|
+
|
|
18
|
+
function splitRecursive(text, sepIndex = 0) {
|
|
19
|
+
if (text.length <= size) {
|
|
20
|
+
const trimmed = text.trim();
|
|
21
|
+
return trimmed ? [trimmed] : [];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (sepIndex >= separators.length) {
|
|
25
|
+
const chunks = [];
|
|
26
|
+
let start = 0;
|
|
27
|
+
while (start < text.length) {
|
|
28
|
+
const chunk = text.slice(start, start + size).trim();
|
|
29
|
+
if (chunk) chunks.push(chunk);
|
|
30
|
+
start += size - overlap;
|
|
31
|
+
}
|
|
32
|
+
return chunks;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const separator = separators[sepIndex];
|
|
36
|
+
const parts = text.split(separator);
|
|
37
|
+
const chunks = [];
|
|
38
|
+
let current = '';
|
|
39
|
+
|
|
40
|
+
for (const part of parts) {
|
|
41
|
+
const potential = current ? current + separator + part : part;
|
|
42
|
+
if (potential.length <= size) {
|
|
43
|
+
current = potential;
|
|
44
|
+
} else {
|
|
45
|
+
if (current) chunks.push(...splitRecursive(current, sepIndex + 1));
|
|
46
|
+
current = part;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (current) chunks.push(...splitRecursive(current, sepIndex + 1));
|
|
51
|
+
return chunks;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return splitRecursive(text);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function POST(request) {
|
|
58
|
+
const start = Date.now();
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const body = await request.json();
|
|
62
|
+
const { text, metadata = {} } = body;
|
|
63
|
+
|
|
64
|
+
if (!text || typeof text !== 'string') {
|
|
65
|
+
return NextResponse.json(
|
|
66
|
+
{ error: 'Text is required' },
|
|
67
|
+
{ status: 400 }
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Chunk the text
|
|
72
|
+
const chunks = chunkText(text);
|
|
73
|
+
|
|
74
|
+
if (chunks.length === 0) {
|
|
75
|
+
return NextResponse.json({
|
|
76
|
+
success: true,
|
|
77
|
+
chunks: 0,
|
|
78
|
+
tokens: 0,
|
|
79
|
+
took: Date.now() - start,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Embed all chunks
|
|
84
|
+
const { embeddings, usage } = await embed(chunks, { inputType: 'document' });
|
|
85
|
+
|
|
86
|
+
// Prepare documents
|
|
87
|
+
const documents = chunks.map((chunk, i) => ({
|
|
88
|
+
text: chunk,
|
|
89
|
+
embedding: embeddings[i],
|
|
90
|
+
metadata: {
|
|
91
|
+
...metadata,
|
|
92
|
+
source: metadata.source || 'api',
|
|
93
|
+
chunkIndex: i,
|
|
94
|
+
totalChunks: chunks.length,
|
|
95
|
+
},
|
|
96
|
+
}));
|
|
97
|
+
|
|
98
|
+
// Insert into MongoDB
|
|
99
|
+
await insertDocuments(documents);
|
|
100
|
+
|
|
101
|
+
return NextResponse.json({
|
|
102
|
+
success: true,
|
|
103
|
+
chunks: chunks.length,
|
|
104
|
+
tokens: usage.total_tokens,
|
|
105
|
+
took: Date.now() - start,
|
|
106
|
+
});
|
|
107
|
+
} catch (error) {
|
|
108
|
+
console.error('Ingest error:', error);
|
|
109
|
+
return NextResponse.json(
|
|
110
|
+
{ error: error.message },
|
|
111
|
+
{ status: 500 }
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|