Files
cobble/src/functions/backend/db/bot-files/ingest-bot-file.ts
T
tbenandClaude Opus 5.5 e7b23b7454 Add knowledgebases
Knowledgebases give bots RAG over documents, scoped like extensions: to
particular bots, to a workspace, or to every bot, with their own button
beside Tools in the composer. Documents are chunked and embedded by the
file-ingest worker with the install's embedding model, which is now an
install-wide setting rather than a per-bot one.

- Word search runs on an FTS5 index ranked by BM25, with a scored LIKE
  fallback for scripts written without spaces.
- Changing the embedding model re-indexes documents automatically.
- Ingest jobs wait without spending attempts while every embedding model
  rests, fail at once on errors retrying can't fix, and keep passages
  searchable by words when the model refuses.
- Settings > Knowledgebases shows each knowledgebase's and document's
  index state: spinner, warning, grey check (words only) or green check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:41:01 +01:00

330 lines
10 KiB
TypeScript

import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_COBBLE_BOT_FILE_CHUNKS,
BUN_SQLITE_COBBLE_BOT_FILES,
BunSQLiteTables,
} from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import embedText from "@/src/harness/embeddings/embed-text";
import HarnessError from "@/src/harness/harness-error";
import { grabHarnessUsageScope } from "@/src/harness/usage/harness-usage-scope";
import type { HarnessUsageScope } from "@/src/harness/usage/types";
import runWithFallbackScope from "@/src/harness/usage/run-with-fallback-scope";
import grabUsageOwnerUserId from "@/src/functions/backend/db/usage/grab-usage-owner";
import chunkText from "./chunk-text";
import extractBotFileText from "./extract-bot-file-text";
type Params = {
file_id: number | string;
/**
* Read images and audio too (a bot asking for it).
*/
read_media?: boolean;
signal?: AbortSignal;
};
export type IngestResult = {
/**
* `ingested`, `empty` (no text to read), `skipped` (too large) or
* `unavailable` (no embedding model), which keeps the file's text but
* leaves it searchable by words only.
*/
status: "ingested" | "empty" | "skipped" | "unavailable";
chunks: number;
};
/**
* Reads a bot file's text, splits it into chunks and embeds them into
* `bot_file_vectors`, replacing whatever was stored before. The chunks
* stay in `bot_file_chunks` even when there's no embedding model, so a
* search can still match words in them.
*
* Failures are recorded on the file (`ingest_error`) and rethrown, so the
* queue can retry.
*/
export default async function ingestBotFile({
file_id,
read_media,
signal,
}: Params): Promise<IngestResult> {
const file = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_FILES>({
sql: `SELECT * FROM bot_files WHERE id = ? LIMIT 1`,
values: [file_id],
}).then((res) => res.payload?.[0]);
if (!file?.id) return { status: "empty", chunks: 0 };
const media = file.media_id
? await BunSQLite.sql<{ media_write_path?: string; text_content?: string }>({
sql: `SELECT media_write_path, text_content FROM media WHERE id = ? LIMIT 1`,
values: [file.media_id],
}).then((res) => res.payload?.[0])
: undefined;
/**
* Decided on the file's size alone. A cached `text_content` is not a
* reason to read an oversized file: it was only ever a slice of it,
* and taking it as the whole document made a file too big to unpack
* look already read.
*/
if (Number(file.size) > AppData.IngestMaxBytes) {
await BunSQLite.sql({
sql: `UPDATE bot_files SET ingest_error = ?, updated_at = ? WHERE id = ?`,
values: [`Too large to read (over ${AppData.IngestMaxBytes / (1024 * 1024)} MB).`, Date.now(), file.id],
});
return { status: "skipped", chunks: 0 };
}
/**
* A bot's own run already carries its user. The ingest queue has no run
* behind it, so everything this file costs - reading an image or an
* audio file, embedding its chunks - is entered under the owner's scope
* instead: it spends real tokens and must show on the usage page.
*/
const owner_scope: HarnessUsageScope | null = grabHarnessUsageScope()?.user_id
? null
: await resolveOwnerScope({ file });
try {
const text = await runWithFallbackScope({
scope: owner_scope,
task: () =>
extractBotFileText({
write_path: media?.media_write_path,
text_content: media?.text_content,
name: file.name,
mime_type: file.mime_type,
read_media,
}),
});
await clearChunks({ file_id: file.id });
if (!text?.trim()) {
await markIngested({ file_id: file.id, chunks: 0 });
return { status: "empty", chunks: 0 };
}
const chunks = chunkText({ text });
if (!chunks.length) {
await markIngested({ file_id: file.id, chunks: 0 });
return { status: "empty", chunks: 0 };
}
const chunk_ids = await insertChunks({ file, chunks });
let embedded = 0;
try {
/**
* A big file's chunks go in batches, so one provider refusal
* doesn't throw away the whole file.
*/
for (let start = 0; start < chunks.length; start += AppData.EmbeddingBatchSize) {
const batch = chunks.slice(start, start + AppData.EmbeddingBatchSize);
const { vectors } = await runEmbed({
scope: owner_scope,
signal,
file,
batch,
});
for (let i = 0; i < vectors.length; i++) {
await insertVector({
vector: vectors[i]!,
chunk_id: chunk_ids[start + i]!,
file_id: file.id,
scope: String(file.scope),
owner_id: Number(file.owner_id),
});
embedded++;
}
}
} catch (error) {
if (error instanceof HarnessError && error.code == "unavailable") {
/**
* No embedding model: the chunks still let a search match
* words, so the file isn't a failure.
*/
await markIngested({
file_id: file.id,
chunks: chunks.length,
error: "No embedding model is set, so this file is searchable by words only.",
});
return { status: "unavailable", chunks: chunks.length };
}
throw error;
}
await markIngested({ file_id: file.id, chunks: chunks.length });
return { status: "ingested", chunks: embedded };
} catch (error) {
/**
* Every model resting means nothing was tried: the job waits for
* them, and the file keeps whatever it said before.
*/
if (error instanceof HarnessError && error.resting) throw error;
const message = error instanceof Error ? error.message : String(error);
await BunSQLite.sql({
sql: `UPDATE bot_files SET ingest_error = ?, updated_at = ? WHERE id = ?`,
values: [message.slice(0, 500), Date.now(), file.id],
}).catch(() => {});
throw error;
}
}
async function clearChunks({ file_id }: { file_id: number }) {
await BunSQLite.sql({ sql: `DELETE FROM bot_file_vectors WHERE file_id = ?`, values: [file_id] });
await BunSQLite.sql({ sql: `DELETE FROM bot_file_chunks WHERE file_id = ?`, values: [file_id] });
}
async function insertChunks({
file,
chunks,
}: {
file: BUN_SQLITE_COBBLE_BOT_FILES;
chunks: string[];
}) {
const ids: number[] = [];
for (let index = 0; index < chunks.length; index++) {
const text = chunks[index]!;
const res = await BunSQLite.insert<
BUN_SQLITE_COBBLE_BOT_FILE_CHUNKS,
(typeof BunSQLiteTables)[number]
>({
table: "bot_file_chunks",
data: [
{
file_id: file.id,
scope: file.scope as any,
owner_id: Number(file.owner_id),
chunk_index: index,
text,
token_estimate: Math.ceil(text.length / 4),
},
],
});
const id = res.postInsertReturn?.insertId;
if (id) ids.push(id);
}
return ids;
}
async function insertVector({
vector,
chunk_id,
file_id,
scope,
owner_id,
}: {
vector: Float32Array;
chunk_id: number;
file_id: number;
scope: string;
owner_id: number;
}) {
/**
* vec0's metadata columns (its id and timestamps) are NOT NULL, so a
* raw insert has to fill them; `BunSQLite.insert` would add them but
* the vector needs `vec_f32`.
*/
const now = Date.now();
const res = await BunSQLite.sql({
sql: `INSERT INTO bot_file_vectors (created_at, updated_at, embedding, chunk_id, file_id, scope, owner_id) VALUES (?, ?, vec_f32(?), ?, ?, ?, ?)`,
values: [now, now, vector as any, chunk_id, file_id, scope, owner_id],
});
if (!res.success) {
throw new Error(`Couldn't store an embedding: ${res.error || res.msg}`);
}
}
async function markIngested({
file_id,
chunks,
error,
}: {
file_id: number;
chunks: number;
error?: string;
}) {
const now = Date.now();
await BunSQLite.sql({
sql: `UPDATE bot_files SET ingested_at = ?, ingest_error = ?, updated_at = ? WHERE id = ?`,
values: [now, error || null, now, file_id],
});
await BunSQLite.sql({
sql: `UPDATE media SET is_rag_ingested = ?, updated_at = ? WHERE id = (SELECT media_id FROM bot_files WHERE id = ?)`,
values: [chunks > 0 ? 1 : 0, now, file_id],
}).catch(() => {});
}
/**
* One batch of a file's chunks, embedded under whatever attribution owns
* it: the run that asked for the ingest, else the file's owner.
*/
async function runEmbed({
scope,
signal,
file,
batch,
}: {
scope: HarnessUsageScope | null;
signal?: AbortSignal;
file: BUN_SQLITE_COBBLE_BOT_FILES;
batch: string[];
}) {
return await runWithFallbackScope({
scope,
task: () =>
embedText({
input: batch,
bot_id: file.bot_id!,
workspace_id: file.scope == "workspace" ? file.owner_id : null,
signal,
}),
});
}
/**
* The attribution a file the queue ingests reports under: its workspace's
* creator when it shares one, else its bot's.
*/
async function resolveOwnerScope({
file,
}: {
file: BUN_SQLITE_COBBLE_BOT_FILES;
}): Promise<HarnessUsageScope | null> {
const workspace_id = file.scope == "workspace" ? file.owner_id : null;
const user_id = await grabUsageOwnerUserId({ bot_id: file.bot_id, workspace_id });
if (!user_id) return null;
return {
user_id,
bot_id: file.scope == "bot" ? file.owner_id : file.bot_id,
workspace_id,
session_id: null,
message_id: null,
job_id: null,
};
}