Skip to content

Commit 3a41906

Browse files
committed
feat: migrate from D1+Vectorize to PostgreSQL+pgvector
Replace Cloudflare D1 and Vectorize with a single PostgreSQL database using pgvector for embeddings. Immediate consistency — no more eventual consistency delays. Chunks and vectors stored together, queried in one SQL statement. Simplifies frontend state (no indexing status needed).
1 parent 91c481f commit 3a41906

12 files changed

Lines changed: 264 additions & 187 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Each demo is a standalone app that showcases a real-world use case: extracting d
1010

1111
Ask your documents. Get cited answers. Upload `.docx` files and get AI-powered answers with citations that scroll to the exact paragraph, comment, or tracked change in the source document.
1212

13-
**Stack**: Cloudflare Workers + R2 + D1 + Vectorize, React, SuperDoc, Claude, OpenAI embeddings
13+
**Stack**: Cloudflare Workers + R2, PostgreSQL + pgvector, React, SuperDoc, Claude, OpenAI embeddings
1414

1515
**What it shows**:
1616
- Extract text, comments, and tracked changes from `.docx` files using the SuperDoc SDK

rag/.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
# Database (PostgreSQL + pgvector)
2+
DATABASE_URL=postgresql://...
3+
14
# API keys
25
OPENAI_API_KEY=sk-...
36
ANTHROPIC_API_KEY=sk-ant-...

