| 1 | /** |
| 2 | * The semantic index: pages and projects' docs files as passages |
| 3 | * (src/chunks.ts) in D1 (`doc_chunks`, with full text in |
| 4 | * `doc_chunks_fts`) and as vectors in the vector store (src/vectors.ts). |
| 5 | * |
| 6 | * A page is indexed a little after its Markdown is saved (the room, |
| 7 | * src/room.ts, in the background, so editing never waits on it); a |
| 8 | * project's docs files after they are read (src/repo-spaces.ts). Only |
| 9 | * passages whose text changed are embedded; one that only moved keeps its |
| 10 | * vector. Embedding is capped per workspace and hour (EMBED_PER_HOUR): |
| 11 | * past the cap passages are kept for words, and a catch-up run on the |
| 12 | * queue embeds them later. A failure leaves them for the next save or run. |
| 13 | * Nothing here throws to its caller. |
| 14 | * |
| 15 | * The backfill indexes a workspace's existing pages and files in batches |
| 16 | * on the queue (`docs.index` jobs, on the artifacts service's own events queue). |
| 17 | */ |
| 18 | import { chunkId, chunkMarkdown, embedText, repoFileId, textHash } from "./chunks.ts"; |
| 19 | import { overFreeBudget } from "./embed-billing.ts"; |
| 20 | import { scopesOf, type FolioRow, FOLIO_COLUMNS } from "./folios/access-store.ts"; |
| 21 | import { kindModel } from "./kinds/index.ts"; |
| 22 | import { cloudflareEmbedder, cloudflareVectors, type Embedder, type VectorMetadata, type VectorStore } from "./vectors.ts"; |
| 23 | |
| 24 | /** Passages embedded per workspace per hour at most. Logged when reached. */ |
| 25 | export const EMBED_PER_HOUR = 200; |
| 26 | /** Documents one backfill job indexes before handing on to the next. */ |
| 27 | const BACKFILL_BATCH = 20; |
| 28 | /** A backfill not finished after this long is taken to have died and may start again. */ |
| 29 | const BACKFILL_STALE_MS = 2 * 60 * 60 * 1000; |
| 30 | /** How long a run waits when the hour's cap is reached. */ |
| 31 | const CAP_DELAY_SECONDS = 3600; |
| 32 | |
| 33 | /** |
| 34 | * A job on the queue: index more of a workspace's docs and folios; or |
| 35 | * bring a large folio subtree's access, rooms and index scope up to date |
| 36 | * after a move or a sharing change (src/folios/service.ts). |
| 37 | */ |
| 38 | export type DocsJob = { type: "docs.index"; workspace_id: string } | { type: "folios.reacl"; folio_id: string }; |
| 39 | |
| 40 | export type IndexEnv = { |
| 41 | DB: D1Database; |
| 42 | AI?: Ai; |
| 43 | VECTORS?: Vectorize; |
| 44 | /** Folios' semantic index, Vectorize `g1t-folios`. Optional: without it folios match words only. */ |
| 45 | FOLIO_VECTORS?: Vectorize; |
| 46 | JOBS?: Queue<DocsJob>; |
| 47 | }; |
| 48 | |
| 49 | /** The embedder and store this deployment has; null without them (words only). */ |
| 50 | export function adapters(env: IndexEnv): { embedder: Embedder | null; store: VectorStore | null } { |
| 51 | return env.AI && env.VECTORS ? { embedder: cloudflareEmbedder(env.AI), store: cloudflareVectors(env.VECTORS) } : { embedder: null, store: null }; |
| 52 | } |
| 53 | |
| 54 | /** One document to index: a page, or a project's docs file. */ |
| 55 | export type IndexDoc = { |
| 56 | kind: "page" | "repo_file"; |
| 57 | /** The page's id, or the file's `rf_` id. */ |
| 58 | doc_id: string; |
| 59 | workspace_id: string; |
| 60 | space_id: string; |
| 61 | title: string; |
| 62 | markdown: string; |
| 63 | repo_id?: string | null; |
| 64 | path?: string | null; |
| 65 | }; |
| 66 | |
| 67 | type ChunkRow = { id: string; seq: number; space_id: string; hash: string; vector_hash: string | null }; |
| 68 | |
| 69 | const hourOf = (now: Date) => now.toISOString().slice(0, 13); |
| 70 | |
| 71 | function metadataOf(doc: IndexDoc): VectorMetadata { |
| 72 | return doc.kind === "page" |
| 73 | ? { workspace_id: doc.workspace_id, space_id: doc.space_id, kind: "page", page_id: doc.doc_id } |
| 74 | : { workspace_id: doc.workspace_id, space_id: doc.space_id, kind: "repo_file", repo_file_id: doc.doc_id, repo_id: doc.repo_id ?? "" }; |
| 75 | } |
| 76 | |
| 77 | /** |
| 78 | * Passages the workspace may still embed this hour; none once a workspace |
| 79 | * without the plan has used its free month (src/embed-billing.ts). |
| 80 | */ |
| 81 | async function allowance(db: D1Database, workspaceId: string, now: Date): Promise<number> { |
| 82 | if (await overFreeBudget(db, workspaceId, now)) { |
| 83 | console.log("embedding budget of a free workspace used for the month", workspaceId); |
| 84 | return 0; |
| 85 | } |
| 86 | const used = await db.prepare("SELECT chunks FROM doc_embed_usage WHERE workspace_id = ? AND hour = ?").bind(workspaceId, hourOf(now)).first<{ chunks: number }>(); |
| 87 | return Math.max(0, EMBED_PER_HOUR - (used?.chunks ?? 0)); |
| 88 | } |
| 89 | |
| 90 | async function meter(db: D1Database, workspaceId: string, chunks: number, chars: number, now: Date): Promise<void> { |
| 91 | if (!chunks) return; |
| 92 | await db |
| 93 | .prepare( |
| 94 | "INSERT INTO doc_embed_usage (workspace_id, hour, chunks, tokens) VALUES (?1, ?2, ?3, ?4) ON CONFLICT (workspace_id, hour) DO UPDATE SET chunks = chunks + ?3, tokens = tokens + ?4", |
| 95 | ) |
| 96 | .bind(workspaceId, hourOf(now), chunks, Math.ceil(chars / 4)) |
| 97 | .run(); |
| 98 | } |
| 99 | |
| 100 | /** |
| 101 | * Brings one document's passages up to date: rows and full text in D1, |
| 102 | * vectors for those that changed (within the hour's cap). `capped` when |
| 103 | * some were left for later. |
| 104 | */ |
| 105 | export async function indexDoc( |
| 106 | db: D1Database, |
| 107 | embedder: Embedder | null, |
| 108 | store: VectorStore | null, |
| 109 | doc: IndexDoc, |
| 110 | now = new Date(), |
| 111 | ): Promise<{ chunks: number; embedded: number; capped: boolean }> { |
| 112 | const at = now.toISOString(); |
| 113 | const chunks = chunkMarkdown(doc.markdown, doc.title).map((c) => { |
| 114 | const embed = embedText(doc.title, c); |
| 115 | return { ...c, id: chunkId(doc.doc_id, c.seq), embed, hash: textHash(embed) }; |
| 116 | }); |
| 117 | const column = doc.kind === "page" ? "page_id" : "repo_file_id"; |
| 118 | const old = (await db.prepare(`SELECT id, seq, space_id, hash, vector_hash FROM doc_chunks WHERE ${column} = ?`).bind(doc.doc_id).all<ChunkRow>()).results; |
| 119 | const byId = new Map(old.map((r) => [r.id, r])); |
| 120 | const statements: D1PreparedStatement[] = []; |
| 121 | for (const c of chunks) { |
| 122 | const was = byId.get(c.id); |
| 123 | if (was && was.hash === c.hash && was.space_id === doc.space_id) continue; |
| 124 | statements.push( |
| 125 | db |
| 126 | .prepare( |
| 127 | `INSERT INTO doc_chunks (id, workspace_id, space_id, page_id, repo_file_id, repo_id, path, seq, heading, text, hash, vector_hash, updated_at) |
| 128 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?) |
| 129 | ON CONFLICT (id) DO UPDATE SET space_id = excluded.space_id, repo_id = excluded.repo_id, path = excluded.path, heading = excluded.heading, text = excluded.text, hash = excluded.hash, updated_at = excluded.updated_at`, |
| 130 | ) |
| 131 | .bind( |
| 132 | c.id, |
| 133 | doc.workspace_id, |
| 134 | doc.space_id, |
| 135 | doc.kind === "page" ? doc.doc_id : null, |
| 136 | doc.kind === "repo_file" ? doc.doc_id : null, |
| 137 | doc.repo_id ?? null, |
| 138 | doc.path ?? null, |
| 139 | c.seq, |
| 140 | c.heading, |
| 141 | c.text, |
| 142 | c.hash, |
| 143 | at, |
| 144 | ), |
| 145 | db.prepare("DELETE FROM doc_chunks_fts WHERE chunk_id = ?").bind(c.id), |
| 146 | db.prepare("INSERT INTO doc_chunks_fts (chunk_id, space_id, doc_id, heading, text) VALUES (?, ?, ?, ?, ?)").bind(c.id, doc.space_id, doc.doc_id, c.heading ?? "", c.text), |
| 147 | ); |
| 148 | } |
| 149 | const keep = new Set(chunks.map((c) => c.id)); |
| 150 | const gone = old.filter((r) => !keep.has(r.id)); |
| 151 | for (const r of gone) { |
| 152 | statements.push(db.prepare("DELETE FROM doc_chunks WHERE id = ?").bind(r.id), db.prepare("DELETE FROM doc_chunks_fts WHERE chunk_id = ?").bind(r.id)); |
| 153 | } |
| 154 | for (let i = 0; i < statements.length; i += 60) await db.batch(statements.slice(i, i + 60)); |
| 155 | |
| 156 | if (!store || !embedder) return { chunks: chunks.length, embedded: 0, capped: false }; |
| 157 | const result = await embedChanged(db, embedder, store, doc, chunks, old, byId, now); |
| 158 | // Last, so a passage that moved from a place now gone could take its vector first. |
| 159 | try { |
| 160 | if (gone.some((r) => r.vector_hash)) await store.delete(gone.filter((r) => r.vector_hash).map((r) => r.id)); |
| 161 | } catch (error) { |
| 162 | console.error("docs could not drop old passages from the index", doc.doc_id, String(error)); |
| 163 | } |
| 164 | return result; |
| 165 | } |
| 166 | |
| 167 | type IndexedChunk = { id: string; seq: number; heading: string | null; text: string; embed: string; hash: string }; |
| 168 | |
| 169 | /** Vectors for the passages that need one: reused when the index already holds their text, embedded otherwise (within the cap). */ |
| 170 | async function embedChanged( |
| 171 | db: D1Database, |
| 172 | embedder: Embedder, |
| 173 | store: VectorStore, |
| 174 | doc: IndexDoc, |
| 175 | chunks: IndexedChunk[], |
| 176 | old: ChunkRow[], |
| 177 | byId: Map<string, ChunkRow>, |
| 178 | now: Date, |
| 179 | ): Promise<{ chunks: number; embedded: number; capped: boolean }> { |
| 180 | // What needs a vector: a passage whose text the index doesn't hold under its id, or one whose space changed. |
| 181 | const stale = chunks.filter((c) => { |
| 182 | const was = byId.get(c.id); |
| 183 | return !was || was.vector_hash !== c.hash || was.space_id !== doc.space_id; |
| 184 | }); |
| 185 | if (!stale.length) return { chunks: chunks.length, embedded: 0, capped: false }; |
| 186 | // A passage that only moved (or changed space) has its vector already, under another id or this one. |
| 187 | const held = new Map<string, string>(); |
| 188 | for (const r of old) if (r.vector_hash) held.set(r.vector_hash, r.id); |
| 189 | const reuse = stale.filter((c) => held.has(c.hash)); |
| 190 | const fresh = stale.filter((c) => !held.has(c.hash)); |
| 191 | const budget = fresh.length ? await allowance(db, doc.workspace_id, now) : 0; |
| 192 | const embedNow = fresh.slice(0, budget); |
| 193 | const capped = embedNow.length < fresh.length; |
| 194 | if (capped) console.log("docs embedding cap reached", doc.workspace_id, `${fresh.length - embedNow.length} passages wait for the next hour`); |
| 195 | const metadata = metadataOf(doc); |
| 196 | const done: { id: string; hash: string }[] = []; |
| 197 | try { |
| 198 | const vectors: { id: string; values: number[]; metadata: VectorMetadata }[] = []; |
| 199 | if (reuse.length) { |
| 200 | const values = new Map((await store.get([...new Set(reuse.map((c) => held.get(c.hash)!))])).map((v) => [v.id, v.values])); |
| 201 | for (const c of reuse) { |
| 202 | const v = values.get(held.get(c.hash)!); |
| 203 | if (v) vectors.push({ id: c.id, values: v, metadata }); |
| 204 | } |
| 205 | } |
| 206 | if (embedNow.length) { |
| 207 | const embedded = await embedder.embed(embedNow.map((c) => c.embed)); |
| 208 | embedNow.forEach((c, i) => vectors.push({ id: c.id, values: embedded[i]!, metadata })); |
| 209 | await meter( |
| 210 | db, |
| 211 | doc.workspace_id, |
| 212 | embedNow.length, |
| 213 | embedNow.reduce((n, c) => n + c.embed.length, 0), |
| 214 | now, |
| 215 | ); |
| 216 | } |
| 217 | if (vectors.length) await store.upsert(vectors); |
| 218 | const hashOf = new Map(chunks.map((c) => [c.id, c.hash])); |
| 219 | for (const v of vectors) done.push({ id: v.id, hash: hashOf.get(v.id)! }); |
| 220 | } catch (error) { |
| 221 | console.error("docs could not embed passages; the next save tries again", doc.doc_id, String(error)); |
| 222 | } |
| 223 | if (done.length) { |
| 224 | const updates = done.map((d) => db.prepare("UPDATE doc_chunks SET vector_hash = ? WHERE id = ?").bind(d.hash, d.id)); |
| 225 | for (let i = 0; i < updates.length; i += 60) await db.batch(updates.slice(i, i + 60)); |
| 226 | } |
| 227 | return { chunks: chunks.length, embedded: embedNow.length, capped }; |
| 228 | } |
| 229 | |
| 230 | /** Takes passages out of D1 and the index: by page ids, file ids, or a whole space. */ |
| 231 | export async function forgetDocs(env: IndexEnv, which: { page_ids?: string[]; repo_file_ids?: string[]; space_id?: string }): Promise<void> { |
| 232 | const db = env.DB; |
| 233 | const { store } = adapters(env); |
| 234 | try { |
| 235 | const found: string[] = []; |
| 236 | const select = async (column: string, values: string[]) => { |
| 237 | for (let i = 0; i < values.length; i += 90) { |
| 238 | const part = values.slice(i, i + 90); |
| 239 | const rows = await db.prepare(`SELECT id FROM doc_chunks WHERE ${column} IN (${part.map(() => "?").join(",")})`).bind(...part).all<{ id: string }>(); |
| 240 | found.push(...rows.results.map((r) => r.id)); |
| 241 | } |
| 242 | }; |
| 243 | if (which.page_ids?.length) await select("page_id", which.page_ids); |
| 244 | if (which.repo_file_ids?.length) await select("repo_file_id", which.repo_file_ids); |
| 245 | if (which.space_id) await select("space_id", [which.space_id]); |
| 246 | if (!found.length) return; |
| 247 | if (store) { |
| 248 | try { |
| 249 | await store.delete(found); |
| 250 | } catch (error) { |
| 251 | console.error("docs could not drop passages from the index", found.length, String(error)); |
| 252 | } |
| 253 | } |
| 254 | const statements = found.flatMap((id) => [db.prepare("DELETE FROM doc_chunks WHERE id = ?").bind(id), db.prepare("DELETE FROM doc_chunks_fts WHERE chunk_id = ?").bind(id)]); |
| 255 | for (let i = 0; i < statements.length; i += 80) await db.batch(statements.slice(i, i + 80)); |
| 256 | } catch (error) { |
| 257 | console.error("docs could not forget passages", String(error)); |
| 258 | } |
| 259 | } |
| 260 | |
| 261 | type PageForIndex = { id: string; workspace_id: string; space_id: string; title: string; markdown: string; archived_at: string | null }; |
| 262 | |
| 263 | /** Indexes one page as it is saved now; forgets it when it is gone or in the trash. Never throws. */ |
| 264 | export async function indexPage(env: IndexEnv, pageId: string, now = new Date()): Promise<{ capped: boolean }> { |
| 265 | try { |
| 266 | const page = await env.DB.prepare("SELECT id, workspace_id, space_id, title, markdown, archived_at FROM pages WHERE id = ?").bind(pageId).first<PageForIndex>(); |
| 267 | if (!page || page.archived_at) { |
| 268 | await forgetDocs(env, { page_ids: [pageId] }); |
| 269 | return { capped: false }; |
| 270 | } |
| 271 | const { embedder, store } = adapters(env); |
| 272 | const result = await indexDoc(env.DB, embedder, store, { kind: "page", doc_id: page.id, workspace_id: page.workspace_id, space_id: page.space_id, title: page.title, markdown: page.markdown }, now); |
| 273 | if (result.capped) await startBackfill(env, page.workspace_id, { delaySeconds: CAP_DELAY_SECONDS }); |
| 274 | return { capped: result.capped }; |
| 275 | } catch (error) { |
| 276 | console.error("docs could not index a page", pageId, String(error)); |
| 277 | return { capped: false }; |
| 278 | } |
| 279 | } |
| 280 | |
| 281 | type RepoFileForIndex = { space_id: string; path: string; title: string; markdown: string; workspace_id: string; repo_id: string }; |
| 282 | |
| 283 | function repoDoc(row: RepoFileForIndex): IndexDoc { |
| 284 | return { kind: "repo_file", doc_id: repoFileId(row.space_id, row.path), workspace_id: row.workspace_id, space_id: row.space_id, title: row.title, markdown: row.markdown, repo_id: row.repo_id, path: row.path }; |
| 285 | } |
| 286 | |
| 287 | /** |
| 288 | * Indexes a project's docs files after they were read: `changed` paths |
| 289 | * again, `gone` paths forgotten. Never throws. |
| 290 | */ |
| 291 | export async function indexRepoFiles(env: IndexEnv, spaceId: string, changed: string[], gone: string[], now = new Date()): Promise<void> { |
| 292 | try { |
| 293 | if (gone.length) await forgetDocs(env, { repo_file_ids: gone.map((p) => repoFileId(spaceId, p)) }); |
| 294 | if (!changed.length) return; |
| 295 | const { embedder, store } = adapters(env); |
| 296 | let capped = false; |
| 297 | let workspaceId: string | null = null; |
| 298 | for (let i = 0; i < changed.length; i += 50) { |
| 299 | const part = changed.slice(i, i + 50); |
| 300 | const rows = ( |
| 301 | await env.DB.prepare( |
| 302 | `SELECT f.space_id, f.path, f.title, f.markdown, s.workspace_id, s.repo_id FROM repo_files f JOIN repo_spaces s ON s.id = f.space_id WHERE f.space_id = ? AND f.path IN (${part.map(() => "?").join(",")})`, |
| 303 | ) |
| 304 | .bind(spaceId, ...part) |
| 305 | .all<RepoFileForIndex>() |
| 306 | ).results; |
| 307 | for (const row of rows) { |
| 308 | workspaceId = row.workspace_id; |
| 309 | // Past the cap, the rest are only kept for words until the catch-up run. |
| 310 | const result = await indexDoc(env.DB, capped ? null : embedder, capped ? null : store, repoDoc(row), now); |
| 311 | capped ||= result.capped; |
| 312 | } |
| 313 | } |
| 314 | if (capped && workspaceId) await startBackfill(env, workspaceId, { delaySeconds: CAP_DELAY_SECONDS }); |
| 315 | } catch (error) { |
| 316 | console.error("docs could not index a project's docs", spaceId, String(error)); |
| 317 | } |
| 318 | } |
| 319 | |
| 320 | /** |
| 321 | * Starts (or, with `force`, restarts) indexing a workspace's existing |
| 322 | * pages and projects' docs on the queue. Does nothing while a run is |
| 323 | * going, unless it has been going so long it must have died. Without a |
| 324 | * queue, runs one batch now. True when a run was started. |
| 325 | */ |
| 326 | export async function startBackfill(env: IndexEnv, workspaceId: string, options: { force?: boolean; delaySeconds?: number } = {}): Promise<boolean> { |
| 327 | const at = new Date(); |
| 328 | const staleBefore = new Date(at.getTime() - BACKFILL_STALE_MS).toISOString(); |
| 329 | const started = await env.DB.prepare( |
| 330 | `INSERT INTO doc_index_runs (workspace_id, started_at, finished_at, cursor, pages, files) VALUES (?1, ?2, NULL, NULL, 0, 0) |
| 331 | ON CONFLICT (workspace_id) DO UPDATE SET started_at = ?2, finished_at = NULL, cursor = NULL, pages = 0, files = 0 |
| 332 | WHERE ?3 OR doc_index_runs.finished_at IS NOT NULL OR doc_index_runs.started_at < ?4 |
| 333 | RETURNING workspace_id`, |
| 334 | ) |
| 335 | .bind(workspaceId, at.toISOString(), options.force ? 1 : 0, staleBefore) |
| 336 | .first<{ workspace_id: string }>(); |
| 337 | if (!started) return false; |
| 338 | await enqueue(env, workspaceId, options.delaySeconds ?? 0); |
| 339 | return true; |
| 340 | } |
| 341 | |
| 342 | async function enqueue(env: IndexEnv, workspaceId: string, delaySeconds: number): Promise<void> { |
| 343 | if (env.JOBS) { |
| 344 | await env.JOBS.send({ type: "docs.index", workspace_id: workspaceId }, delaySeconds ? { delaySeconds } : undefined); |
| 345 | return; |
| 346 | } |
| 347 | // No queue (self-hosted without one): one batch now; saves and later recalls carry on from there. |
| 348 | if (!delaySeconds) await runBackfill(env, workspaceId, false); |
| 349 | } |
| 350 | |
| 351 | /** |
| 352 | * One batch of a workspace's backfill: pages by id, then projects' docs |
| 353 | * files by space and path, from the run's cursor. Hands on to the next |
| 354 | * batch (or waits out the hour's cap) through the queue. |
| 355 | */ |
| 356 | export async function runBackfill(env: IndexEnv, workspaceId: string, chain = true): Promise<{ done: boolean }> { |
| 357 | const run = await env.DB.prepare("SELECT cursor, finished_at, pages, files FROM doc_index_runs WHERE workspace_id = ?").bind(workspaceId).first<{ cursor: string | null; finished_at: string | null; pages: number; files: number }>(); |
| 358 | if (!run || run.finished_at) return { done: true }; |
| 359 | const { embedder, store } = adapters(env); |
| 360 | const cursor = run.cursor ?? "p:"; |
| 361 | let next: string | null = cursor; |
| 362 | let pages = 0; |
| 363 | let files = 0; |
| 364 | let capped = false; |
| 365 | if (cursor.startsWith("o:")) { |
| 366 | // Folios, after pages and before projects' docs. |
| 367 | const rows = ( |
| 368 | await env.DB.prepare(`SELECT ${FOLIO_COLUMNS} FROM folios WHERE workspace_id = ? AND trashed_at IS NULL AND id > ? ORDER BY id LIMIT ?`) |
| 369 | .bind(workspaceId, cursor.slice(2), BACKFILL_BATCH) |
| 370 | .all<FolioRow>() |
| 371 | ).results; |
| 372 | for (const row of rows) { |
| 373 | const result = await indexFolio(env, row.id); |
| 374 | pages++; |
| 375 | if (result.capped) { |
| 376 | capped = true; |
| 377 | break; |
| 378 | } |
| 379 | next = `o:${row.id}`; |
| 380 | } |
| 381 | if (!capped && rows.length < BACKFILL_BATCH) next = "f:"; |
| 382 | } else if (cursor.startsWith("p:")) { |
| 383 | const rows = ( |
| 384 | await env.DB.prepare("SELECT id, workspace_id, space_id, title, markdown, archived_at FROM pages WHERE workspace_id = ? AND archived_at IS NULL AND id > ? ORDER BY id LIMIT ?") |
| 385 | .bind(workspaceId, cursor.slice(2), BACKFILL_BATCH) |
| 386 | .all<PageForIndex>() |
| 387 | ).results; |
| 388 | for (const page of rows) { |
| 389 | const result = await indexDoc(env.DB, embedder, store, { kind: "page", doc_id: page.id, workspace_id: page.workspace_id, space_id: page.space_id, title: page.title, markdown: page.markdown }); |
| 390 | pages++; |
| 391 | if (result.capped) { |
| 392 | // This page again next time, after the hour: the cursor stays before it. |
| 393 | capped = true; |
| 394 | break; |
| 395 | } |
| 396 | next = `p:${page.id}`; |
| 397 | } |
| 398 | if (!capped && rows.length < BACKFILL_BATCH) next = "o:"; |
| 399 | } else { |
| 400 | const [space, path] = splitFileCursor(cursor.slice(2)); |
| 401 | const rows = ( |
| 402 | await env.DB.prepare( |
| 403 | `SELECT f.space_id, f.path, f.title, f.markdown, s.workspace_id, s.repo_id FROM repo_files f JOIN repo_spaces s ON s.id = f.space_id |
| 404 | WHERE s.workspace_id = ? AND (f.space_id > ? OR (f.space_id = ? AND f.path > ?)) ORDER BY f.space_id, f.path LIMIT ?`, |
| 405 | ) |
| 406 | .bind(workspaceId, space, space, path, BACKFILL_BATCH) |
| 407 | .all<RepoFileForIndex>() |
| 408 | ).results; |
| 409 | for (const row of rows) { |
| 410 | const result = await indexDoc(env.DB, embedder, store, repoDoc(row)); |
| 411 | files++; |
| 412 | if (result.capped) { |
| 413 | capped = true; |
| 414 | break; |
| 415 | } |
| 416 | next = `f:${row.space_id}\n${row.path}`; |
| 417 | } |
| 418 | if (!capped && rows.length < BACKFILL_BATCH) next = null; |
| 419 | } |
| 420 | await env.DB.prepare("UPDATE doc_index_runs SET cursor = ?, pages = pages + ?, files = files + ?, finished_at = ? WHERE workspace_id = ?") |
| 421 | .bind(next, pages, files, next === null ? new Date().toISOString() : null, workspaceId) |
| 422 | .run(); |
| 423 | if (next === null) return { done: true }; |
| 424 | if (chain && env.JOBS) await enqueue(env, workspaceId, capped ? CAP_DELAY_SECONDS : 0); |
| 425 | return { done: false }; |
| 426 | } |
| 427 | |
| 428 | function splitFileCursor(cursor: string): [string, string] { |
| 429 | const at = cursor.indexOf("\n"); |
| 430 | // Empty: from the start. |
| 431 | return at < 0 ? ["", ""] : [cursor.slice(0, at), cursor.slice(at + 1)]; |
| 432 | } |
| 433 | |
| 434 | // ── Folios (Artifacts mode) ───────────────────────────────────────────── |
| 435 | |
| 436 | let saidNoFolioIndex = false; |
| 437 | |
| 438 | /** |
| 439 | * The embedder and folio index this deployment has. Without Workers AI or |
| 440 | * the `g1t-folios` index (FOLIO_VECTORS, which is made by hand before a |
| 441 | * deploy that uses it), folios keep their passages in D1 and recall and |
| 442 | * search match words; that is said once per isolate. |
| 443 | */ |
| 444 | export function folioAdapters(env: IndexEnv): { embedder: Embedder | null; store: VectorStore | null } { |
| 445 | if (env.AI && env.FOLIO_VECTORS) return { embedder: cloudflareEmbedder(env.AI), store: cloudflareVectors(env.FOLIO_VECTORS) }; |
| 446 | if (!saidNoFolioIndex) { |
| 447 | saidNoFolioIndex = true; |
| 448 | console.log(`folios: no ${env.AI ? "FOLIO_VECTORS (Vectorize g1t-folios)" : "AI"} binding, so artifacts are searched and recalled by their words only`); |
| 449 | } |
| 450 | return { embedder: null, store: null }; |
| 451 | } |
| 452 | |
| 453 | type FolioChunkRow = { id: string; seq: number; scope: string; hash: string; vector_hash: string | null }; |
| 454 | |
| 455 | /** |
| 456 | * Brings one folio's passages up to date, as `indexDoc` does for pages: |
| 457 | * rows and full text in D1, under the folio's index scope, and vectors |
| 458 | * for passages whose text or scope changed (a passage whose text the |
| 459 | * index already holds keeps its vector; only its metadata is written |
| 460 | * again). Forgets it when it is gone or in the trash. Never throws. |
| 461 | */ |
| 462 | export async function indexFolio(env: IndexEnv, folioId: string, now = new Date()): Promise<{ capped: boolean }> { |
| 463 | try { |
| 464 | const db = env.DB; |
| 465 | const row = await db.prepare(`SELECT ${FOLIO_COLUMNS.replace("'' AS text", "text")} FROM folios WHERE id = ?`).bind(folioId).first<FolioRow>(); |
| 466 | if (!row || row.trashed_at) { |
| 467 | await forgetFolios(env, [folioId]); |
| 468 | return { capped: false }; |
| 469 | } |
| 470 | const scope = (await scopesOf(db, [row])).get(row.id) ?? `folio:${row.acl_root}`; |
| 471 | const model = kindModel(row.kind); |
| 472 | const at = now.toISOString(); |
| 473 | const chunks = (model ? model.chunks(row.text, row.title) : chunkMarkdown(row.text, row.title)).map((c) => { |
| 474 | const embed = embedText(row.title, c); |
| 475 | return { ...c, id: chunkId(row.id, c.seq), embed, hash: textHash(embed) }; |
| 476 | }); |
| 477 | const old = (await db.prepare("SELECT id, seq, scope, hash, vector_hash FROM folio_chunks WHERE folio_id = ?").bind(row.id).all<FolioChunkRow>()).results; |
| 478 | const byId = new Map(old.map((r) => [r.id, r])); |
| 479 | const statements: D1PreparedStatement[] = []; |
| 480 | for (const c of chunks) { |
| 481 | const was = byId.get(c.id); |
| 482 | if (was && was.hash === c.hash && was.scope === scope) continue; |
| 483 | statements.push( |
| 484 | db |
| 485 | .prepare( |
| 486 | `INSERT INTO folio_chunks (id, workspace_id, folio_id, kind, scope, seq, heading, text, hash, vector_hash, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?) |
| 487 | ON CONFLICT (id) DO UPDATE SET kind = excluded.kind, scope = excluded.scope, heading = excluded.heading, text = excluded.text, hash = excluded.hash, updated_at = excluded.updated_at`, |
| 488 | ) |
| 489 | .bind(c.id, row.workspace_id, row.id, row.kind, scope, c.seq, c.heading, c.text, c.hash, at), |
| 490 | db.prepare("DELETE FROM folio_chunks_fts WHERE chunk_id = ?").bind(c.id), |
| 491 | db.prepare("INSERT INTO folio_chunks_fts (chunk_id, scope, folio_id, heading, text) VALUES (?, ?, ?, ?, ?)").bind(c.id, scope, row.id, c.heading ?? "", c.text), |
| 492 | ); |
| 493 | } |
| 494 | const keep = new Set(chunks.map((c) => c.id)); |
| 495 | const gone = old.filter((r) => !keep.has(r.id)); |
| 496 | for (const r of gone) statements.push(db.prepare("DELETE FROM folio_chunks WHERE id = ?").bind(r.id), db.prepare("DELETE FROM folio_chunks_fts WHERE chunk_id = ?").bind(r.id)); |
| 497 | for (let i = 0; i < statements.length; i += 60) await db.batch(statements.slice(i, i + 60)); |
| 498 | |
| 499 | const { embedder, store } = folioAdapters(env); |
| 500 | if (!embedder || !store) return { capped: false }; |
| 501 | // What needs a vector written: text the index doesn't hold under this id, or a new scope. |
| 502 | const stale = chunks.filter((c) => { |
| 503 | const was = byId.get(c.id); |
| 504 | return !was || was.vector_hash !== c.hash || was.scope !== scope; |
| 505 | }); |
| 506 | let capped = false; |
| 507 | if (stale.length) { |
| 508 | const held = new Map<string, string>(); |
| 509 | for (const r of old) if (r.vector_hash) held.set(r.vector_hash, r.id); |
| 510 | const reuse = stale.filter((c) => held.has(c.hash)); |
| 511 | const fresh = stale.filter((c) => !held.has(c.hash)); |
| 512 | const budget = fresh.length ? await allowance(db, row.workspace_id, now) : 0; |
| 513 | const embedNow = fresh.slice(0, budget); |
| 514 | capped = embedNow.length < fresh.length; |
| 515 | if (capped) console.log("folios embedding cap reached", row.workspace_id, `${fresh.length - embedNow.length} passages wait for the next hour`); |
| 516 | const metadata: VectorMetadata = { workspace_id: row.workspace_id, scope, kind: row.kind, folio_id: row.id }; |
| 517 | const done: { id: string; hash: string }[] = []; |
| 518 | try { |
| 519 | const vectors: { id: string; values: number[]; metadata: VectorMetadata }[] = []; |
| 520 | if (reuse.length) { |
| 521 | const values = new Map((await store.get([...new Set(reuse.map((c) => held.get(c.hash)!))])).map((v) => [v.id, v.values])); |
| 522 | for (const c of reuse) { |
| 523 | const v = values.get(held.get(c.hash)!); |
| 524 | if (v) vectors.push({ id: c.id, values: v, metadata }); |
| 525 | } |
| 526 | } |
| 527 | if (embedNow.length) { |
| 528 | const embedded = await embedder.embed(embedNow.map((c) => c.embed)); |
| 529 | embedNow.forEach((c, i) => vectors.push({ id: c.id, values: embedded[i]!, metadata })); |
| 530 | await meter( |
| 531 | db, |
| 532 | row.workspace_id, |
| 533 | embedNow.length, |
| 534 | embedNow.reduce((n, c) => n + c.embed.length, 0), |
| 535 | now, |
| 536 | ); |
| 537 | } |
| 538 | if (vectors.length) await store.upsert(vectors); |
| 539 | const hashOf = new Map(chunks.map((c) => [c.id, c.hash])); |
| 540 | for (const v of vectors) done.push({ id: v.id, hash: hashOf.get(v.id)! }); |
| 541 | } catch (error) { |
| 542 | console.error("folios could not embed passages; the next save tries again", row.id, String(error)); |
| 543 | } |
| 544 | if (done.length) { |
| 545 | const updates = done.map((d) => db.prepare("UPDATE folio_chunks SET vector_hash = ? WHERE id = ?").bind(d.hash, d.id)); |
| 546 | for (let i = 0; i < updates.length; i += 60) await db.batch(updates.slice(i, i + 60)); |
| 547 | } |
| 548 | } |
| 549 | try { |
| 550 | if (gone.some((r) => r.vector_hash)) await store.delete(gone.filter((r) => r.vector_hash).map((r) => r.id)); |
| 551 | } catch (error) { |
| 552 | console.error("folios could not drop old passages from the index", row.id, String(error)); |
| 553 | } |
| 554 | if (capped) await startBackfill(env, row.workspace_id, { delaySeconds: CAP_DELAY_SECONDS }); |
| 555 | return { capped }; |
| 556 | } catch (error) { |
| 557 | console.error("folios could not index", folioId, String(error)); |
| 558 | return { capped: false }; |
| 559 | } |
| 560 | } |
| 561 | |
| 562 | /** Takes folios' passages out of D1 and the index. Never throws. */ |
| 563 | export async function forgetFolios(env: IndexEnv, ids: readonly string[]): Promise<void> { |
| 564 | if (!ids.length) return; |
| 565 | const db = env.DB; |
| 566 | try { |
| 567 | const found: string[] = []; |
| 568 | for (let i = 0; i < ids.length; i += 500) { |
| 569 | const rows = await db |
| 570 | .prepare("SELECT id FROM folio_chunks WHERE folio_id IN (SELECT value FROM json_each(?))") |
| 571 | .bind(JSON.stringify(ids.slice(i, i + 500))) |
| 572 | .all<{ id: string }>(); |
| 573 | found.push(...rows.results.map((r) => r.id)); |
| 574 | } |
| 575 | if (!found.length) return; |
| 576 | const { store } = folioAdapters(env); |
| 577 | if (store) { |
| 578 | try { |
| 579 | await store.delete(found); |
| 580 | } catch (error) { |
| 581 | console.error("folios could not drop passages from the index", found.length, String(error)); |
| 582 | } |
| 583 | } |
| 584 | const statements = found.flatMap((id) => [db.prepare("DELETE FROM folio_chunks WHERE id = ?").bind(id), db.prepare("DELETE FROM folio_chunks_fts WHERE chunk_id = ?").bind(id)]); |
| 585 | for (let i = 0; i < statements.length; i += 80) await db.batch(statements.slice(i, i + 80)); |
| 586 | } catch (error) { |
| 587 | console.error("folios could not forget passages", String(error)); |
| 588 | } |
| 589 | } |