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]>
This commit is contained in:
2026-10-26 16:41:01 +01:00
co-authored by Claude Opus 5.5
parent 2259b6895f
commit e7b23b7454
111 changed files with 4573 additions and 148 deletions
@@ -166,6 +166,12 @@ export default async function ingestBotFile({
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({
@@ -5,7 +5,7 @@ type Params = {
};
/**
* Gives a new bot every MCP server, tool and skill set to "every bot".
* Gives a new bot every MCP server, tool, skill and knowledgebase set to "every bot".
* Called when a bot is created; it can drop any of them afterwards.
*/
export default async function attachDefaultExtensions({ bot_id }: Params) {
@@ -28,4 +28,10 @@ export default async function attachDefaultExtensions({ bot_id }: Params) {
SELECT ?, ?, ?, id FROM skills WHERE all_bots = 1`,
values: [now, now, Number(bot_id)],
});
await BunSQLite.sql({
sql: `INSERT OR IGNORE INTO bot_knowledgebases (created_at, updated_at, bot_id, knowledgebase_id)
SELECT ?, ?, ?, id FROM knowledgebases WHERE all_bots = 1`,
values: [now, now, Number(bot_id)],
});
}
@@ -24,6 +24,13 @@ export const ExtensionTables = {
column: "skill_id",
label: "skill",
},
knowledgebase: {
table: "knowledgebases",
bot_link: "bot_knowledgebases",
workspace_link: "workspace_knowledgebases",
column: "knowledgebase_id",
label: "knowledgebase",
},
} as const;
export type ExtensionKind = keyof typeof ExtensionTables;
@@ -1,10 +1,12 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_COBBLE_CUSTOM_TOOLS,
BUN_SQLITE_COBBLE_KNOWLEDGEBASES,
BUN_SQLITE_COBBLE_MCP_SERVERS,
BUN_SQLITE_COBBLE_SKILLS,
} from "@/db/types/db";
import botExtensionsSql from "@/src/utils/bot-extensions-sql";
import parseKnowledgebaseRow from "../knowledgebases/parse-knowledgebase-row";
import parseCustomToolRow from "./parse-custom-tool-row";
import parseMcpServerRow from "./parse-mcp-server-row";
import parseSkillRow from "./parse-skill-row";
@@ -15,19 +17,23 @@ type Params = {
};
/**
* The MCP servers, custom tools and skills a bot gets on one run
* The MCP servers, custom tools, skills and knowledgebases a bot gets on one run
* (`botExtensionsSql`): every bot's, its own, and its workspace's there.
*/
export default async function grabBotExtensions({ bot_id, workspace_id }: Params) {
const [servers, tools, skills] = await Promise.all([
const [servers, tools, skills, knowledgebases] = await Promise.all([
BunSQLite.sql<BUN_SQLITE_COBBLE_MCP_SERVERS>(botExtensionsSql({ kind: "mcp", bot_id, workspace_id })),
BunSQLite.sql<BUN_SQLITE_COBBLE_CUSTOM_TOOLS>(botExtensionsSql({ kind: "tool", bot_id, workspace_id })),
BunSQLite.sql<BUN_SQLITE_COBBLE_SKILLS>(botExtensionsSql({ kind: "skill", bot_id, workspace_id })),
BunSQLite.sql<BUN_SQLITE_COBBLE_KNOWLEDGEBASES>(
botExtensionsSql({ kind: "knowledgebase", bot_id, workspace_id }),
),
]);
return {
servers: (servers.payload || []).map((row) => parseMcpServerRow({ row })),
tools: (tools.payload || []).map((row) => parseCustomToolRow({ row })),
skills: (skills.payload || []).map((row) => parseSkillRow({ row })),
knowledgebases: (knowledgebases.payload || []).map((row) => parseKnowledgebaseRow({ row })),
};
}
@@ -1,12 +1,14 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_COBBLE_CUSTOM_TOOLS,
BUN_SQLITE_COBBLE_KNOWLEDGEBASES,
BUN_SQLITE_COBBLE_MCP_SERVERS,
BUN_SQLITE_COBBLE_SKILLS,
} from "@/db/types/db";
import { CustomToolKinds } from "@/src/dict/custom-tool-dict";
import { McpTransports } from "@/src/dict/mcp-dict";
import type { BotExtension, BotExtensionsPayload } from "@/src/types";
import parseKnowledgebaseRow from "../knowledgebases/parse-knowledgebase-row";
import parseCustomToolRow from "./parse-custom-tool-row";
import parseMcpServerRow from "./parse-mcp-server-row";
import parseSkillRow from "./parse-skill-row";
@@ -28,7 +30,7 @@ type Params = {
type Linked<T> = T & { source: "bot" | "workspace" };
/**
* The MCP servers, tools and skills a bot or a workspace has, and the rest of
* The MCP servers, tools, skills and knowledgebases a bot or a workspace has, and the rest of
* them, which can be added (`grabBotExtensionsOverview`,
* `grabWorkspaceExtensionsOverview`).
*/
@@ -51,8 +53,10 @@ export default async function grabExtensionsOverview({
const mcp_query = linked("mcp_server_id", "bot_mcp_servers", "workspace_mcp_servers");
const tool_query = linked("custom_tool_id", "bot_custom_tools", "workspace_custom_tools");
const skill_query = linked("skill_id", "bot_skills", "workspace_skills");
const knowledgebase_query = linked("knowledgebase_id", "bot_knowledgebases", "workspace_knowledgebases");
const [servers, tools, skills, all_servers, all_tools, all_skills] = await Promise.all([
const [servers, tools, skills, knowledgebases, documents, all_servers, all_tools, all_skills, all_knowledgebases] =
await Promise.all([
BunSQLite.sql<Linked<BUN_SQLITE_COBBLE_MCP_SERVERS>>({
sql: mcp_query.sql.replace("%TABLE%", "mcp_servers"),
values: mcp_query.values,
@@ -65,6 +69,15 @@ export default async function grabExtensionsOverview({
sql: skill_query.sql.replace("%TABLE%", "skills"),
values: skill_query.values,
}),
BunSQLite.sql<Linked<BUN_SQLITE_COBBLE_KNOWLEDGEBASES>>({
sql: knowledgebase_query.sql.replace("%TABLE%", "knowledgebases"),
values: knowledgebase_query.values,
}),
BunSQLite.sql<{ knowledgebase_id: number; documents: number; failed: number }>({
sql: `SELECT knowledgebase_id, COUNT(*) AS documents,
SUM(CASE WHEN ingest_error IS NOT NULL AND COALESCE(chunk_count, 0) = 0 THEN 1 ELSE 0 END) AS failed
FROM knowledgebase_documents GROUP BY knowledgebase_id`,
}),
can_manage
? BunSQLite.sql<{ id: number; name: string }>({ sql: `SELECT id, name FROM mcp_servers ORDER BY name COLLATE NOCASE` })
: null,
@@ -76,8 +89,17 @@ export default async function grabExtensionsOverview({
sql: `SELECT id, name FROM skills ORDER BY name COLLATE NOCASE`,
})
: null,
can_manage
? BunSQLite.sql<{ id: number; name: string }>({
sql: `SELECT id, name FROM knowledgebases ORDER BY name COLLATE NOCASE`,
})
: null,
]);
const document_counts = new Map(
(documents.payload || []).map((row) => [Number(row.knowledgebase_id), row]),
);
const attached: BotExtension[] = [
...(servers.payload || []).map((row) => {
const server = parseMcpServerRow({ row });
@@ -123,6 +145,22 @@ export default async function grabExtensionsOverview({
source: row.source,
};
}),
...(knowledgebases.payload || []).map((row) => {
const knowledgebase = parseKnowledgebaseRow({ row });
const count = document_counts.get(knowledgebase.id);
const total = Number(count?.documents) || 0;
return {
kind: "knowledgebase" as const,
id: knowledgebase.id,
name: knowledgebase.name,
description: knowledgebase.description,
detail: `Knowledgebase · ${total} ${total == 1 ? "document" : "documents"}`,
state: Number(count?.failed) ? ("error" as const) : ("ok" as const),
enabled: knowledgebase.enabled,
source: row.source,
};
}),
];
/**
@@ -144,6 +182,9 @@ export default async function grabExtensionsOverview({
...(all_skills?.payload || [])
.filter((row) => !has("skill", Number(row.id)))
.map((row) => ({ kind: "skill" as const, id: Number(row.id), name: row.name })),
...(all_knowledgebases?.payload || [])
.filter((row) => !has("knowledgebase", Number(row.id)))
.map((row) => ({ kind: "knowledgebase" as const, id: Number(row.id), name: row.name })),
],
can_manage,
};
@@ -1,4 +1,10 @@
import { Harnesses } from "@/src/dict/harness-dict";
/**
* Only what a bot or workspace may set: install-wide harnesses are the
* defaults' alone.
*/
const OwnHarnesses = Harnesses.filter((harness) => !harness.install_wide);
import type { BotHarnessOverviewPayload } from "@/src/types";
import grabHarnessModelEntries from "./grab-harness-model-entries";
import grabHarnessOverviewItem from "./grab-harness-overview-item";
@@ -20,7 +26,8 @@ type Params = {
/**
* A worker bot's (or a workspace's) model settings: per harness, its own
* setting (or what it inherits), the models it can pick, what it runs on
* now, and what it would run on without its own setting.
* now, and what it would run on without its own setting. Install-wide
* harnesses (embeddings) aren't listed.
*/
export default async function grabBotHarnessOverview({
bot_id,
@@ -34,7 +41,7 @@ export default async function grabBotHarnessOverview({
const [own, defaults] = await Promise.all([
Promise.all(
Harnesses.map((harness) =>
OwnHarnesses.map((harness) =>
grabHarnessSetting({
harness: harness.value,
bot_id: workspace_id ? undefined : bot_id,
@@ -43,7 +50,7 @@ export default async function grabBotHarnessOverview({
),
),
Promise.all(
Harnesses.map((harness) =>
OwnHarnesses.map((harness) =>
grabHarnessSetting({ harness: harness.value, bot_id: inherited_bot_id }),
),
),
@@ -4,7 +4,7 @@ import type {
BUN_SQLITE_COBBLE_HARNESS_SETTINGS,
BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS,
} from "@/db/types/db";
import type { HarnessKind } from "@/src/dict/harness-dict";
import { isInstallWideHarness, type HarnessKind } from "@/src/dict/harness-dict";
import parseJobJson from "@/src/bots-queue/parse-job-json";
import grabDefaultHarnessProviderId from "./grab-default-harness-provider-id";
@@ -51,14 +51,18 @@ type SettingRow = Pick<
/**
* A harness's setting for a call: the workspace's, else the bot's, else
* the default. With no row it is automatic on the default provider
* (OpenRouter), on, with fallback. Text is always on.
* (OpenRouter), on, with fallback. Text is always on. An install-wide
* harness (embeddings) always takes the default: rows a bot or workspace
* saved before it was one are ignored.
*/
export default async function grabHarnessSetting({
harness,
bot_id,
workspace_id,
}: Params): Promise<HarnessSetting> {
const workspace_row = workspace_id
const install_wide = isInstallWideHarness({ harness });
const workspace_row = workspace_id && !install_wide
? await grabWorkspaceOverride({ harness, workspace_id })
: undefined;
@@ -66,7 +70,7 @@ export default async function grabHarnessSetting({
return toSetting({ harness, row: workspace_row, source: "workspace" });
}
const bot_row = bot_id
const bot_row = bot_id && !install_wide
? await grabBotOverride({ harness, bot_id })
: undefined;
@@ -1,5 +1,5 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { HarnessKind } from "@/src/dict/harness-dict";
import { Harnesses, isInstallWideHarness, type HarnessKind } from "@/src/dict/harness-dict";
import AppError from "@/src/utils/app-error";
import assertHarnessModel from "./assert-harness-model";
import assertHarnessProvider from "./assert-harness-provider";
@@ -47,6 +47,12 @@ export default async function saveBotHarnessSetting({
return;
}
if (isInstallWideHarness({ harness })) {
const title = Harnesses.find((h) => h.value == harness)?.title || harness;
throw new AppError(`${title} is set for the whole install, in Settings, under Models.`);
}
if (harness == "text" && !enabled) {
throw new AppError(`The text model can't be turned off.`);
}
@@ -1,5 +1,5 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { HarnessKind } from "@/src/dict/harness-dict";
import { Harnesses, isInstallWideHarness, type HarnessKind } from "@/src/dict/harness-dict";
import AppError from "@/src/utils/app-error";
import assertHarnessModel from "./assert-harness-model";
import assertHarnessProvider from "./assert-harness-provider";
@@ -48,6 +48,12 @@ export default async function saveWorkspaceHarnessSetting({
return;
}
if (isInstallWideHarness({ harness })) {
const title = Harnesses.find((h) => h.value == harness)?.title || harness;
throw new AppError(`${title} is set for the whole install, in Settings, under Models.`);
}
if (harness == "text" && !enabled) {
throw new AppError(`The text model can't be turned off.`);
}
@@ -0,0 +1,49 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import { rmSync } from "node:fs";
import mediaFilePath from "@/src/utils/media-file-path";
import grabKnowledgebaseDocumentRow from "./grab-knowledgebase-document-row";
type Params = {
document_id: number | string;
};
/**
* Deletes a knowledgebase document: its vectors, chunks (and their
* full-text index rows), pending ingest,
* row and (for a file) its bytes and `media` row. Children are deleted here
* rather than left to the schema's cascade, since foreign keys are off
* outside the server process.
*/
export default async function deleteKnowledgebaseDocument({ document_id }: Params) {
const document = await grabKnowledgebaseDocumentRow({ document_id });
await BunSQLite.sql({ sql: `DELETE FROM knowledgebase_vectors WHERE document_id = ?`, values: [document.id] });
await BunSQLite.sql({
sql: `DELETE FROM knowledgebase_chunks_fts WHERE document_id = ?`,
values: [document.id],
}).catch(() => {});
await BunSQLite.sql({ sql: `DELETE FROM knowledgebase_chunks WHERE document_id = ?`, values: [document.id] });
await BunSQLite.sql({
sql: `DELETE FROM file_ingest_queue WHERE document_id = ? AND status IN ('pending', 'processing')`,
values: [document.id],
}).catch(() => {});
await BunSQLite.sql({ sql: `DELETE FROM knowledgebase_documents WHERE id = ?`, values: [document.id] });
if (document.media_id) {
const media = await BunSQLite.sql<{ media_write_path?: string; media_thumbnail_write_path?: string }>({
sql: `SELECT media_write_path, media_thumbnail_write_path FROM media WHERE id = ? LIMIT 1`,
values: [document.media_id],
}).then((res) => res.payload?.[0]);
for (const write_path of [media?.media_write_path, media?.media_thumbnail_write_path]) {
const absolute = mediaFilePath({ write_path });
if (absolute) rmSync(absolute, { force: true });
}
await BunSQLite.sql({ sql: `DELETE FROM media_paradigms WHERE media_id = ?`, values: [document.media_id] });
await BunSQLite.sql({ sql: `DELETE FROM media WHERE id = ?`, values: [document.media_id] });
}
return document;
}
@@ -0,0 +1,38 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import deleteKnowledgebaseDocument from "./delete-knowledgebase-document";
import grabKnowledgebase from "./grab-knowledgebase";
type Params = {
knowledgebase_id: number | string;
};
/**
* Deletes a knowledgebase with everything in it: each document (its
* vectors, chunks and bytes), then its bot and workspace links. All
* deleted here rather than left to the schema's cascade, since foreign
* keys are off outside the server process.
*/
export default async function deleteKnowledgebase({ knowledgebase_id }: Params) {
const knowledgebase = await grabKnowledgebase({ knowledgebase_id });
const documents = await BunSQLite.sql<{ id: number }>({
sql: `SELECT id FROM knowledgebase_documents WHERE knowledgebase_id = ?`,
values: [knowledgebase.id],
});
for (const document of documents.payload || []) {
await deleteKnowledgebaseDocument({ document_id: document.id });
}
await BunSQLite.sql({ sql: `DELETE FROM bot_knowledgebases WHERE knowledgebase_id = ?`, values: [knowledgebase.id] });
await BunSQLite.sql({
sql: `DELETE FROM workspace_knowledgebases WHERE knowledgebase_id = ?`,
values: [knowledgebase.id],
});
const res = await BunSQLite.sql({ sql: `DELETE FROM knowledgebases WHERE id = ?`, values: [knowledgebase.id] });
if (!res.success) throw new Error(`Couldn't delete the knowledgebase: ${res.error}`);
return knowledgebase;
}
@@ -0,0 +1,33 @@
import BunSQLite from "@moduletrace/bun-sqlite";
let ensured: Promise<void> | undefined;
/**
* Makes sure the full-text index of knowledgebase passages exists, once
* per process. An FTS5 table can't be declared in `db/schema.ts`, so it is
* made here; schema sync only drops tables it made, so it leaves this one
* alone. `unicode61 remove_diacritics 2` matches words in any script that
* separates them with spaces, accents or not ("conges" finds "congés").
*/
export default function ensureKnowledgebaseFts() {
ensured ||= create().catch((error) => {
ensured = undefined;
throw error;
});
return ensured;
}
async function create() {
const res = await BunSQLite.sql({
sql: `CREATE VIRTUAL TABLE IF NOT EXISTS knowledgebase_chunks_fts USING fts5(
text,
chunk_id UNINDEXED,
document_id UNINDEXED,
knowledgebase_id UNINDEXED,
tokenize = 'unicode61 remove_diacritics 2'
)`,
});
if (!res.success) throw new Error(`Couldn't make the knowledgebase search index: ${res.error || res.msg}`);
}
@@ -0,0 +1,41 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import ensureKnowledgebaseFts from "./ensure-knowledgebase-fts";
/**
* Brings the full-text index in line with `knowledgebase_chunks`, at boot:
* adds passages it doesn't have (stored before it existed, or by a process
* that failed halfway) and drops ones whose passage is gone (a delete from
* a script). Returns how many rows it added and dropped.
*/
export default async function syncKnowledgebaseFts() {
await ensureKnowledgebaseFts();
/**
* Counted here: FTS5 reports its own shadow-table writes as changes.
*/
const count = async () =>
Number(
(await BunSQLite.sql<{ n: number }>({ sql: `SELECT COUNT(*) AS n FROM knowledgebase_chunks_fts` }))
.payload?.[0]?.n,
) || 0;
const start = await count();
const added = await BunSQLite.sql({
sql: `INSERT INTO knowledgebase_chunks_fts (text, chunk_id, document_id, knowledgebase_id)
SELECT c.text, c.id, c.document_id, c.knowledgebase_id FROM knowledgebase_chunks c
WHERE c.id NOT IN (SELECT chunk_id FROM knowledgebase_chunks_fts)`,
});
const after_add = await count();
const dropped = await BunSQLite.sql({
sql: `DELETE FROM knowledgebase_chunks_fts WHERE chunk_id NOT IN (SELECT id FROM knowledgebase_chunks)`,
});
if (!added.success || !dropped.success) {
throw new Error(`Couldn't sync the knowledgebase search index: ${added.error || dropped.error}`);
}
return { added: after_add - start, dropped: after_add - (await count()) };
}
@@ -0,0 +1,11 @@
import resolveHarnessCandidates from "@/src/harness/resolve/resolve-harness-candidates";
/**
* The model the install embeds with now (the first candidate of the
* default embedding setting), null when none is set or it's turned off.
*/
export default async function grabInstallEmbeddingModel() {
const candidates = await resolveHarnessCandidates({ harness: "embedding" }).catch(() => []);
return candidates[0]?.model.slug || null;
}
@@ -0,0 +1,24 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_KNOWLEDGEBASE_DOCUMENTS } from "@/db/types/db";
import AppError from "@/src/utils/app-error";
type Params = {
document_id: number | string;
};
/**
* One knowledgebase document's row, content included, or an `AppError`
* when there's none.
*/
export default async function grabKnowledgebaseDocumentRow({ document_id }: Params) {
const row = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_KNOWLEDGEBASE_DOCUMENTS>({
sql: `SELECT * FROM knowledgebase_documents WHERE id = ?`,
values: [document_id],
})
).payload?.[0];
if (!row?.id) throw new AppError(`That document doesn't exist.`);
return { ...row, id: Number(row.id) };
}
@@ -0,0 +1,36 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import extractBotFileText from "../bot-files/extract-bot-file-text";
type Params = {
document: {
source?: string | null;
content?: string | null;
media_id?: number | null;
name?: string | null;
mime_type?: string | null;
};
};
/**
* A knowledgebase document's whole text: what was written in, or what its
* file holds (the text pulled out at upload, else unpacked now).
*/
export default async function grabKnowledgebaseDocumentText({ document }: Params) {
if (document.source == "text") return document.content || "";
if (!document.media_id) return "";
const media = await BunSQLite.sql<{ media_write_path?: string; text_content?: string }>({
sql: `SELECT media_write_path, text_content FROM media WHERE id = ? LIMIT 1`,
values: [document.media_id],
}).then((res) => res.payload?.[0]);
return (
(await extractBotFileText({
write_path: media?.media_write_path,
text_content: media?.text_content,
name: document.name,
mime_type: document.mime_type,
})) || ""
);
}
@@ -0,0 +1,24 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_KNOWLEDGEBASES } from "@/db/types/db";
import AppError from "@/src/utils/app-error";
import parseKnowledgebaseRow from "./parse-knowledgebase-row";
type Params = {
knowledgebase_id: number | string;
};
/**
* One knowledgebase, or an `AppError` when there's none.
*/
export default async function grabKnowledgebase({ knowledgebase_id }: Params) {
const row = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_KNOWLEDGEBASES>({
sql: `SELECT * FROM knowledgebases WHERE id = ?`,
values: [knowledgebase_id],
})
).payload?.[0];
if (!row) throw new AppError(`That knowledgebase doesn't exist.`);
return parseKnowledgebaseRow({ row });
}
@@ -0,0 +1,79 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_KNOWLEDGEBASES } from "@/db/types/db";
import type { KnowledgebasesOverviewPayload } from "@/src/types";
import grabCrewRole from "../access/grab-crew-role";
import grabExtensionAccess from "../extensions/grab-extension-access";
import grabExtensionTargets from "../extensions/grab-extension-targets";
import { KnowledgebaseDocumentIndexingSql } from "./knowledgebase-document-indexing-sql";
import grabInstallEmbeddingModel from "./grab-install-embedding-model";
import parseKnowledgebaseRow from "./parse-knowledgebase-row";
type Params = {
user_id: number | string;
};
type Counts = {
knowledgebase_id: number;
documents: number;
pending: number;
errors: number;
warnings: number;
stale: number;
size: number;
};
/**
* The Knowledgebases settings page: every knowledgebase with who gets it
* and how much of it is searchable, what it can be attached to, and the
* install's embedding model. Admins read it and choose who gets each;
* only a super admin adds, edits, fills or deletes one.
*/
export default async function grabKnowledgebasesOverview({
user_id,
}: Params): Promise<KnowledgebasesOverviewPayload> {
const role = await grabCrewRole({ user_id });
const embedding_model = await grabInstallEmbeddingModel();
const [rows, counts, access, targets] = await Promise.all([
BunSQLite.sql<BUN_SQLITE_COBBLE_KNOWLEDGEBASES>({
sql: `SELECT * FROM knowledgebases ORDER BY name COLLATE NOCASE ASC, id ASC`,
}),
BunSQLite.sql<Counts>({
sql: `SELECT knowledgebase_id,
COUNT(*) AS documents,
SUM(CASE WHEN ${KnowledgebaseDocumentIndexingSql} THEN 1 ELSE 0 END) AS pending,
SUM(CASE WHEN ingest_error IS NOT NULL AND COALESCE(chunk_count, 0) = 0 THEN 1 ELSE 0 END) AS errors,
SUM(CASE WHEN ingest_error IS NOT NULL AND NOT ${KnowledgebaseDocumentIndexingSql}
THEN 1 ELSE 0 END) AS warnings,
SUM(CASE WHEN ingested_at IS NOT NULL AND chunk_count > 0
AND (embedding_model IS NULL OR embedding_model IS NOT ?) THEN 1 ELSE 0 END) AS stale,
SUM(size) AS size
FROM knowledgebase_documents d GROUP BY knowledgebase_id`,
values: [embedding_model],
}),
grabExtensionAccess({ kind: "knowledgebase" }),
grabExtensionTargets({ role }),
]);
const by_id = new Map((counts.payload || []).map((row) => [Number(row.knowledgebase_id), row]));
return {
...targets,
embedding_model,
knowledgebases: (rows.payload || []).map((row) => {
const { all_bots, ...knowledgebase } = parseKnowledgebaseRow({ row });
const count = by_id.get(knowledgebase.id);
return {
...knowledgebase,
access: { all_bots, ...access(knowledgebase.id) },
document_count: Number(count?.documents) || 0,
pending_count: Number(count?.pending) || 0,
error_count: Number(count?.errors) || 0,
warning_count: Number(count?.warnings) || 0,
stale_count: Number(count?.stale) || 0,
size: Number(count?.size) || 0,
};
}),
};
}
@@ -0,0 +1,297 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_KNOWLEDGEBASE_CHUNKS, 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 runWithFallbackScope from "@/src/harness/usage/run-with-fallback-scope";
import chunkText from "../bot-files/chunk-text";
import ensureKnowledgebaseFts from "./fts/ensure-knowledgebase-fts";
import grabKnowledgebaseDocumentText from "./grab-knowledgebase-document-text";
type Params = {
document_id: number | string;
signal?: AbortSignal;
};
export type KnowledgebaseIngestResult = {
/**
* `ingested`, `empty` (no text to read), `skipped` (too large) or
* `words` (no embedding model: searchable by words only).
*/
status: "ingested" | "empty" | "skipped" | "words" | "missing";
chunks: number;
};
/**
* Reads a knowledgebase document's text, splits it into chunks and embeds
* them with the install's embedding model (never a bot's: every vector in
* a knowledgebase must be comparable). The old passages stay searchable
* until the new ones are in. Without an embedding model the chunks are
* still kept, and indexed for ranked word matching
* (`knowledgebase_chunks_fts`).
*
* Failures are recorded on the document (`ingest_error`) and rethrown, so
* the queue can retry.
*/
export default async function ingestKnowledgebaseDocument({
document_id,
signal,
}: Params): Promise<KnowledgebaseIngestResult> {
const document = (
await BunSQLite.sql<{
id: number;
knowledgebase_id: number;
name: string;
source: string;
media_id: number | null;
content: string | null;
mime_type: string | null;
size: number;
user_id: number | null;
}>({
sql: `SELECT id, knowledgebase_id, name, source, media_id, content, mime_type, size, user_id
FROM knowledgebase_documents WHERE id = ? LIMIT 1`,
values: [document_id],
})
).payload?.[0];
if (!document?.id) return { status: "missing", chunks: 0 };
if (Number(document.size) > AppData.KnowledgebaseDocumentMaxBytes) {
await finish({
document_id: document.id,
chunks: 0,
model: null,
error: `Too large to read (over ${AppData.KnowledgebaseDocumentMaxBytes / (1024 * 1024)} MB).`,
});
return { status: "skipped", chunks: 0 };
}
/**
* The ingest queue has no run behind it: the embedding is charged to
* whoever added the document, so it shows on the usage page.
*/
const scope = document.user_id ? { user_id: document.user_id } : null;
/**
* Set once the document's outcome is written, so the catch below
* doesn't write over it.
*/
let recorded = false;
try {
const text = await grabKnowledgebaseDocumentText({ document });
const old_ids = await chunkIds({ document_id: document.id });
const chunks = text.trim() ? chunkText({ text }) : [];
if (!chunks.length) {
await dropChunks({ ids: old_ids });
await finish({ document_id: document.id, chunks: 0, model: null, error: "It has no text to read." });
return { status: "empty", chunks: 0 };
}
const new_ids = await insertChunks({ document, chunks });
let model: string | null = null;
try {
for (let start = 0; start < chunks.length; start += AppData.EmbeddingBatchSize) {
const batch = chunks.slice(start, start + AppData.EmbeddingBatchSize);
const embedded = await runWithFallbackScope({
scope,
task: () => embedText({ input: batch, signal }),
});
model = embedded.model || model;
for (let i = 0; i < embedded.vectors.length; i++) {
await insertVector({
vector: embedded.vectors[i]!,
chunk_id: new_ids[start + i]!,
document_id: document.id,
knowledgebase_id: document.knowledgebase_id,
});
}
}
} catch (error) {
if (!(error instanceof HarnessError) || error.resting) {
/**
* Not the model's answer (a bug, a database error), or no
* model was tried yet: the old passages are still whole,
* the new ones go, and the job tries again.
*/
await dropChunks({ ids: new_ids });
throw error;
}
/**
* No embedding model, or it refused: the new passages stay,
* found by their words, and none of their vectors are kept
* (half a document embedded would rank unevenly).
*/
await dropVectors({ ids: new_ids });
if (error.code != "unavailable") {
await dropChunks({ ids: old_ids });
await finish({
document_id: document.id,
chunks: chunks.length,
model: null,
error: `Found by its words only: the embedding model failed. ${error.message}`.slice(0, 500),
});
recorded = true;
throw error;
}
model = null;
}
await dropChunks({ ids: old_ids });
await finish({ document_id: document.id, chunks: chunks.length, model });
return { status: model ? "ingested" : "words", chunks: chunks.length };
} catch (error) {
/**
* Every model resting: nothing was tried, the job waits for them
* and the document keeps what it said before.
*/
if (recorded || (error instanceof HarnessError && error.resting)) throw error;
const message = error instanceof Error ? error.message : String(error);
await BunSQLite.sql({
sql: `UPDATE knowledgebase_documents SET ingest_error = ?, updated_at = ? WHERE id = ?`,
values: [message.slice(0, 500), Date.now(), document.id],
}).catch(() => {});
throw error;
}
}
async function chunkIds({ document_id }: { document_id: number }) {
const res = await BunSQLite.sql<{ id: number }>({
sql: `SELECT id FROM knowledgebase_chunks WHERE document_id = ?`,
values: [document_id],
});
return (res.payload || []).map((row) => Number(row.id));
}
async function dropVectors({ ids }: { ids: number[] }) {
for (let start = 0; start < ids.length; start += 500) {
const batch = ids.slice(start, start + 500);
await BunSQLite.sql({
sql: `DELETE FROM knowledgebase_vectors WHERE chunk_id IN (${batch.map(() => "?").join(", ")})`,
values: batch,
});
}
}
async function dropChunks({ ids }: { ids: number[] }) {
for (let start = 0; start < ids.length; start += 500) {
const batch = ids.slice(start, start + 500);
const placeholders = batch.map(() => "?").join(", ");
await BunSQLite.sql({ sql: `DELETE FROM knowledgebase_vectors WHERE chunk_id IN (${placeholders})`, values: batch });
await BunSQLite.sql({
sql: `DELETE FROM knowledgebase_chunks_fts WHERE chunk_id IN (${placeholders})`,
values: batch,
});
await BunSQLite.sql({ sql: `DELETE FROM knowledgebase_chunks WHERE id IN (${placeholders})`, values: batch });
}
}
async function insertChunks({
document,
chunks,
}: {
document: { id: number; knowledgebase_id: number };
chunks: string[];
}) {
const ids: number[] = [];
await ensureKnowledgebaseFts();
for (let index = 0; index < chunks.length; index++) {
const text = chunks[index]!;
const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_KNOWLEDGEBASE_CHUNKS, (typeof BunSQLiteTables)[number]>({
table: "knowledgebase_chunks",
data: [
{
document_id: document.id,
knowledgebase_id: Number(document.knowledgebase_id),
chunk_index: index,
text,
token_estimate: Math.ceil(text.length / 4),
},
],
});
const id = res.postInsertReturn?.insertId;
if (!res.success || !id) throw new Error(`Couldn't store a passage: ${res.msg || res.error}`);
ids.push(Number(id));
/**
* Its words go in the full-text index, which ranks a word search.
*/
const indexed = await BunSQLite.sql({
sql: `INSERT INTO knowledgebase_chunks_fts (text, chunk_id, document_id, knowledgebase_id) VALUES (?, ?, ?, ?)`,
values: [text, Number(id), document.id, Number(document.knowledgebase_id)],
});
if (!indexed.success) throw new Error(`Couldn't index a passage: ${indexed.error || indexed.msg}`);
}
return ids;
}
async function insertVector({
vector,
chunk_id,
document_id,
knowledgebase_id,
}: {
vector: Float32Array;
chunk_id: number;
document_id: number;
knowledgebase_id: number;
}) {
/**
* vec0's metadata columns (its id and timestamps) are NOT NULL, so a
* raw insert fills them; the vector needs `vec_f32`.
*/
const now = Date.now();
const res = await BunSQLite.sql({
sql: `INSERT INTO knowledgebase_vectors (created_at, updated_at, embedding, chunk_id, document_id, knowledgebase_id) VALUES (?, ?, vec_f32(?), ?, ?, ?)`,
values: [now, now, vector as any, chunk_id, document_id, knowledgebase_id],
});
if (!res.success) throw new Error(`Couldn't store an embedding: ${res.error || res.msg}`);
}
async function finish({
document_id,
chunks,
model,
error,
}: {
document_id: number;
chunks: number;
model: string | null;
error?: string;
}) {
const now = Date.now();
await BunSQLite.sql({
sql: `UPDATE knowledgebase_documents SET ingested_at = ?, chunk_count = ?, embedding_model = ?, ingest_error = ?,
updated_at = ? WHERE id = ?`,
values: [now, chunks, model, error || null, now, document_id],
});
}
@@ -0,0 +1,7 @@
/**
* Whether a `knowledgebase_documents` row aliased `d` is being read: never
* read yet, or with an ingest job still waiting or running (a re-index
* leaves `ingested_at` set while it reads the document again).
*/
export const KnowledgebaseDocumentIndexingSql = `((d.ingested_at IS NULL AND d.ingest_error IS NULL)
OR EXISTS (SELECT 1 FROM file_ingest_queue q WHERE q.document_id = d.id AND q.status IN ('pending', 'processing')))`;
@@ -0,0 +1,31 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_KNOWLEDGEBASE_DOCUMENTS } from "@/db/types/db";
import type { KnowledgebaseDocumentsPayload } from "@/src/types";
import grabKnowledgebase from "./grab-knowledgebase";
import { KnowledgebaseDocumentIndexingSql } from "./knowledgebase-document-indexing-sql";
import parseKnowledgebaseDocumentRow from "./parse-knowledgebase-document-row";
type Params = {
knowledgebase_id: number | string;
};
/**
* A knowledgebase's documents, newest first, without their text.
*/
export default async function listKnowledgebaseDocuments({
knowledgebase_id,
}: Params): Promise<KnowledgebaseDocumentsPayload> {
const knowledgebase = await grabKnowledgebase({ knowledgebase_id });
const rows = await BunSQLite.sql<BUN_SQLITE_COBBLE_KNOWLEDGEBASE_DOCUMENTS & { indexing?: number }>({
sql: `SELECT id, knowledgebase_id, name, source, mime_type, size, chunk_count, embedding_model,
ingested_at, ingest_error, created_at, ${KnowledgebaseDocumentIndexingSql} AS indexing
FROM knowledgebase_documents d WHERE knowledgebase_id = ? ORDER BY id DESC`,
values: [knowledgebase.id],
});
return {
knowledgebase_id: knowledgebase.id,
documents: (rows.payload || []).map((row) => parseKnowledgebaseDocumentRow({ row })),
};
}
@@ -0,0 +1,16 @@
import { describe, expect, test } from "bun:test";
import locatePassage from "./locate-passage";
describe("locatePassage", () => {
const text = "# Leave\n\nTwenty days a year.\n\n# Refunds\n\nRefunds are issued within fourteen days.";
test("finds a passage by its last line, counted back by what comes before it", () => {
const at = locatePassage({ text, passage: "# Refunds\n\nRefunds are issued within fourteen days." });
expect(text.slice(at).startsWith("# Refunds")).toBe(true);
});
test("starts at the top when it can't be found", () => {
expect(locatePassage({ text, passage: "Nothing like this" })).toBe(0);
});
});
@@ -0,0 +1,31 @@
type Params = {
/**
* The document's whole text.
*/
text: string;
/**
* One of its passages, which may start with a heading carried over and
* a stretch repeated from the passage before.
*/
passage: string;
};
/**
* Where a passage sits in its document, roughly: found by its last line
* (the part least likely to be a carried heading or an overlap), counted
* back by where that line sits in the passage. 0 when it can't be found.
*/
export default function locatePassage({ text, passage }: Params) {
const lines = passage.split("\n").map((line) => line.trim()).filter(Boolean);
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i]!;
const at = text.indexOf(line);
if (at < 0) continue;
return Math.max(0, at - passage.lastIndexOf(line));
}
return 0;
}
@@ -0,0 +1,30 @@
import type { BUN_SQLITE_COBBLE_KNOWLEDGEBASE_DOCUMENTS } from "@/db/types/db";
import type { KnowledgebaseDocument } from "@/src/types";
type Params = {
/**
* With `indexing` when the query asked (`KnowledgebaseDocumentIndexingSql`).
*/
row: BUN_SQLITE_COBBLE_KNOWLEDGEBASE_DOCUMENTS & { indexing?: number | boolean };
};
/**
* A `knowledgebase_documents` row as the settings page shows it (a text
* document's content is left out: it loads when it's opened).
*/
export default function parseKnowledgebaseDocumentRow({ row }: Params): KnowledgebaseDocument {
return {
id: Number(row.id),
knowledgebase_id: Number(row.knowledgebase_id),
name: row.name || "",
source: row.source == "text" ? "text" : "file",
mime_type: row.mime_type || "",
size: Number(row.size) || 0,
chunk_count: Number(row.chunk_count) || 0,
embedding_model: row.embedding_model || null,
ingested_at: typeof row.ingested_at == "number" ? row.ingested_at : null,
ingest_error: row.ingest_error || null,
indexing: row.indexing == undefined ? !row.ingested_at && !row.ingest_error : Boolean(row.indexing),
created_at: typeof row.created_at == "number" ? row.created_at : null,
};
}
@@ -0,0 +1,20 @@
import type { BUN_SQLITE_COBBLE_KNOWLEDGEBASES } from "@/db/types/db";
import type { KnowledgebaseRecord } from "@/src/types";
type Params = {
row: BUN_SQLITE_COBBLE_KNOWLEDGEBASES;
};
/**
* A `knowledgebases` row, with its flags read as booleans.
*/
export default function parseKnowledgebaseRow({ row }: Params): KnowledgebaseRecord {
return {
id: Number(row.id),
name: row.name || "",
description: row.description || "",
all_bots: row.all_bots == 1,
enabled: row.enabled != 0,
updated_at: typeof row.updated_at == "number" ? row.updated_at : null,
};
}
@@ -0,0 +1,79 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import grabKnowledgebaseDocumentText from "./grab-knowledgebase-document-text";
import locatePassage from "./locate-passage";
type Params = {
/**
* The knowledgebases the run reaches: a document outside them is
* refused.
*/
knowledgebase_ids: number[];
document_id: number;
/**
* Where to start: a passage a search returned, or a character offset
* the last read said to go on from. Neither starts at the top.
*/
from_passage?: number;
from_char?: number;
max_chars: number;
};
/**
* A stretch of a knowledgebase document's own text (not its passages,
* which overlap), up to `max_chars`, with the offset to go on from when
* there's more.
*/
export default async function readKnowledgebaseDocument({
knowledgebase_ids,
document_id,
from_passage,
from_char,
max_chars,
}: Params) {
const document = (
await BunSQLite.sql<{
id: number;
knowledgebase_id: number;
name: string;
source: string;
content: string | null;
media_id: number | null;
mime_type: string | null;
}>({
sql: `SELECT id, knowledgebase_id, name, source, content, media_id, mime_type
FROM knowledgebase_documents WHERE id = ? LIMIT 1`,
values: [document_id],
})
).payload?.[0];
if (!document || !knowledgebase_ids.includes(Number(document.knowledgebase_id))) {
throw new AppError(`That document isn't in a knowledgebase you have.`);
}
const text = (await grabKnowledgebaseDocumentText({ document })).replace(/\r\n/g, "\n");
const passage =
from_passage != null && from_char == null
? (
await BunSQLite.sql<{ text: string }>({
sql: `SELECT text FROM knowledgebase_chunks WHERE document_id = ? AND chunk_index = ? LIMIT 1`,
values: [document.id, from_passage],
})
).payload?.[0]?.text
: undefined;
const start = Math.min(
text.length,
Math.max(0, from_char ?? (passage ? locatePassage({ text, passage }) : 0)),
);
const end = Math.min(text.length, start + max_chars);
return {
name: document.name,
start,
length: text.length,
text: text.slice(start, end),
next: end < text.length ? end : null,
};
}
@@ -0,0 +1,68 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_KNOWLEDGEBASE_DOCUMENTS, BunSQLiteTables } from "@/db/types/db";
import type { KnowledgebaseDocumentSource } from "@/src/dict/knowledgebase-dict";
import enqueueDocumentIngest from "@/src/file-ingest/enqueue-document-ingest";
import grabKnowledgebase from "./grab-knowledgebase";
type Params = {
user_id: number | string;
knowledgebase_id: number | string;
name: string;
source: KnowledgebaseDocumentSource;
media_id?: number | null;
content?: string | null;
mime_type?: string;
size: number;
};
/**
* Adds a document to a knowledgebase and queues it to be chunked and
* embedded. Returns its id.
*/
export default async function recordKnowledgebaseDocument({
user_id,
knowledgebase_id,
name,
source,
media_id,
content,
mime_type,
size,
}: Params) {
const knowledgebase = await grabKnowledgebase({ knowledgebase_id });
const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_KNOWLEDGEBASE_DOCUMENTS, (typeof BunSQLiteTables)[number]>({
table: "knowledgebase_documents",
data: [
{
knowledgebase_id: knowledgebase.id,
name,
source,
media_id: media_id || undefined,
content: content || undefined,
mime_type: mime_type || "",
size,
user_id: Number(user_id),
},
],
});
const id = res.postInsertReturn?.insertId;
if (!res.success || !id) throw new Error(`Couldn't add the document: ${res.msg || res.error}`);
await touchKnowledgebase({ knowledgebase_id: knowledgebase.id });
await enqueueDocumentIngest({ document_id: id });
return Number(id);
}
/**
* Marks a knowledgebase changed, for its "updated" line.
*/
export async function touchKnowledgebase({ knowledgebase_id }: { knowledgebase_id: number | string }) {
await BunSQLite.sql({
sql: `UPDATE knowledgebases SET updated_at = ? WHERE id = ?`,
values: [Date.now(), knowledgebase_id],
});
}
@@ -0,0 +1,38 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import enqueueDocumentIngest from "@/src/file-ingest/enqueue-document-ingest";
import grabInstallEmbeddingModel from "./grab-install-embedding-model";
type Params = {
/**
* The install's embedding model before the change
* (`grabInstallEmbeddingModel`), null when there was none.
*/
before: string | null;
};
/**
* After something that can change the install's embedding model (its
* setting, a provider, the catalog), queues every knowledgebase document
* that was read with another model, or by words only, to be read again:
* a query embedded by the new model can't be compared with vectors from
* the old one. Nothing happens when the model is the same, or there's
* none. Returns how many documents were queued.
*/
export default async function reindexAfterEmbeddingChange({ before }: Params) {
const after = await grabInstallEmbeddingModel();
if (!after || after == before) return 0;
const documents = await BunSQLite.sql<{ id: number }>({
sql: `SELECT id FROM knowledgebase_documents
WHERE ingested_at IS NOT NULL AND chunk_count > 0
AND (embedding_model IS NULL OR embedding_model != ?)`,
values: [after],
});
for (const document of documents.payload || []) {
await enqueueDocumentIngest({ document_id: document.id });
}
return documents.payload?.length || 0;
}
@@ -0,0 +1,32 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import enqueueDocumentIngest from "@/src/file-ingest/enqueue-document-ingest";
import grabKnowledgebase from "./grab-knowledgebase";
type Params = {
knowledgebase_id: number | string;
};
/**
* Reads every document of a knowledgebase again (after the install's
* embedding model changed, or one failed). Each keeps its old passages
* until its new ones are in. Returns how many were queued.
*/
export default async function reindexKnowledgebase({ knowledgebase_id }: Params) {
const knowledgebase = await grabKnowledgebase({ knowledgebase_id });
const documents = await BunSQLite.sql<{ id: number }>({
sql: `SELECT id FROM knowledgebase_documents WHERE knowledgebase_id = ?`,
values: [knowledgebase.id],
});
await BunSQLite.sql({
sql: `UPDATE knowledgebase_documents SET ingest_error = NULL WHERE knowledgebase_id = ?`,
values: [knowledgebase.id],
});
for (const document of documents.payload || []) {
await enqueueDocumentIngest({ document_id: document.id });
}
return documents.payload?.length || 0;
}
@@ -0,0 +1,50 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import enqueueDocumentIngest from "@/src/file-ingest/enqueue-document-ingest";
import type { SaveKnowledgebaseTextRequest } from "@/src/schemas/knowledgebase-schema";
import AppError from "@/src/utils/app-error";
import grabKnowledgebaseDocumentRow from "./grab-knowledgebase-document-row";
import recordKnowledgebaseDocument, { touchKnowledgebase } from "./record-knowledgebase-document";
type Params = {
user_id: number | string;
data: SaveKnowledgebaseTextRequest;
};
/**
* Writes a text document into a knowledgebase, or changes one, and queues
* it to be read again. Returns its id.
*/
export default async function saveKnowledgebaseText({ user_id, data }: Params) {
const size = Buffer.byteLength(data.content, "utf8");
if (!data.document_id) {
return await recordKnowledgebaseDocument({
user_id,
knowledgebase_id: data.knowledgebase_id,
name: data.name,
source: "text",
content: data.content,
mime_type: "text/markdown",
size,
});
}
const document = await grabKnowledgebaseDocumentRow({ document_id: data.document_id });
if (Number(document.knowledgebase_id) != data.knowledgebase_id || document.source != "text") {
throw new AppError(`Only a text document can be edited here.`);
}
const res = await BunSQLite.sql({
sql: `UPDATE knowledgebase_documents SET name = ?, content = ?, size = ?, ingested_at = NULL,
ingest_error = NULL, updated_at = ? WHERE id = ?`,
values: [data.name, data.content, size, Date.now(), document.id],
});
if (!res.success) throw new Error(`Couldn't save the document: ${res.error}`);
await touchKnowledgebase({ knowledgebase_id: data.knowledgebase_id });
await enqueueDocumentIngest({ document_id: document.id! });
return Number(document.id);
}
@@ -0,0 +1,57 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_KNOWLEDGEBASES, BunSQLiteTables } from "@/db/types/db";
import type { SaveKnowledgebaseRequest } from "@/src/schemas/knowledgebase-schema";
import attachToAllBots from "../extensions/attach-to-all-bots";
import grabKnowledgebase from "./grab-knowledgebase";
type Params = {
/**
* The super admin saving it (checked by the caller).
*/
user_id: number | string;
data: SaveKnowledgebaseRequest;
};
/**
* Adds or edits a knowledgebase (its name, what it holds, on or off, every
* bot) and returns its id. Its documents are added on their own.
*/
export default async function saveKnowledgebase({ user_id, data }: Params) {
const fields = {
name: data.name,
description: data.description,
enabled: data.enabled ? 1 : 0,
all_bots: data.all_bots ? 1 : 0,
} as const;
if (data.knowledgebase_id) {
const knowledgebase = await grabKnowledgebase({ knowledgebase_id: data.knowledgebase_id });
const res = await BunSQLite.update<BUN_SQLITE_COBBLE_KNOWLEDGEBASES, (typeof BunSQLiteTables)[number]>({
table: "knowledgebases",
targetId: knowledgebase.id,
data: fields,
});
if (!res.success) throw new Error(`Couldn't save the knowledgebase: ${res.msg}`);
if (data.all_bots && !knowledgebase.all_bots) {
await attachToAllBots({ kind: "knowledgebase", id: knowledgebase.id });
}
return knowledgebase.id;
}
const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_KNOWLEDGEBASES, (typeof BunSQLiteTables)[number]>({
table: "knowledgebases",
data: [{ ...fields, user_id: Number(user_id) }],
});
const id = res.singleRes?.id || res.postInsertReturn?.insertId;
if (!res.success || !id) throw new Error(`Couldn't add the knowledgebase: ${res.msg}`);
if (data.all_bots) await attachToAllBots({ kind: "knowledgebase", id: Number(id) });
return Number(id);
}
@@ -0,0 +1,245 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import { AppData } from "@/src/data/app-data";
import { embedQuery } from "@/src/harness/embeddings/embed-text";
import HarnessError from "@/src/harness/harness-error";
import knowledgebaseWordQuery from "@/src/utils/knowledgebase-word-query";
import ensureKnowledgebaseFts from "./fts/ensure-knowledgebase-fts";
type Params = {
/**
* The knowledgebases the run reaches: nothing outside them is returned.
*/
knowledgebase_ids: number[];
query: string;
/**
* Only this document's passages.
*/
document_id?: number | null;
limit?: number;
signal?: AbortSignal;
};
export type KnowledgebaseSearchHit = {
knowledgebase_id: number;
document_id: number;
chunk_id: number;
chunk_index: number;
document_name: string;
/**
* Cosine distance when the vector index answered, null for a word
* match.
*/
distance: number | null;
text: string;
};
export type KnowledgebaseSearchResult = {
hits: KnowledgebaseSearchHit[];
mode: "vector" | "words" | "none";
/**
* Said plainly for the bot's own reading of what it got back.
*/
note: string;
};
/**
* Searches knowledgebases: by meaning through the install's embedding
* model, else (or when nothing matches) by words over the stored passages.
* Never throws for an unavailable model.
*/
export default async function searchKnowledgebases({
knowledgebase_ids,
query,
document_id,
limit = AppData.RagTopChunks,
signal,
}: Params): Promise<KnowledgebaseSearchResult> {
const text = query.trim();
const ids = [...new Set(knowledgebase_ids.map(Number).filter(Boolean))];
if (!ids.length || !text) {
return { hits: [], mode: "none", note: "No knowledgebase to search." };
}
try {
const vector = await embedQuery({ text, signal });
const hits = await vectorSearch({
ids,
query_vector: vector,
document_id,
limit,
seek: Math.min(limit * AppData.RagOverFetch, AppData.RagMaxK),
});
if (hits.length) {
return { hits, mode: "vector", note: `${hits.length} passage(s), found by meaning.` };
}
} catch (error) {
if (!(error instanceof HarnessError)) throw error;
}
const hits = await wordSearch({ ids, query: text, document_id, limit });
return {
hits,
mode: hits.length ? "words" : "none",
note: hits.length
? `${hits.length} passage(s), found by matching words (no embedding model, or nothing matched by meaning).`
: `Nothing in the knowledgebase matched "${text}".`,
};
}
/**
* The nearest passages by vector. sqlite-vec refuses a filter on a vec0
* side-car column in the same query as the match, so the index is asked
* for its nearest `seek` and the run's knowledgebases are applied here.
*/
async function vectorSearch({
ids,
query_vector,
document_id,
limit,
seek,
}: {
ids: number[];
query_vector: Float32Array;
document_id?: number | null;
limit: number;
seek: number;
}): Promise<KnowledgebaseSearchHit[]> {
const nearest = await BunSQLite.sql<{
chunk_id: number;
document_id: number;
knowledgebase_id: number;
distance: number;
}>({
sql: `SELECT chunk_id, document_id, knowledgebase_id, distance FROM knowledgebase_vectors
WHERE embedding MATCH vec_f32(?) AND k = ? ORDER BY distance`,
values: [query_vector as any, seek],
});
if (!nearest.success) {
throw new Error(`The knowledgebase index couldn't be read: ${nearest.error || nearest.msg}`);
}
const picked = (nearest.payload || [])
.filter((row) => ids.includes(Number(row.knowledgebase_id)))
.filter((row) => !document_id || Number(row.document_id) == Number(document_id))
.slice(0, limit);
if (!picked.length) return [];
const chunk_ids = picked.map((row) => Number(row.chunk_id));
const chunks = await BunSQLite.sql<{ id: number; text: string; chunk_index: number; name: string }>({
sql: `SELECT c.id, c.text, c.chunk_index, d.name FROM knowledgebase_chunks c
JOIN knowledgebase_documents d ON d.id = c.document_id
WHERE c.id IN (${chunk_ids.map(() => "?").join(", ")})`,
values: chunk_ids,
});
const by_id = new Map((chunks.payload || []).map((row) => [Number(row.id), row]));
return picked.flatMap((row) => {
const chunk = by_id.get(Number(row.chunk_id));
if (!chunk) return [];
return [
{
knowledgebase_id: Number(row.knowledgebase_id),
document_id: Number(row.document_id),
chunk_id: Number(row.chunk_id),
chunk_index: Number(chunk.chunk_index),
document_name: chunk.name || "",
distance: Number(row.distance),
text: chunk.text || "",
},
];
});
}
/**
* Passages by their words: ranked by the full-text index (BM25, so a rare
* word counts for more than a common one), else, when it finds nothing
* (text written without spaces, a word cut differently), by the most
* substrings they hold.
*/
async function wordSearch({
ids,
query,
document_id,
limit,
}: {
ids: number[];
query: string;
document_id?: number | null;
limit: number;
}): Promise<KnowledgebaseSearchHit[]> {
const { match, like_terms } = knowledgebaseWordQuery({ query });
const in_ids = ids.map(() => "?").join(", ");
if (match) {
await ensureKnowledgebaseFts();
const res = await BunSQLite.sql<HitRow>({
sql: `SELECT c.knowledgebase_id, c.document_id, c.id AS chunk_id, c.chunk_index, d.name, c.text
FROM knowledgebase_chunks_fts f
JOIN knowledgebase_chunks c ON c.id = f.chunk_id
JOIN knowledgebase_documents d ON d.id = c.document_id
WHERE knowledgebase_chunks_fts MATCH ?
AND f.knowledgebase_id IN (${in_ids})
${document_id ? "AND f.document_id = ?" : ""}
ORDER BY bm25(knowledgebase_chunks_fts)
LIMIT ?`,
values: [match, ...ids, ...(document_id ? [document_id] : []), limit],
});
if (!res.success) {
throw new Error(`The knowledgebase word index couldn't be read: ${res.error || res.msg}`);
}
if (res.payload?.length) return res.payload.map(toHit);
}
if (!like_terms.length) return [];
const score = like_terms.map(() => `(CASE WHEN lower(c.text) LIKE ? THEN 1 ELSE 0 END)`).join(" + ");
const like_values = like_terms.map((term) => `%${term}%`);
const res = await BunSQLite.sql<HitRow>({
sql: `SELECT c.knowledgebase_id, c.document_id, c.id AS chunk_id, c.chunk_index, d.name, c.text,
(${score}) AS score
FROM knowledgebase_chunks c
JOIN knowledgebase_documents d ON d.id = c.document_id
WHERE c.knowledgebase_id IN (${in_ids})
${document_id ? "AND c.document_id = ?" : ""}
AND (${like_terms.map(() => `lower(c.text) LIKE ?`).join(" OR ")})
ORDER BY score DESC, d.updated_at DESC, c.chunk_index
LIMIT ?`,
values: [...like_values, ...ids, ...(document_id ? [document_id] : []), ...like_values, limit],
});
return (res.payload || []).map(toHit);
}
type HitRow = {
knowledgebase_id: number;
document_id: number;
chunk_id: number;
chunk_index: number;
name: string;
text: string;
};
function toHit(row: HitRow): KnowledgebaseSearchHit {
return {
knowledgebase_id: Number(row.knowledgebase_id),
document_id: Number(row.document_id),
chunk_id: Number(row.chunk_id),
chunk_index: Number(row.chunk_index),
document_name: row.name || "",
distance: null,
text: row.text || "",
};
}