rag/README.md

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ Upload `.docx` files, ask questions in natural language, and get answers with ci
99
1. **Upload** `.docx` files through the UI
1010
2. **Extract** text, comments, and tracked changes using the [SuperDoc SDK](https://docs.superdoc.dev)
1111
3. **Chunk** each paragraph with its stable node ID, embed with OpenAI
12-
4. **Store** chunks in Cloudflare Vectorize, metadata in D1, files in R2
13-
5. **Query** — ask a question, relevant chunks are retrieved via vector search
12+
4. **Store** chunks with embeddings in PostgreSQL + pgvector, files in R2
13+
5. **Query** — ask a question, relevant chunks are retrieved via vector similarity search
1414
6. **Answer** — Claude generates a response with `[cite:ID]` references
1515
7. **Navigate** — click a citation to scroll to the source in the SuperDoc viewer
1616

@@ -20,19 +20,22 @@ Upload `.docx` files, ask questions in natural language, and get answers with ci
2020
apps/
2121
api/ Cloudflare Worker — query, documents, file serving
2222
web/ React frontend — document viewer + chat sidebar
23-
ingest-service/ Docker service — automated extraction for VM deployment
23+
ingest/ Docker service — automated extraction for VM deployment
2424
packages/
2525
shared/ SuperDoc extraction, chunking, embedding client
2626
docs/ Sample .docx files
2727
```
2828

29+
**Stack**: Cloudflare Workers, PostgreSQL + pgvector, Cloudflare R2, React, SuperDoc, Claude, OpenAI embeddings
30+
2931
## Quick Start
3032

3133
### Prerequisites
3234

3335
- [Bun](https://bun.sh) v1.1+
3436
- [Wrangler](https://developers.cloudflare.com/workers/wrangler/) (installed automatically)
3537
- Cloudflare account (free tier works)
38+
- PostgreSQL database with [pgvector](https://github.com/pgvector/pgvector)
3639
- OpenAI API key (for embeddings)
3740
- Anthropic API key (for Claude)
3841

@@ -41,18 +44,19 @@ docs/ Sample .docx files
4144
```bash
4245
bun install
4346

44-
# Create Cloudflare resources
45-
cd apps/api
46-
npx wrangler d1 create docrag
47-
# Copy the database_id into wrangler.toml
47+
# Create the database schema (run against your Neon database)
48+
psql $DATABASE_URL -f apps/api/schema.sql
4849

49-
npx wrangler d1 execute docrag --local --file=schema.sql
50-
npx wrangler vectorize create rag-chunks --dimensions=1536 --metric=cosine
50+
# Create Cloudflare R2 bucket
51+
cd apps/api
52+
npx wrangler r2 bucket create rag-demo-docs
5153

5254
# Add secrets for local dev
5355
cat > .dev.vars << EOF
56+
DATABASE_URL=postgresql://...your-connection-string...
5457
OPENAI_API_KEY=sk-...
5558
ANTHROPIC_API_KEY=sk-ant-...
59+
INGEST_SERVICE_URL=http://localhost:4000
5660
EOF
5761
cd ../..
5862
```
@@ -86,9 +90,9 @@ Try these across the sample documents:
8690
```bash
8791
# Deploy API Worker
8892
cd apps/api
93+
wrangler secret put DATABASE_URL
8994
wrangler secret put OPENAI_API_KEY
9095
wrangler secret put ANTHROPIC_API_KEY
91-
wrangler d1 execute docrag --remote --file=schema.sql
9296
wrangler deploy
9397

9498
# Deploy frontend to Cloudflare Pages
@@ -101,7 +105,7 @@ bun run deploy:web
101105
For automated ingestion, deploy the Docker service to a VM:
102106

103107
```bash
104-
docker build -f apps/ingest-service/Dockerfile -t docrag-ingest .
108+
docker build -f apps/ingest/Dockerfile -t docrag-ingest .
105109
docker run -d \
106110
-e API_URL=https://docrag-api.<account>.workers.dev \
107111
-e OPENAI_API_KEY=sk-... \

rag/apps/api/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
},
1111
"dependencies": {
1212
"@anthropic-ai/sdk": "^0.30.0",
13-
"@docrag/shared": "workspace:*"
13+
"@docrag/shared": "workspace:*",
14+
"@neondatabase/serverless": "^1.0.2"
1415
},
1516
"devDependencies": {
1617
"@cloudflare/workers-types": "^4.20250326.0",

rag/apps/api/schema.sql

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,26 @@
1+
CREATE EXTENSION IF NOT EXISTS vector;
2+
13
CREATE TABLE IF NOT EXISTS documents (
2-
id INTEGER PRIMARY KEY,
4+
id BIGINT PRIMARY KEY,
35
filename TEXT NOT NULL,
46
r2_key TEXT NOT NULL,
57
file_hash TEXT,
68
status TEXT DEFAULT 'ready',
7-
created_at TEXT DEFAULT (datetime('now'))
9+
created_at TIMESTAMPTZ DEFAULT now()
810
);
911

1012
CREATE TABLE IF NOT EXISTS chunks (
1113
id TEXT PRIMARY KEY,
12-
document_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
14+
document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
1315
block_id TEXT NOT NULL,
1416
target_id TEXT NOT NULL,
1517
target_type TEXT NOT NULL,
1618
node_type TEXT NOT NULL,
1719
content TEXT NOT NULL,
1820
context_type TEXT DEFAULT 'body',
1921
metadata TEXT DEFAULT '{}',
20-
created_at TEXT DEFAULT (datetime('now'))
22+
embedding vector(1536),
23+
created_at TIMESTAMPTZ DEFAULT now()
2124
);
2225

2326
CREATE INDEX IF NOT EXISTS idx_chunks_document ON chunks(document_id);

rag/apps/api/src/cf/neon.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { neon } from "@neondatabase/serverless";
2+
3+
export type DocumentRow = {
4+
id: number;
5+
filename: string;
6+
r2Key: string;
7+
fileHash: string | null;
8+
status: string;
9+
createdAt: string;
10+
};
11+
12+
export function createNeonClient(databaseUrl: string) {
13+
const sql = neon(databaseUrl);
14+
15+
return {
16+
async insertDocument(
17+
id: number,
18+
filename: string,
19+
r2Key: string,
20+
status = "ready",
21+
fileHash: string | null = null,
22+
): Promise<void> {
23+
await sql`INSERT INTO documents (id, filename, r2_key, status, file_hash) VALUES (${id}, ${filename}, ${r2Key}, ${status}, ${fileHash})`;
24+
},
25+
26+
async findByHash(hash: string): Promise<DocumentRow | null> {
27+
const rows =
28+
await sql`SELECT id, filename, r2_key, file_hash, status, created_at FROM documents WHERE file_hash = ${hash} LIMIT 1`;
29+
if (rows.length === 0) return null;
30+
const r = rows[0];
31+
return {
32+
id: r.id,
33+
filename: r.filename,
34+
r2Key: r.r2_key,
35+
fileHash: r.file_hash,
36+
status: r.status,
37+
createdAt: r.created_at,
38+
};
39+
},
40+
41+
async insertChunks(
42+
chunks: Array<{
43+
id: string;
44+
documentId: number;
45+
blockId: string;
46+
targetId: string;
47+
targetType: string;
48+
nodeType: string;
49+
content: string;
50+
contextType: string;
51+
metadata: string;
52+
embedding: number[];
53+
}>,
54+
): Promise<void> {
55+
for (const c of chunks) {
56+
const embeddingStr = `[${c.embedding.join(",")}]`;
57+
await sql`INSERT INTO chunks (id, document_id, block_id, target_id, target_type, node_type, content, context_type, metadata, embedding) VALUES (${c.id}, ${c.documentId}, ${c.blockId}, ${c.targetId}, ${c.targetType}, ${c.nodeType}, ${c.content}, ${c.contextType}, ${c.metadata}, ${embeddingStr}::vector)`;
58+
}
59+
},
60+
61+
async searchChunks(
62+
queryEmbedding: number[],
63+
limit = 8,
64+
): Promise<
65+
Array<{
66+
id: string;
67+
documentId: number;
68+
filename: string;
69+
blockId: string;
70+
targetId: string;
71+
targetType: string;
72+
nodeType: string;
73+
content: string;
74+
contextType: string;
75+
metadata: string;
76+
}>
77+
> {
78+
const embeddingStr = `[${queryEmbedding.join(",")}]`;
79+
const rows = await sql`
80+
SELECT c.id, c.document_id, d.filename, c.block_id, c.target_id, c.target_type, c.node_type, c.content, c.context_type, c.metadata
81+
FROM chunks c
82+
JOIN documents d ON d.id = c.document_id
83+
ORDER BY c.embedding <=> ${embeddingStr}::vector
84+
LIMIT ${limit}
85+
`;
86+
return rows.map((r: any) => ({
87+
id: r.id,
88+
documentId: r.document_id,
89+
filename: r.filename,
90+
blockId: r.block_id,
91+
targetId: r.target_id,
92+
targetType: r.target_type,
93+
nodeType: r.node_type,
94+
content: r.content,
95+
contextType: r.context_type,
96+
metadata: r.metadata,
97+
}));
98+
},
99+
100+
async listDocuments(): Promise<DocumentRow[]> {
101+
const rows =
102+
await sql`SELECT id, filename, r2_key, file_hash, status, created_at FROM documents ORDER BY created_at DESC`;
103+
return rows.map((r: any) => ({
104+
id: r.id,
105+
filename: r.filename,
106+
r2Key: r.r2_key,
107+
fileHash: r.file_hash,
108+
status: r.status,
109+
createdAt: r.created_at,
110+
}));
111+
},
112+
113+
async getDocument(id: number): Promise<DocumentRow | null> {
114+
const rows =
115+
await sql`SELECT id, filename, r2_key, file_hash, status, created_at FROM documents WHERE id = ${id} LIMIT 1`;
116+
if (rows.length === 0) return null;
117+
const r = rows[0];
118+
return {
119+
id: r.id,
120+
filename: r.filename,
121+
r2Key: r.r2_key,
122+
fileHash: r.file_hash,
123+
status: r.status,
124+
createdAt: r.created_at,
125+
};
126+
},
127+
128+
async chunkCount(): Promise<number> {
129+
const rows = await sql`SELECT COUNT(*) as count FROM chunks`;
130+
return Number(rows[0].count);
131+
},
132+
133+
async getChunkIdsByDocument(documentId: number): Promise<string[]> {
134+
const rows =
135+
await sql`SELECT id FROM chunks WHERE document_id = ${documentId}`;
136+
return rows.map((r: any) => r.id);
137+
},
138+
139+
async deleteDocument(id: number): Promise<void> {
140+
await sql`DELETE FROM documents WHERE id = ${id}`;
141+
},
142+
143+
async updateDocumentStatus(id: number, status: string): Promise<void> {
144+
await sql`UPDATE documents SET status = ${status} WHERE id = ${id}`;
145+
},
146+
};
147+
}

0 commit comments

Comments
 (0)