Skip to content

Commit 8eec2b6

Browse files
committed
Rewrite RAG as persistent SQLite collections with real chunking and citations
Replaces the in-memory, 200-chunk, character-chunked RAG prototype: SQLite-backed collections (rag-db.ts) survive restarts, content-hash-based incremental reindexing skips unchanged files, chunking is document-aware and token-budgeted (never splits a line, tracks markdown headings), PDFs get real page-numbered chunks, and stale documents are removed on re-index.
1 parent ec30930 commit 8eec2b6

5 files changed

Lines changed: 618 additions & 98 deletions

File tree

app/src/file-reader.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,38 @@ function walkDir(rootDir: string, dir: string, files: AttachedFile[], state: Wal
241241
}
242242
}
243243

244+
const MAX_PDF_FILES = 50;
245+
246+
// Folder attach (openFolderAndRead/walkDir) skips .pdf entirely — it's a
247+
// binary format the generic "dump raw content" attach flow can't use safely.
248+
// RAG indexing wants PDFs too (for page-numbered citations), so this walks
249+
// the same tree with the same ignored-dirs convention, collecting .pdf paths
250+
// only, separately from the general attach path above.
251+
export function findPdfFiles(rootDir: string): string[] {
252+
const found: string[] = [];
253+
function walk(dir: string): void {
254+
if (found.length >= MAX_PDF_FILES) return;
255+
let entries: fs.Dirent[];
256+
try {
257+
entries = fs.readdirSync(dir, { withFileTypes: true });
258+
} catch {
259+
return;
260+
}
261+
for (const entry of entries) {
262+
if (found.length >= MAX_PDF_FILES) return;
263+
const fullPath = path.join(dir, entry.name);
264+
if (entry.isDirectory()) {
265+
if (entry.name.startsWith(".") || IGNORED_DIRS.has(entry.name)) continue;
266+
walk(fullPath);
267+
} else if (entry.isFile() && path.extname(entry.name).toLowerCase() === ".pdf") {
268+
found.push(fullPath);
269+
}
270+
}
271+
}
272+
walk(rootDir);
273+
return found;
274+
}
275+
244276
export async function openFolderAndRead(win: BrowserWindow | null): Promise<OpenFolderResult | null> {
245277
const result = win
246278
? await dialog.showOpenDialog(win, { properties: ["openDirectory"] })

app/src/media.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,26 @@ export async function extractPdfText(filePath: string): Promise<string> {
5353
}
5454
}
5555

