Initial commit
This commit is contained in:
254
templates/basic-search.ts
Normal file
254
templates/basic-search.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Basic Semantic Search with Cloudflare Vectorize + Workers AI
|
||||
*
|
||||
* Use case: Simple semantic search over documents, FAQs, or product catalog
|
||||
*
|
||||
* Features:
|
||||
* - Workers AI embeddings (@cf/baai/bge-base-en-v1.5)
|
||||
* - Vectorize query with topK results
|
||||
* - Metadata filtering
|
||||
* - Simple JSON API
|
||||
*/
|
||||
|
||||
export interface Env {
|
||||
VECTORIZE_INDEX: VectorizeIndex;
|
||||
AI: Ai;
|
||||
}
|
||||
|
||||
interface SearchRequest {
|
||||
query: string;
|
||||
topK?: number;
|
||||
filter?: Record<string, any>;
|
||||
namespace?: string;
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
score: number;
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
// Handle CORS preflight
|
||||
if (request.method === 'OPTIONS') {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Route: POST /search - Semantic search endpoint
|
||||
if (url.pathname === '/search' && request.method === 'POST') {
|
||||
try {
|
||||
const body = await request.json() as SearchRequest;
|
||||
const { query, topK = 5, filter, namespace } = body;
|
||||
|
||||
if (!query) {
|
||||
return Response.json(
|
||||
{ error: 'Missing required field: query' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Generate embedding for search query
|
||||
const queryEmbedding = await env.AI.run('@cf/baai/bge-base-en-v1.5', {
|
||||
text: query,
|
||||
});
|
||||
|
||||
// Search vector database
|
||||
const results = await env.VECTORIZE_INDEX.query(queryEmbedding.data[0], {
|
||||
topK,
|
||||
filter,
|
||||
namespace,
|
||||
returnMetadata: 'all',
|
||||
returnValues: false, // Save bandwidth
|
||||
});
|
||||
|
||||
// Format results
|
||||
const searchResults: SearchResult[] = results.matches.map((match) => ({
|
||||
id: match.id,
|
||||
score: match.score,
|
||||
metadata: match.metadata || {},
|
||||
}));
|
||||
|
||||
return Response.json({
|
||||
query,
|
||||
results: searchResults,
|
||||
count: results.count,
|
||||
}, {
|
||||
headers: { 'Access-Control-Allow-Origin': '*' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
return Response.json(
|
||||
{
|
||||
error: 'Search failed',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Route: POST /index - Add document to index
|
||||
if (url.pathname === '/index' && request.method === 'POST') {
|
||||
try {
|
||||
const body = await request.json() as {
|
||||
id: string;
|
||||
content: string;
|
||||
metadata?: Record<string, any>;
|
||||
namespace?: string;
|
||||
};
|
||||
|
||||
if (!body.id || !body.content) {
|
||||
return Response.json(
|
||||
{ error: 'Missing required fields: id, content' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Generate embedding for document
|
||||
const embedding = await env.AI.run('@cf/baai/bge-base-en-v1.5', {
|
||||
text: body.content,
|
||||
});
|
||||
|
||||
// Upsert vector (overwrites if exists)
|
||||
await env.VECTORIZE_INDEX.upsert([
|
||||
{
|
||||
id: body.id,
|
||||
values: embedding.data[0],
|
||||
namespace: body.namespace,
|
||||
metadata: {
|
||||
...body.metadata,
|
||||
content: body.content,
|
||||
indexed_at: Date.now(),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return Response.json({
|
||||
success: true,
|
||||
id: body.id,
|
||||
message: 'Document indexed successfully',
|
||||
}, {
|
||||
headers: { 'Access-Control-Allow-Origin': '*' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Index error:', error);
|
||||
return Response.json(
|
||||
{
|
||||
error: 'Indexing failed',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Route: DELETE /index/:id - Remove document from index
|
||||
if (url.pathname.startsWith('/index/') && request.method === 'DELETE') {
|
||||
try {
|
||||
const id = url.pathname.split('/')[2];
|
||||
|
||||
if (!id) {
|
||||
return Response.json(
|
||||
{ error: 'Missing document ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await env.VECTORIZE_INDEX.deleteByIds([id]);
|
||||
|
||||
return Response.json({
|
||||
success: true,
|
||||
id,
|
||||
message: 'Document removed from index',
|
||||
}, {
|
||||
headers: { 'Access-Control-Allow-Origin': '*' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Delete error:', error);
|
||||
return Response.json(
|
||||
{
|
||||
error: 'Delete failed',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Default: API documentation
|
||||
return Response.json({
|
||||
name: 'Vectorize Semantic Search API',
|
||||
endpoints: {
|
||||
'POST /search': {
|
||||
description: 'Semantic search over indexed documents',
|
||||
body: {
|
||||
query: 'string (required)',
|
||||
topK: 'number (optional, default: 5)',
|
||||
filter: 'object (optional)',
|
||||
namespace: 'string (optional)',
|
||||
},
|
||||
example: {
|
||||
query: 'How do I deploy a Worker?',
|
||||
topK: 3,
|
||||
filter: { category: 'documentation' },
|
||||
},
|
||||
},
|
||||
'POST /index': {
|
||||
description: 'Add or update document in index',
|
||||
body: {
|
||||
id: 'string (required)',
|
||||
content: 'string (required)',
|
||||
metadata: 'object (optional)',
|
||||
namespace: 'string (optional)',
|
||||
},
|
||||
example: {
|
||||
id: 'doc-123',
|
||||
content: 'Cloudflare Workers are serverless functions...',
|
||||
metadata: { category: 'documentation', author: 'Cloudflare' },
|
||||
},
|
||||
},
|
||||
'DELETE /index/:id': {
|
||||
description: 'Remove document from index',
|
||||
example: 'DELETE /index/doc-123',
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Example Usage:
|
||||
*
|
||||
* 1. Index a document:
|
||||
*
|
||||
* curl -X POST https://your-worker.workers.dev/index \
|
||||
* -H "Content-Type: application/json" \
|
||||
* -d '{
|
||||
* "id": "doc-1",
|
||||
* "content": "Cloudflare Workers allow you to deploy serverless code globally.",
|
||||
* "metadata": { "category": "docs", "section": "workers" }
|
||||
* }'
|
||||
*
|
||||
* 2. Search:
|
||||
*
|
||||
* curl -X POST https://your-worker.workers.dev/search \
|
||||
* -H "Content-Type: application/json" \
|
||||
* -d '{
|
||||
* "query": "How do I deploy serverless functions?",
|
||||
* "topK": 5,
|
||||
* "filter": { "category": "docs" }
|
||||
* }'
|
||||
*
|
||||
* 3. Delete:
|
||||
*
|
||||
* curl -X DELETE https://your-worker.workers.dev/index/doc-1
|
||||
*/
|
||||
414
templates/document-ingestion.ts
Normal file
414
templates/document-ingestion.ts
Normal file
@@ -0,0 +1,414 @@
|
||||
/**
|
||||
* Document Ingestion Pipeline for Cloudflare Vectorize
|
||||
*
|
||||
* Use case: Process large documents, chunk text, generate embeddings, and index
|
||||
*
|
||||
* Features:
|
||||
* - Intelligent text chunking (sentence-based)
|
||||
* - Batch embedding generation
|
||||
* - Metadata tagging (doc_id, chunk_index, timestamps)
|
||||
* - R2 integration for document storage (optional)
|
||||
* - Progress tracking and error handling
|
||||
*/
|
||||
|
||||
export interface Env {
|
||||
VECTORIZE_INDEX: VectorizeIndex;
|
||||
AI: Ai;
|
||||
DOCUMENTS_BUCKET?: R2Bucket; // Optional: Store original documents
|
||||
}
|
||||
|
||||
interface Document {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
url?: string;
|
||||
author?: string;
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
publishedAt?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface ChunkMetadata {
|
||||
doc_id: string;
|
||||
doc_title: string;
|
||||
chunk_index: number;
|
||||
total_chunks: number;
|
||||
content: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunk text into smaller segments while preserving sentence boundaries
|
||||
*/
|
||||
function chunkText(text: string, maxChunkSize = 500, overlapSize = 50): string[] {
|
||||
// Split into sentences (handles . ! ? with spaces)
|
||||
const sentences = text.match(/[^.!?]+[.!?]+(?:\s|$)/g) || [text];
|
||||
const chunks: string[] = [];
|
||||
let currentChunk = '';
|
||||
|
||||
for (let i = 0; i < sentences.length; i++) {
|
||||
const sentence = sentences[i].trim();
|
||||
|
||||
// If adding this sentence exceeds max size and we have content, start new chunk
|
||||
if ((currentChunk + ' ' + sentence).length > maxChunkSize && currentChunk) {
|
||||
chunks.push(currentChunk.trim());
|
||||
|
||||
// Create overlap by including last few words
|
||||
const words = currentChunk.split(' ');
|
||||
const overlapWords = words.slice(-Math.floor(overlapSize / 6)); // ~6 chars/word
|
||||
currentChunk = overlapWords.join(' ') + ' ' + sentence;
|
||||
} else {
|
||||
currentChunk += (currentChunk ? ' ' : '') + sentence;
|
||||
}
|
||||
}
|
||||
|
||||
// Add final chunk
|
||||
if (currentChunk.trim()) {
|
||||
chunks.push(currentChunk.trim());
|
||||
}
|
||||
|
||||
return chunks.length > 0 ? chunks : [text];
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch array into smaller arrays of specified size
|
||||
*/
|
||||
function batchArray<T>(array: T[], batchSize: number): T[][] {
|
||||
const batches: T[][] = [];
|
||||
for (let i = 0; i < array.length; i += batchSize) {
|
||||
batches.push(array.slice(i, i + batchSize));
|
||||
}
|
||||
return batches;
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
// Handle CORS
|
||||
if (request.method === 'OPTIONS') {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Route: POST /ingest - Process and index document(s)
|
||||
if (url.pathname === '/ingest' && request.method === 'POST') {
|
||||
try {
|
||||
const body = await request.json() as {
|
||||
documents: Document[];
|
||||
chunkSize?: number;
|
||||
overlapSize?: number;
|
||||
namespace?: string;
|
||||
storeInR2?: boolean;
|
||||
};
|
||||
|
||||
const {
|
||||
documents,
|
||||
chunkSize = 500,
|
||||
overlapSize = 50,
|
||||
namespace,
|
||||
storeInR2 = false,
|
||||
} = body;
|
||||
|
||||
if (!documents || !Array.isArray(documents) || documents.length === 0) {
|
||||
return Response.json(
|
||||
{ error: 'Missing or invalid field: documents (non-empty array)' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const results = {
|
||||
success: true,
|
||||
processed: 0,
|
||||
totalChunks: 0,
|
||||
errors: [] as string[],
|
||||
documentDetails: [] as any[],
|
||||
};
|
||||
|
||||
// Process each document
|
||||
for (const doc of documents) {
|
||||
try {
|
||||
if (!doc.id || !doc.content) {
|
||||
results.errors.push(`Document missing id or content: ${JSON.stringify(doc)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Optional: Store original document in R2
|
||||
if (storeInR2 && env.DOCUMENTS_BUCKET) {
|
||||
await env.DOCUMENTS_BUCKET.put(
|
||||
`documents/${doc.id}.json`,
|
||||
JSON.stringify(doc),
|
||||
{
|
||||
httpMetadata: { contentType: 'application/json' },
|
||||
customMetadata: { title: doc.title, indexed_at: Date.now().toString() },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Chunk the document
|
||||
const chunks = chunkText(doc.content, chunkSize, overlapSize);
|
||||
|
||||
// Generate embeddings for all chunks (batch)
|
||||
const embeddings = await env.AI.run('@cf/baai/bge-base-en-v1.5', {
|
||||
text: chunks,
|
||||
});
|
||||
|
||||
// Prepare vectors with metadata
|
||||
const vectors = chunks.map((chunk, index) => ({
|
||||
id: `${doc.id}-chunk-${index}`,
|
||||
values: embeddings.data[index],
|
||||
namespace,
|
||||
metadata: {
|
||||
doc_id: doc.id,
|
||||
doc_title: doc.title,
|
||||
chunk_index: index,
|
||||
total_chunks: chunks.length,
|
||||
content: chunk,
|
||||
url: doc.url,
|
||||
author: doc.author,
|
||||
category: doc.category,
|
||||
tags: doc.tags,
|
||||
published_at: doc.publishedAt,
|
||||
indexed_at: Date.now(),
|
||||
} as ChunkMetadata,
|
||||
}));
|
||||
|
||||
// Upsert in batches (100 vectors at a time)
|
||||
const vectorBatches = batchArray(vectors, 100);
|
||||
for (const batch of vectorBatches) {
|
||||
await env.VECTORIZE_INDEX.upsert(batch);
|
||||
}
|
||||
|
||||
results.processed++;
|
||||
results.totalChunks += chunks.length;
|
||||
results.documentDetails.push({
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
chunks: chunks.length,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to process document ${doc.id}: ${
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
}`;
|
||||
console.error(errorMsg);
|
||||
results.errors.push(errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
const statusCode = results.errors.length > 0 ? 207 : 200; // 207 Multi-Status
|
||||
|
||||
return Response.json(results, {
|
||||
status: statusCode,
|
||||
headers: { 'Access-Control-Allow-Origin': '*' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Ingest error:', error);
|
||||
return Response.json(
|
||||
{
|
||||
error: 'Ingestion failed',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Route: POST /ingest/url - Fetch and ingest from URL (requires Firecrawl or similar)
|
||||
if (url.pathname === '/ingest/url' && request.method === 'POST') {
|
||||
try {
|
||||
const body = await request.json() as {
|
||||
url: string;
|
||||
id?: string;
|
||||
category?: string;
|
||||
namespace?: string;
|
||||
};
|
||||
|
||||
if (!body.url) {
|
||||
return Response.json({ error: 'Missing required field: url' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Fetch content (simple fetch - for production use Firecrawl or similar)
|
||||
const response = await fetch(body.url);
|
||||
const html = await response.text();
|
||||
|
||||
// Simple text extraction (production would use proper HTML parsing)
|
||||
const text = html
|
||||
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
// Create document from fetched content
|
||||
const doc: Document = {
|
||||
id: body.id || `url-${Date.now()}`,
|
||||
title: body.url,
|
||||
content: text,
|
||||
url: body.url,
|
||||
category: body.category || 'web-page',
|
||||
publishedAt: Date.now(),
|
||||
};
|
||||
|
||||
// Re-use the /ingest logic
|
||||
const ingestResponse = await this.fetch(
|
||||
new Request(new URL('/ingest', request.url), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
documents: [doc],
|
||||
namespace: body.namespace,
|
||||
}),
|
||||
}),
|
||||
env,
|
||||
ctx
|
||||
);
|
||||
|
||||
return ingestResponse;
|
||||
} catch (error) {
|
||||
console.error('URL ingest error:', error);
|
||||
return Response.json(
|
||||
{
|
||||
error: 'URL ingestion failed',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Route: DELETE /documents/:id - Delete all chunks for a document
|
||||
if (url.pathname.startsWith('/documents/') && request.method === 'DELETE') {
|
||||
try {
|
||||
const docId = url.pathname.split('/')[2];
|
||||
|
||||
if (!docId) {
|
||||
return Response.json({ error: 'Missing document ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
// List all vector IDs (need to find chunks for this doc)
|
||||
// Note: This is inefficient for large indexes. Better to maintain a separate index of doc -> chunk mappings
|
||||
const allVectors = await env.VECTORIZE_INDEX.listVectors({ limit: 1000 });
|
||||
|
||||
const chunkIds = allVectors.vectors
|
||||
.filter((v) => v.id.startsWith(`${docId}-chunk-`))
|
||||
.map((v) => v.id);
|
||||
|
||||
if (chunkIds.length === 0) {
|
||||
return Response.json(
|
||||
{ error: 'Document not found', id: docId },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Delete in batches
|
||||
const idBatches = batchArray(chunkIds, 100);
|
||||
for (const batch of idBatches) {
|
||||
await env.VECTORIZE_INDEX.deleteByIds(batch);
|
||||
}
|
||||
|
||||
// Optional: Delete from R2 if exists
|
||||
if (env.DOCUMENTS_BUCKET) {
|
||||
await env.DOCUMENTS_BUCKET.delete(`documents/${docId}.json`);
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
success: true,
|
||||
id: docId,
|
||||
chunksDeleted: chunkIds.length,
|
||||
}, {
|
||||
headers: { 'Access-Control-Allow-Origin': '*' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Delete error:', error);
|
||||
return Response.json(
|
||||
{
|
||||
error: 'Delete failed',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Default: API documentation
|
||||
return Response.json({
|
||||
name: 'Document Ingestion Pipeline API',
|
||||
endpoints: {
|
||||
'POST /ingest': {
|
||||
description: 'Process and index documents with chunking',
|
||||
body: {
|
||||
documents: [
|
||||
{
|
||||
id: 'string (required)',
|
||||
title: 'string (required)',
|
||||
content: 'string (required)',
|
||||
url: 'string (optional)',
|
||||
author: 'string (optional)',
|
||||
category: 'string (optional)',
|
||||
tags: ['array (optional)'],
|
||||
publishedAt: 'number (optional)',
|
||||
},
|
||||
],
|
||||
chunkSize: 'number (optional, default: 500)',
|
||||
overlapSize: 'number (optional, default: 50)',
|
||||
namespace: 'string (optional)',
|
||||
storeInR2: 'boolean (optional, default: false)',
|
||||
},
|
||||
},
|
||||
'POST /ingest/url': {
|
||||
description: 'Fetch and ingest document from URL',
|
||||
body: {
|
||||
url: 'string (required)',
|
||||
id: 'string (optional)',
|
||||
category: 'string (optional)',
|
||||
namespace: 'string (optional)',
|
||||
},
|
||||
},
|
||||
'DELETE /documents/:id': {
|
||||
description: 'Delete all chunks for a document',
|
||||
example: 'DELETE /documents/doc-123',
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Example Usage:
|
||||
*
|
||||
* 1. Ingest a single document:
|
||||
*
|
||||
* curl -X POST https://your-worker.workers.dev/ingest \
|
||||
* -H "Content-Type: application/json" \
|
||||
* -d '{
|
||||
* "documents": [{
|
||||
* "id": "cloudflare-workers-intro",
|
||||
* "title": "Introduction to Cloudflare Workers",
|
||||
* "content": "Very long document content here...",
|
||||
* "category": "documentation",
|
||||
* "author": "Cloudflare",
|
||||
* "tags": ["workers", "serverless", "edge-computing"]
|
||||
* }],
|
||||
* "chunkSize": 500,
|
||||
* "overlapSize": 50
|
||||
* }'
|
||||
*
|
||||
* 2. Ingest from URL:
|
||||
*
|
||||
* curl -X POST https://your-worker.workers.dev/ingest/url \
|
||||
* -H "Content-Type: application/json" \
|
||||
* -d '{
|
||||
* "url": "https://developers.cloudflare.com/workers/",
|
||||
* "category": "documentation"
|
||||
* }'
|
||||
*
|
||||
* 3. Delete document:
|
||||
*
|
||||
* curl -X DELETE https://your-worker.workers.dev/documents/cloudflare-workers-intro
|
||||
*/
|
||||
425
templates/metadata-filtering.ts
Normal file
425
templates/metadata-filtering.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
/**
|
||||
* Advanced Metadata Filtering Examples for Cloudflare Vectorize
|
||||
*
|
||||
* Use case: Multi-tenant apps, complex filtering, range queries, nested metadata
|
||||
*
|
||||
* Features:
|
||||
* - All filter operators ($eq, $ne, $in, $nin, $lt, $lte, $gt, $gte)
|
||||
* - Nested metadata with dot notation
|
||||
* - Namespace-based isolation
|
||||
* - Combined filters (implicit AND)
|
||||
* - Range queries on numbers and strings
|
||||
* - Performance optimization tips
|
||||
*/
|
||||
|
||||
export interface Env {
|
||||
VECTORIZE_INDEX: VectorizeIndex;
|
||||
AI: Ai;
|
||||
}
|
||||
|
||||
interface FilterExample {
|
||||
name: string;
|
||||
description: string;
|
||||
filter: Record<string, any>;
|
||||
namespace?: string;
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
// Handle CORS
|
||||
if (request.method === 'OPTIONS') {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Route: GET /examples - Show all filter examples
|
||||
if (url.pathname === '/examples' && request.method === 'GET') {
|
||||
const examples: FilterExample[] = [
|
||||
{
|
||||
name: 'Equality (implicit)',
|
||||
description: 'Find vectors with exact category match',
|
||||
filter: { category: 'documentation' },
|
||||
},
|
||||
{
|
||||
name: 'Equality (explicit)',
|
||||
description: 'Explicit $eq operator',
|
||||
filter: { category: { $eq: 'documentation' } },
|
||||
},
|
||||
{
|
||||
name: 'Not Equals',
|
||||
description: 'Exclude archived documents',
|
||||
filter: { status: { $ne: 'archived' } },
|
||||
},
|
||||
{
|
||||
name: 'In Array',
|
||||
description: 'Match any of multiple categories',
|
||||
filter: { category: { $in: ['docs', 'tutorials', 'guides'] } },
|
||||
},
|
||||
{
|
||||
name: 'Not In Array',
|
||||
description: 'Exclude multiple statuses',
|
||||
filter: { status: { $nin: ['archived', 'draft', 'deleted'] } },
|
||||
},
|
||||
{
|
||||
name: 'Greater Than',
|
||||
description: 'Documents published after date',
|
||||
filter: { published_at: { $gt: 1704067200 } }, // Jan 1, 2024
|
||||
},
|
||||
{
|
||||
name: 'Less Than or Equal',
|
||||
description: 'Documents published before or on date',
|
||||
filter: { published_at: { $lte: 1735689600 } }, // Jan 1, 2025
|
||||
},
|
||||
{
|
||||
name: 'Range Query (numbers)',
|
||||
description: 'Documents published in 2024',
|
||||
filter: {
|
||||
published_at: {
|
||||
$gte: 1704067200, // >= Jan 1, 2024
|
||||
$lt: 1735689600, // < Jan 1, 2025
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Range Query (strings - prefix search)',
|
||||
description: 'URLs starting with /docs/workers/',
|
||||
filter: {
|
||||
url: {
|
||||
$gte: '/docs/workers/',
|
||||
$lt: '/docs/workersz', // 'z' is after all possible chars
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Nested Metadata',
|
||||
description: 'Filter by nested author ID',
|
||||
filter: { 'author.id': 'user123' },
|
||||
},
|
||||
{
|
||||
name: 'Combined Filters (AND)',
|
||||
description: 'Multiple conditions (implicit AND)',
|
||||
filter: {
|
||||
category: 'docs',
|
||||
language: 'en',
|
||||
published: true,
|
||||
published_at: { $gte: 1704067200 },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Multi-tenant (namespace)',
|
||||
description: 'Isolate by customer ID using namespace',
|
||||
namespace: 'customer-abc123',
|
||||
filter: { type: 'support_ticket' },
|
||||
},
|
||||
{
|
||||
name: 'Boolean Filter',
|
||||
description: 'Published documents only',
|
||||
filter: { published: true },
|
||||
},
|
||||
{
|
||||
name: 'Complex Multi-field',
|
||||
description: 'Docs in English, published in 2024, not archived',
|
||||
filter: {
|
||||
category: { $in: ['docs', 'tutorials'] },
|
||||
language: 'en',
|
||||
status: { $ne: 'archived' },
|
||||
published_at: { $gte: 1704067200, $lt: 1735689600 },
|
||||
'author.verified': true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return Response.json({ examples });
|
||||
}
|
||||
|
||||
// Route: POST /search/filtered - Execute filtered search
|
||||
if (url.pathname === '/search/filtered' && request.method === 'POST') {
|
||||
try {
|
||||
const body = await request.json() as {
|
||||
query: string;
|
||||
exampleName?: string;
|
||||
filter?: Record<string, any>;
|
||||
namespace?: string;
|
||||
topK?: number;
|
||||
};
|
||||
|
||||
const { query, exampleName, filter, namespace, topK = 5 } = body;
|
||||
|
||||
if (!query) {
|
||||
return Response.json({ error: 'Missing required field: query' }, { status: 400 });
|
||||
}
|
||||
|
||||
// If exampleName provided, use pre-defined filter
|
||||
let finalFilter = filter;
|
||||
let finalNamespace = namespace;
|
||||
|
||||
if (exampleName) {
|
||||
const examplesResponse = await this.fetch(
|
||||
new Request(new URL('/examples', request.url)),
|
||||
env,
|
||||
ctx
|
||||
);
|
||||
const { examples } = (await examplesResponse.json()) as { examples: FilterExample[] };
|
||||
|
||||
const example = examples.find((ex) => ex.name === exampleName);
|
||||
if (example) {
|
||||
finalFilter = example.filter;
|
||||
finalNamespace = example.namespace || namespace;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate embedding
|
||||
const embedding = await env.AI.run('@cf/baai/bge-base-en-v1.5', {
|
||||
text: query,
|
||||
});
|
||||
|
||||
// Query with filter
|
||||
const results = await env.VECTORIZE_INDEX.query(embedding.data[0], {
|
||||
topK,
|
||||
filter: finalFilter,
|
||||
namespace: finalNamespace,
|
||||
returnMetadata: 'all',
|
||||
returnValues: false,
|
||||
});
|
||||
|
||||
return Response.json({
|
||||
query,
|
||||
filter: finalFilter,
|
||||
namespace: finalNamespace,
|
||||
results: results.matches.map((m) => ({
|
||||
id: m.id,
|
||||
score: m.score,
|
||||
metadata: m.metadata,
|
||||
})),
|
||||
count: results.count,
|
||||
}, {
|
||||
headers: { 'Access-Control-Allow-Origin': '*' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Filtered search error:', error);
|
||||
return Response.json(
|
||||
{
|
||||
error: 'Filtered search failed',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Route: POST /seed - Seed example data with rich metadata
|
||||
if (url.pathname === '/seed' && request.method === 'POST') {
|
||||
try {
|
||||
// Sample documents with diverse metadata
|
||||
const sampleDocs = [
|
||||
{
|
||||
content: 'Cloudflare Workers are serverless functions that run on the edge.',
|
||||
metadata: {
|
||||
category: 'documentation',
|
||||
language: 'en',
|
||||
status: 'published',
|
||||
published_at: 1704153600, // Jan 2, 2024
|
||||
published: true,
|
||||
url: '/docs/workers/intro',
|
||||
author: { id: 'user123', name: 'John Doe', verified: true },
|
||||
tags: ['workers', 'serverless', 'edge'],
|
||||
},
|
||||
},
|
||||
{
|
||||
content: 'Vectorize is a globally distributed vector database.',
|
||||
metadata: {
|
||||
category: 'documentation',
|
||||
language: 'en',
|
||||
status: 'published',
|
||||
published_at: 1720310400, // Jul 7, 2024
|
||||
published: true,
|
||||
url: '/docs/vectorize/intro',
|
||||
author: { id: 'user456', name: 'Jane Smith', verified: true },
|
||||
tags: ['vectorize', 'database', 'ai'],
|
||||
},
|
||||
},
|
||||
{
|
||||
content: 'D1 is Cloudflare\'s serverless SQL database.',
|
||||
metadata: {
|
||||
category: 'tutorials',
|
||||
language: 'en',
|
||||
status: 'draft',
|
||||
published_at: 1735603200, // Dec 31, 2024
|
||||
published: false,
|
||||
url: '/tutorials/d1/getting-started',
|
||||
author: { id: 'user123', name: 'John Doe', verified: true },
|
||||
tags: ['d1', 'database', 'sql'],
|
||||
},
|
||||
},
|
||||
{
|
||||
content: 'R2 provides S3-compatible object storage without egress fees.',
|
||||
metadata: {
|
||||
category: 'guides',
|
||||
language: 'en',
|
||||
status: 'published',
|
||||
published_at: 1712880000, // Apr 12, 2024
|
||||
published: true,
|
||||
url: '/docs/r2/overview',
|
||||
author: { id: 'user789', name: 'Bob Wilson', verified: false },
|
||||
tags: ['r2', 'storage', 'object-storage'],
|
||||
},
|
||||
},
|
||||
{
|
||||
content: 'Workers KV is a key-value store for edge applications.',
|
||||
metadata: {
|
||||
category: 'documentation',
|
||||
language: 'en',
|
||||
status: 'archived',
|
||||
published_at: 1640995200, // Jan 1, 2022
|
||||
published: true,
|
||||
url: '/docs/kv/intro',
|
||||
author: { id: 'user456', name: 'Jane Smith', verified: true },
|
||||
tags: ['kv', 'storage', 'edge'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Generate embeddings
|
||||
const texts = sampleDocs.map((doc) => doc.content);
|
||||
const embeddings = await env.AI.run('@cf/baai/bge-base-en-v1.5', { text: texts });
|
||||
|
||||
// Prepare vectors
|
||||
const vectors = sampleDocs.map((doc, i) => ({
|
||||
id: `sample-${i + 1}`,
|
||||
values: embeddings.data[i],
|
||||
metadata: {
|
||||
content: doc.content,
|
||||
...doc.metadata,
|
||||
indexed_at: Date.now(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Upsert all
|
||||
await env.VECTORIZE_INDEX.upsert(vectors);
|
||||
|
||||
return Response.json({
|
||||
success: true,
|
||||
message: 'Seeded 5 sample documents with rich metadata',
|
||||
count: vectors.length,
|
||||
}, {
|
||||
headers: { 'Access-Control-Allow-Origin': '*' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Seed error:', error);
|
||||
return Response.json(
|
||||
{
|
||||
error: 'Seeding failed',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Default: API documentation
|
||||
return Response.json({
|
||||
name: 'Metadata Filtering Examples API',
|
||||
endpoints: {
|
||||
'GET /examples': {
|
||||
description: 'List all filter examples with syntax',
|
||||
},
|
||||
'POST /search/filtered': {
|
||||
description: 'Execute filtered vector search',
|
||||
body: {
|
||||
query: 'string (required)',
|
||||
exampleName: 'string (optional) - use pre-defined filter',
|
||||
filter: 'object (optional) - custom filter',
|
||||
namespace: 'string (optional)',
|
||||
topK: 'number (optional, default: 5)',
|
||||
},
|
||||
example: {
|
||||
query: 'serverless database',
|
||||
exampleName: 'Range Query (numbers)',
|
||||
},
|
||||
},
|
||||
'POST /seed': {
|
||||
description: 'Seed database with example documents',
|
||||
note: 'Creates 5 sample documents with rich metadata for testing',
|
||||
},
|
||||
},
|
||||
filterOperators: {
|
||||
$eq: 'Equals',
|
||||
$ne: 'Not equals',
|
||||
$in: 'In array',
|
||||
$nin: 'Not in array',
|
||||
$lt: 'Less than',
|
||||
$lte: 'Less than or equal',
|
||||
$gt: 'Greater than',
|
||||
$gte: 'Greater than or equal',
|
||||
},
|
||||
notes: {
|
||||
'Metadata Keys': 'Cannot be empty, contain dots (.), quotes ("), or start with $',
|
||||
'Filter Size': 'Max 2048 bytes (compact JSON)',
|
||||
'Cardinality': 'High cardinality in range queries can impact performance',
|
||||
'Namespace': 'Applied BEFORE metadata filters',
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Example Usage:
|
||||
*
|
||||
* 1. Seed example data:
|
||||
*
|
||||
* curl -X POST https://your-worker.workers.dev/seed
|
||||
*
|
||||
* 2. List filter examples:
|
||||
*
|
||||
* curl https://your-worker.workers.dev/examples
|
||||
*
|
||||
* 3. Search with pre-defined filter:
|
||||
*
|
||||
* curl -X POST https://your-worker.workers.dev/search/filtered \
|
||||
* -H "Content-Type: application/json" \
|
||||
* -d '{
|
||||
* "query": "database storage",
|
||||
* "exampleName": "Range Query (numbers)"
|
||||
* }'
|
||||
*
|
||||
* 4. Search with custom filter:
|
||||
*
|
||||
* curl -X POST https://your-worker.workers.dev/search/filtered \
|
||||
* -H "Content-Type: application/json" \
|
||||
* -d '{
|
||||
* "query": "edge computing",
|
||||
* "filter": {
|
||||
* "category": { "$in": ["docs", "tutorials"] },
|
||||
* "language": "en",
|
||||
* "status": { "$ne": "archived" },
|
||||
* "author.verified": true
|
||||
* },
|
||||
* "topK": 3
|
||||
* }'
|
||||
*
|
||||
* Performance Tips:
|
||||
*
|
||||
* 1. Low Cardinality for Range Queries:
|
||||
* ✅ Good: published_at (timestamps in seconds, not milliseconds)
|
||||
* ❌ Bad: user_id (millions of unique values in range)
|
||||
*
|
||||
* 2. Namespace First:
|
||||
* Use namespace for partition key (customer_id, tenant_id)
|
||||
* Then use metadata filters for finer-grained filtering
|
||||
*
|
||||
* 3. Filter Size:
|
||||
* Keep filters under 2048 bytes
|
||||
* If hitting limit, split into multiple queries
|
||||
*
|
||||
* 4. Indexed Metadata:
|
||||
* Create metadata indexes BEFORE inserting vectors:
|
||||
* npx wrangler vectorize create-metadata-index my-index \
|
||||
* --property-name=category --type=string
|
||||
*/
|
||||
351
templates/rag-chat.ts
Normal file
351
templates/rag-chat.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* RAG (Retrieval Augmented Generation) Chatbot
|
||||
* with Cloudflare Vectorize + Workers AI
|
||||
*
|
||||
* Use case: Q&A chatbot that retrieves relevant context before generating answers
|
||||
*
|
||||
* Features:
|
||||
* - Semantic search over knowledge base
|
||||
* - Context-aware LLM responses
|
||||
* - Source citations
|
||||
* - Conversation history support
|
||||
* - Streaming responses (optional)
|
||||
*/
|
||||
|
||||
export interface Env {
|
||||
VECTORIZE_INDEX: VectorizeIndex;
|
||||
AI: Ai;
|
||||
}
|
||||
|
||||
interface ChatRequest {
|
||||
question: string;
|
||||
conversationHistory?: Array<{ role: string; content: string }>;
|
||||
topK?: number;
|
||||
filter?: Record<string, any>;
|
||||
namespace?: string;
|
||||
}
|
||||
|
||||
interface ChatResponse {
|
||||
answer: string;
|
||||
sources: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
score: number;
|
||||
excerpt: string;
|
||||
}>;
|
||||
context: string;
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
// Handle CORS
|
||||
if (request.method === 'OPTIONS') {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Route: POST /chat - RAG chatbot endpoint
|
||||
if (url.pathname === '/chat' && request.method === 'POST') {
|
||||
try {
|
||||
const body = await request.json() as ChatRequest;
|
||||
const {
|
||||
question,
|
||||
conversationHistory = [],
|
||||
topK = 3,
|
||||
filter,
|
||||
namespace,
|
||||
} = body;
|
||||
|
||||
if (!question) {
|
||||
return Response.json(
|
||||
{ error: 'Missing required field: question' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Step 1: Generate embedding for user question
|
||||
const questionEmbedding = await env.AI.run('@cf/baai/bge-base-en-v1.5', {
|
||||
text: question,
|
||||
});
|
||||
|
||||
// Step 2: Search vector database for relevant context
|
||||
const searchResults = await env.VECTORIZE_INDEX.query(
|
||||
questionEmbedding.data[0],
|
||||
{
|
||||
topK,
|
||||
filter,
|
||||
namespace,
|
||||
returnMetadata: 'all',
|
||||
returnValues: false,
|
||||
}
|
||||
);
|
||||
|
||||
// Step 3: Build context from retrieved documents
|
||||
const contextParts: string[] = [];
|
||||
const sources: ChatResponse['sources'] = [];
|
||||
|
||||
for (const match of searchResults.matches) {
|
||||
const metadata = match.metadata || {};
|
||||
const title = metadata.title || metadata.id || match.id;
|
||||
const content = metadata.content || '';
|
||||
|
||||
// Truncate content for context (max ~500 chars per source)
|
||||
const excerpt =
|
||||
content.length > 500 ? content.slice(0, 497) + '...' : content;
|
||||
|
||||
contextParts.push(`[${title}]\n${content}`);
|
||||
sources.push({
|
||||
id: match.id,
|
||||
title,
|
||||
score: match.score,
|
||||
excerpt,
|
||||
});
|
||||
}
|
||||
|
||||
const context = contextParts.join('\n\n---\n\n');
|
||||
|
||||
// Step 4: Build conversation with context
|
||||
const messages = [
|
||||
{
|
||||
role: 'system',
|
||||
content: `You are a helpful AI assistant. Answer questions based on the following context. If the context doesn't contain enough information to answer the question, say so honestly.
|
||||
|
||||
Context:
|
||||
${context}`,
|
||||
},
|
||||
...conversationHistory,
|
||||
{
|
||||
role: 'user',
|
||||
content: question,
|
||||
},
|
||||
];
|
||||
|
||||
// Step 5: Generate answer with LLM
|
||||
const aiResponse = await env.AI.run('@cf/meta/llama-3-8b-instruct', {
|
||||
messages,
|
||||
});
|
||||
|
||||
const answer = aiResponse.response || 'Sorry, I could not generate a response.';
|
||||
|
||||
// Return response with sources
|
||||
return Response.json({
|
||||
answer,
|
||||
sources,
|
||||
context: context.slice(0, 1000), // Include truncated context for debugging
|
||||
} as ChatResponse, {
|
||||
headers: { 'Access-Control-Allow-Origin': '*' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Chat error:', error);
|
||||
return Response.json(
|
||||
{
|
||||
error: 'Chat failed',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Route: POST /chat/stream - Streaming RAG responses
|
||||
if (url.pathname === '/chat/stream' && request.method === 'POST') {
|
||||
try {
|
||||
const body = await request.json() as ChatRequest;
|
||||
const { question, topK = 3, filter, namespace } = body;
|
||||
|
||||
if (!question) {
|
||||
return Response.json(
|
||||
{ error: 'Missing required field: question' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Retrieve context (same as above)
|
||||
const questionEmbedding = await env.AI.run('@cf/baai/bge-base-en-v1.5', {
|
||||
text: question,
|
||||
});
|
||||
|
||||
const searchResults = await env.VECTORIZE_INDEX.query(
|
||||
questionEmbedding.data[0],
|
||||
{ topK, filter, namespace, returnMetadata: 'all', returnValues: false }
|
||||
);
|
||||
|
||||
const contextParts = searchResults.matches.map(
|
||||
(m) => `[${m.metadata?.title || m.id}]\n${m.metadata?.content || ''}`
|
||||
);
|
||||
const context = contextParts.join('\n\n---\n\n');
|
||||
|
||||
// Stream LLM response
|
||||
const stream = await env.AI.run('@cf/meta/llama-3-8b-instruct', {
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: `Answer based on context:\n\n${context}`,
|
||||
},
|
||||
{ role: 'user', content: question },
|
||||
],
|
||||
stream: true,
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Stream error:', error);
|
||||
return Response.json(
|
||||
{
|
||||
error: 'Streaming failed',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Route: POST /ingest - Add knowledge base content
|
||||
if (url.pathname === '/ingest' && request.method === 'POST') {
|
||||
try {
|
||||
const body = await request.json() as {
|
||||
documents: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
metadata?: Record<string, any>;
|
||||
}>;
|
||||
namespace?: string;
|
||||
};
|
||||
|
||||
if (!body.documents || !Array.isArray(body.documents)) {
|
||||
return Response.json(
|
||||
{ error: 'Missing or invalid field: documents (array)' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Generate embeddings for all documents
|
||||
const texts = body.documents.map((doc) => doc.content);
|
||||
const embeddings = await env.AI.run('@cf/baai/bge-base-en-v1.5', {
|
||||
text: texts,
|
||||
});
|
||||
|
||||
// Prepare vectors for upsert
|
||||
const vectors = body.documents.map((doc, i) => ({
|
||||
id: doc.id,
|
||||
values: embeddings.data[i],
|
||||
namespace: body.namespace,
|
||||
metadata: {
|
||||
title: doc.title,
|
||||
content: doc.content,
|
||||
...doc.metadata,
|
||||
indexed_at: Date.now(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Batch upsert
|
||||
await env.VECTORIZE_INDEX.upsert(vectors);
|
||||
|
||||
return Response.json({
|
||||
success: true,
|
||||
count: vectors.length,
|
||||
message: `Successfully indexed ${vectors.length} documents`,
|
||||
}, {
|
||||
headers: { 'Access-Control-Allow-Origin': '*' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Ingest error:', error);
|
||||
return Response.json(
|
||||
{
|
||||
error: 'Ingestion failed',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Default: API documentation
|
||||
return Response.json({
|
||||
name: 'RAG Chatbot API',
|
||||
endpoints: {
|
||||
'POST /chat': {
|
||||
description: 'Ask questions with context retrieval',
|
||||
body: {
|
||||
question: 'string (required)',
|
||||
conversationHistory: 'array (optional)',
|
||||
topK: 'number (optional, default: 3)',
|
||||
filter: 'object (optional)',
|
||||
namespace: 'string (optional)',
|
||||
},
|
||||
example: {
|
||||
question: 'How do I deploy a Cloudflare Worker?',
|
||||
topK: 3,
|
||||
filter: { category: 'documentation' },
|
||||
},
|
||||
},
|
||||
'POST /chat/stream': {
|
||||
description: 'Streaming responses',
|
||||
body: 'Same as /chat',
|
||||
},
|
||||
'POST /ingest': {
|
||||
description: 'Add documents to knowledge base',
|
||||
body: {
|
||||
documents: [
|
||||
{
|
||||
id: 'doc-1',
|
||||
title: 'Document Title',
|
||||
content: 'Document content...',
|
||||
metadata: { category: 'docs' },
|
||||
},
|
||||
],
|
||||
namespace: 'string (optional)',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Example Usage:
|
||||
*
|
||||
* 1. Ingest knowledge base:
|
||||
*
|
||||
* curl -X POST https://your-worker.workers.dev/ingest \
|
||||
* -H "Content-Type: application/json" \
|
||||
* -d '{
|
||||
* "documents": [
|
||||
* {
|
||||
* "id": "workers-intro",
|
||||
* "title": "Introduction to Workers",
|
||||
* "content": "Cloudflare Workers allow you to deploy serverless code globally...",
|
||||
* "metadata": { "category": "docs", "section": "workers" }
|
||||
* }
|
||||
* ]
|
||||
* }'
|
||||
*
|
||||
* 2. Ask a question:
|
||||
*
|
||||
* curl -X POST https://your-worker.workers.dev/chat \
|
||||
* -H "Content-Type: application/json" \
|
||||
* -d '{
|
||||
* "question": "How do I deploy serverless code?",
|
||||
* "topK": 3,
|
||||
* "filter": { "category": "docs" }
|
||||
* }'
|
||||
*
|
||||
* 3. Streaming response:
|
||||
*
|
||||
* curl -X POST https://your-worker.workers.dev/chat/stream \
|
||||
* -H "Content-Type: application/json" \
|
||||
* -d '{ "question": "What is a Worker?" }'
|
||||
*/
|
||||
Reference in New Issue
Block a user