56+
export interface PdfPage {
57+
num: number;
58+
text: string;
59+
}
60+
61+
// Same extraction as extractPdfText, but keeps text broken down per page —
62+
// getText() already returns this (TextResult.pages), no extra per-page calls
63+
// needed. Used by RAG folder-indexing to attach page numbers to chunks.
64+
export async function extractPdfPages(filePath: string): Promise<{ text: string; pages: PdfPage[] }> {
65+
const { PDFParse } = require("pdf-parse");
66+
const buffer = fs.readFileSync(filePath);
67+
const parser = new PDFParse({ data: buffer });
68+
try {
69+
const result = await parser.getText();
70+
return { text: result.text as string, pages: (result.pages ?? []).map((p: { num: number; text: string }) => ({ num: p.num, text: p.text })) };
71+
} finally {
72+
await parser.destroy();
73+
}
74+
}
75+
5676
function ffmpegBinaryPath(): string {
5777
// electron-builder's asarUnpack keeps this binary outside the asar archive
5878
// (asar-packed files can't be exec'd directly); this swap finds it there

app/src/rag-db.ts

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import * as fs from "node:fs";
2+
import * as path from "node:path";
3+
import { app } from "electron";
4+
import Database from "better-sqlite3";
5+
6+
export interface CollectionRow {
7+
id: string;
8+
name: string;
9+
folder_path: string;
10+
embedding_model: string;
11+
created_at: number;
12+
updated_at: number;
13+
}
14+
15+
export interface DocumentRow {
16+
id: string;
17+
collection_id: string;
18+
path: string;
19+
name: string;
20+
content_hash: string;
21+
size: number;
22+
mtime_ms: number;
23+
page_count: number | null;
24+
indexed_at: number;
25+
}
26+
27+
export interface ChunkInput {
28+
text: string;
29+
tokenCount: number;
30+
heading: string | null;
31+
page: number | null;
32+
startLine: number;
33+
endLine: number;
34+
embedding: number[];
35+
}
36+
37+
export interface ChunkRow {
38+
id: string;
39+
document_id: string;
40+
collection_id: string;
41+
ordinal: number;
42+
text: string;
43+
token_count: number;
44+
heading: string | null;
45+
page: number | null;
46+
start_line: number;
47+
end_line: number;
48+
embedding: Buffer;
49+
}
50+
51+
function filePath(): string {
52+
return path.join(app.getPath("userData"), "rag.db");
53+
}
54+
55+
let db: Database.Database | null = null;
56+
57+
// Module-level singleton, opened lazily so tests (and any code running
58+
// before app.getPath is available) don't pay for it until first use.
59+
export function getDb(): Database.Database {
60+
if (db) return db;
61+
fs.mkdirSync(path.dirname(filePath()), { recursive: true });
62+
db = new Database(filePath());
63+
db.pragma("journal_mode = WAL");
64+
db.exec(`
65+
CREATE TABLE IF NOT EXISTS collections (
66+
id TEXT PRIMARY KEY, name TEXT NOT NULL, folder_path TEXT NOT NULL UNIQUE,
67+
embedding_model TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
68+
);
69+
CREATE TABLE IF NOT EXISTS documents (
70+
id TEXT PRIMARY KEY, collection_id TEXT NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
71+
path TEXT NOT NULL, name TEXT NOT NULL, content_hash TEXT NOT NULL,
72+
size INTEGER NOT NULL, mtime_ms INTEGER NOT NULL, page_count INTEGER, indexed_at INTEGER NOT NULL,
73+
UNIQUE(collection_id, path)
74+
);
75+
CREATE TABLE IF NOT EXISTS chunks (
76+
id TEXT PRIMARY KEY, document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
77+
collection_id TEXT NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
78+
ordinal INTEGER NOT NULL, text TEXT NOT NULL, token_count INTEGER NOT NULL,
79+
heading TEXT, page INTEGER, start_line INTEGER NOT NULL, end_line INTEGER NOT NULL,
80+
embedding BLOB NOT NULL
81+
);
82+
CREATE INDEX IF NOT EXISTS idx_chunks_collection ON chunks(collection_id);
83+
CREATE INDEX IF NOT EXISTS idx_documents_collection ON documents(collection_id);
84+
`);
85+
return db;
86+
}
87+
88+
// Exposed for tests only — the electron mock points app.getPath("userData")
89+
// at a real temp directory shared for the whole test process, so without
90+
// this, rows from one test would leak into the next via the same rag.db file.
91+
export function clearAllForTests(): void {
92+
getDb().exec(`DELETE FROM chunks; DELETE FROM documents; DELETE FROM collections;`);
93+
}
94+
95+
export function encodeEmbedding(vector: number[]): Buffer {
96+
return Buffer.from(Float32Array.from(vector).buffer);
97+
}
98+
99+
export function decodeEmbedding(buffer: Buffer): Float32Array {
100+
return new Float32Array(buffer.buffer, buffer.byteOffset, buffer.length / Float32Array.BYTES_PER_ELEMENT);
101+
}
102+
103+
export function upsertCollection(input: { id: string; name: string; folderPath: string; embeddingModel: string }): CollectionRow {
104+
const now = Date.now();
105+
const existing = getCollectionByPath(input.folderPath);
106+
if (existing) {
107+
getDb().prepare(`UPDATE collections SET name = ?, updated_at = ? WHERE id = ?`).run(input.name, now, existing.id);
108+
return { ...existing, name: input.name, updated_at: now };
109+
}
110+
const row: CollectionRow = { id: input.id, name: input.name, folder_path: input.folderPath, embedding_model: input.embeddingModel, created_at: now, updated_at: now };
111+
getDb().prepare(`INSERT INTO collections (id, name, folder_path, embedding_model, created_at, updated_at) VALUES (@id, @name, @folder_path, @embedding_model, @created_at, @updated_at)`).run(row);
112+
return row;
113+
}
114+
115+
export function getCollectionByPath(folderPath: string): CollectionRow | undefined {
116+
return getDb().prepare(`SELECT * FROM collections WHERE folder_path = ?`).get(folderPath) as CollectionRow | undefined;
117+
}
118+
119+
export function getCollection(id: string): CollectionRow | undefined {
120+
return getDb().prepare(`SELECT * FROM collections WHERE id = ?`).get(id) as CollectionRow | undefined;
121+
}
122+
123+
export function listCollections(): CollectionRow[] {
124+
return getDb().prepare(`SELECT * FROM collections ORDER BY updated_at DESC`).all() as CollectionRow[];
125+
}
126+
127+
export function deleteCollection(id: string): void {
128+
getDb().prepare(`DELETE FROM collections WHERE id = ?`).run(id);
129+
}
130+
131+
export function touchCollection(id: string): void {
132+
getDb().prepare(`UPDATE collections SET updated_at = ? WHERE id = ?`).run(Date.now(), id);
133+
}
134+
135+
export function getDocument(collectionId: string, filePath: string): DocumentRow | undefined {
136+
return getDb().prepare(`SELECT * FROM documents WHERE collection_id = ? AND path = ?`).get(collectionId, filePath) as DocumentRow | undefined;
137+
}
138+
139+
export function listDocuments(collectionId: string): DocumentRow[] {
140+
return getDb().prepare(`SELECT * FROM documents WHERE collection_id = ?`).all(collectionId) as DocumentRow[];
141+
}
142+
143+
export function upsertDocument(input: {
144+
id: string; collectionId: string; path: string; name: string; contentHash: string; size: number; mtimeMs: number; pageCount: number | null;
145+
}): void {
146+
const now = Date.now();
147+
getDb().prepare(`
148+
INSERT INTO documents (id, collection_id, path, name, content_hash, size, mtime_ms, page_count, indexed_at)
149+
VALUES (@id, @collectionId, @path, @name, @contentHash, @size, @mtimeMs, @pageCount, @now)
150+
ON CONFLICT(collection_id, path) DO UPDATE SET
151+
content_hash = excluded.content_hash, size = excluded.size, mtime_ms = excluded.mtime_ms,
152+
page_count = excluded.page_count, indexed_at = excluded.indexed_at
153+
`).run({ ...input, now });
154+
}
155+
156+
export function deleteDocument(id: string): void {
157+
getDb().prepare(`DELETE FROM documents WHERE id = ?`).run(id);
158+
}
159+
160+
export function replaceChunks(documentId: string, collectionId: string, chunks: ChunkInput[]): void {
161+
const db = getDb();
162+
const del = db.prepare(`DELETE FROM chunks WHERE document_id = ?`);
163+
const insert = db.prepare(`
164+
INSERT INTO chunks (id, document_id, collection_id, ordinal, text, token_count, heading, page, start_line, end_line, embedding)
165+
VALUES (@id, @document_id, @collection_id, @ordinal, @text, @token_count, @heading, @page, @start_line, @end_line, @embedding)
166+
`);
167+
const tx = db.transaction((rows: ChunkInput[]) => {
168+
del.run(documentId);
169+
rows.forEach((chunk, ordinal) => {
170+
insert.run({
171+
id: `${documentId}:${ordinal}`, document_id: documentId, collection_id: collectionId, ordinal,
172+
text: chunk.text, token_count: chunk.tokenCount, heading: chunk.heading, page: chunk.page,
173+
start_line: chunk.startLine, end_line: chunk.endLine, embedding: encodeEmbedding(chunk.embedding),
174+
});
175+
});
176+
});
177+
tx(chunks);
178+
}
179+
180+
export function chunksForCollection(collectionId: string): (ChunkRow & { doc_path: string; doc_name: string })[] {
181+
return getDb().prepare(`
182+
SELECT chunks.*, documents.path AS doc_path, documents.name AS doc_name
183+
FROM chunks JOIN documents ON documents.id = chunks.document_id
184+
WHERE chunks.collection_id = ?
185+
`).all(collectionId) as (ChunkRow & { doc_path: string; doc_name: string })[];
186+
}
187+
188+
export function countChunks(collectionId: string): number {
189+
const row = getDb().prepare(`SELECT COUNT(*) AS n FROM chunks WHERE collection_id = ?`).get(collectionId) as { n: number };
190+
return row.n;
191+
}

0 commit comments

Comments
 (0)