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
+8 -2
View File
@@ -33,6 +33,11 @@ export type EmbeddingResult = {
* change of model is visible.
*/
model_dimensions: number;
/**
* The model that answered (its provider slug), so stored vectors can
* say where they came from.
*/
model: string;
};
/**
@@ -47,7 +52,7 @@ export default async function embedText({
bot_id,
workspace_id,
}: Params): Promise<EmbeddingResult> {
if (!input.length) return { vectors: [], model_dimensions: 0 };
if (!input.length) return { vectors: [], model_dimensions: 0, model: "" };
const candidates = await resolveHarnessCandidates({
harness: "embedding",
@@ -55,7 +60,7 @@ export default async function embedText({
workspace_id,
});
const { result } = await withCandidates<HarnessEmbedResult>({
const { result, candidate: answered } = await withCandidates<HarnessEmbedResult>({
harness: "embedding",
candidates,
on_success: async ({ candidate, result: embedded, failed }): Promise<void> =>
@@ -78,6 +83,7 @@ export default async function embedText({
return {
vectors: result.vectors.map((vector) => fitVectorDimensions({ vector })),
model_dimensions: result.vectors[0]?.length || 0,
model: answered.model.slug,
};
}
@@ -25,7 +25,7 @@ type Params = {
};
type Args = {
kind?: ExtensionKind;
kind?: z.infer<typeof ExtensionKindSchema>;
id?: number;
query?: string;
};
@@ -58,7 +58,7 @@ export default function listExtensionsTool({ user_id }: Params): HarnessTool<Arg
};
}
async function describeOne({ kind, id }: { kind: ExtensionKind; id: number }) {
async function describeOne({ kind, id }: { kind: Exclude<ExtensionKind, "knowledgebase">; id: number }) {
const access = (await grabExtensionAccess({ kind }))(id);
if (kind == "skill") return describeExtension({ kind, item: await grabSkill({ skill_id: id }), ...access, full: true });
@@ -1,6 +1,6 @@
import grabBotExtensions from "@/src/functions/backend/db/extensions/grab-bot-extensions";
import grabRunEnvs from "@/src/functions/backend/db/extensions/grab-run-envs";
import type { SkillRecord } from "@/src/types";
import type { KnowledgebaseRecord, SkillRecord } from "@/src/types";
import mcpToolName from "@/src/utils/mcp-tool-name";
import redactSecrets from "@/src/utils/redact-secrets";
import mcpHarnessTool from "../../mcp/mcp-harness-tool";
@@ -47,6 +47,10 @@ export type OpenBotExtensions = {
* as the MCP servers and tools.
*/
skills: SkillRecord[];
/**
* The knowledgebases the run has, for `openBotKnowledgebases`.
*/
knowledgebases: KnowledgebaseRecord[];
close: () => Promise<void>;
};
@@ -66,10 +70,10 @@ export default async function openBotExtensions({
taken = [],
signal,
}: Params): Promise<OpenBotExtensions> {
const { servers, tools, skills } = await grabBotExtensions({ bot_id, workspace_id });
const { servers, tools, skills, knowledgebases } = await grabBotExtensions({ bot_id, workspace_id });
if (!servers.length && !tools.length) {
return { tools: [], note: "", skills, close: async () => {} };
return { tools: [], note: "", skills, knowledgebases, close: async () => {} };
}
const envs = await grabRunEnvs({ bot_id, user_id, workspace_id });
@@ -120,6 +124,7 @@ export default async function openBotExtensions({
return {
tools: result,
skills,
knowledgebases,
note: notes.length
? `These MCP servers were set up for you but you can't use them right now; if the user needs them, say so (an admin fixes them in Settings, under MCPs):\n${notes.join("\n")}`
: "",
@@ -0,0 +1,54 @@
import { AppData } from "@/src/data/app-data";
import searchKnowledgebases from "@/src/functions/backend/db/knowledgebases/search-knowledgebases";
import type { KnowledgebaseRecord } from "@/src/types";
import knowledgebasePassageBlock from "@/src/utils/knowledgebase-passage-block";
type Params = {
knowledgebases: KnowledgebaseRecord[];
/**
* The knowledgebases the run may search (`openBotKnowledgebases`).
*/
ids: number[];
/**
* What the user just asked: the reply's unanswered messages.
*/
query: string;
signal?: AbortSignal;
};
/**
* The passages a reply starts with: the knowledgebases searched for the
* user's newest messages before the bot reads them, so a plain question
* gets an answer from the documents without a tool call. Empty when
* nothing matched or the search failed: the bot can still search itself.
*/
export default async function grabKnowledgebaseContext({ knowledgebases, ids, query, signal }: Params) {
const text = query.trim().slice(0, 1000);
if (!ids.length || text.length < 3) return "";
const result = await searchKnowledgebases({
knowledgebase_ids: ids,
query: text,
limit: AppData.KnowledgebaseAutoPassages,
signal,
}).catch(() => null);
if (!result?.hits.length) return "";
const names = new Map(knowledgebases.map((knowledgebase) => [knowledgebase.id, knowledgebase.name]));
const passages = result.hits.map((hit) =>
knowledgebasePassageBlock({
source: `${names.get(hit.knowledgebase_id) || "a knowledgebase"} / ${hit.document_name}`,
document_id: hit.document_id,
at: `passage ${hit.chunk_index}`,
text: hit.text,
}),
);
return `# From your knowledgebases
The app searched your knowledgebases for the user's newest message and found these passages (${result.mode == "vector" ? "by meaning" : "by matching words"}). They may or may not be relevant; use the ones that are, say which document an answer came from, and search_knowledgebase for more.
${passages.join("\n\n")}`;
}
@@ -0,0 +1,128 @@
import { z } from "zod";
import { AppData } from "@/src/data/app-data";
import { KnowledgebasesNote } from "@/src/data/harness-prompts";
import {
ReadKnowledgebaseDocumentToolName,
SearchKnowledgebaseToolName,
} from "@/src/dict/knowledgebase-dict";
import readKnowledgebaseDocument from "@/src/functions/backend/db/knowledgebases/read-knowledgebase-document";
import searchKnowledgebases from "@/src/functions/backend/db/knowledgebases/search-knowledgebases";
import type { KnowledgebaseRecord } from "@/src/types";
import knowledgebasePassageBlock from "@/src/utils/knowledgebase-passage-block";
import type { HarnessTool } from "../../types";
type Params = {
/**
* The knowledgebases the run reaches (`grabBotExtensions`).
*/
knowledgebases: KnowledgebaseRecord[];
/**
* Names the run's other tools already use: they win.
*/
taken?: string[];
};
export type OpenBotKnowledgebases = {
tools: HarnessTool[];
/**
* For the system prompt: how to use them. Empty when the run has none.
*/
note: string;
/**
* The enabled ones' ids, for retrieving passages ahead of a reply.
*/
ids: number[];
};
/**
* The knowledgebases a run has, as a search tool and a reader, both
* limited to them. Their list (name and what each holds) is in the search
* tool's description, so a bot knows when to reach for one.
*/
export default function openBotKnowledgebases({ knowledgebases, taken = [] }: Params): OpenBotKnowledgebases {
const enabled = knowledgebases.filter((knowledgebase) => knowledgebase.enabled);
if (!enabled.length || taken.includes(SearchKnowledgebaseToolName)) return { tools: [], note: "", ids: [] };
const ids = enabled.map((knowledgebase) => knowledgebase.id);
const names = new Map(enabled.map((knowledgebase) => [knowledgebase.id, knowledgebase.name]));
const list = enabled
.map((knowledgebase) => `- ${knowledgebase.id}: ${knowledgebase.name}${knowledgebase.description ? `: ${knowledgebase.description}` : ""}`)
.join("\n");
const search: HarnessTool<{ query: string; knowledgebase_id?: number; document_id?: number; limit?: number }> = {
name: SearchKnowledgebaseToolName,
description: `Searches your knowledgebases for the passages that answer "query" (by meaning, falling back to words). Leave knowledgebase_id out to search them all. Your knowledgebases (id: name: what it holds):\n${list}`,
schema: z.object({
query: z.string().trim().min(1).max(500),
knowledgebase_id: z
.number()
.int()
.positive()
.optional()
.describe(`Only this knowledgebase`),
document_id: z.number().int().positive().optional().describe(`Only this document's passages`),
limit: z.number().int().min(1).max(20).optional(),
}),
run: async ({ args, signal }) => {
if (args.knowledgebase_id && !ids.includes(args.knowledgebase_id)) {
throw new Error(`You have no knowledgebase ${args.knowledgebase_id}. Search one of: ${ids.join(", ")}.`);
}
const result = await searchKnowledgebases({
knowledgebase_ids: args.knowledgebase_id ? [args.knowledgebase_id] : ids,
query: args.query,
document_id: args.document_id,
limit: args.limit || AppData.RagTopChunks,
signal,
});
if (!result.hits.length) return result.note;
const passages = result.hits.map((hit) =>
knowledgebasePassageBlock({
source: `${names.get(hit.knowledgebase_id) || "a knowledgebase"} / ${hit.document_name}`,
document_id: hit.document_id,
at: `passage ${hit.chunk_index}`,
text: hit.text,
}),
);
return `${result.note}\n\n${passages.join("\n\n")}`;
},
};
const read: HarnessTool<{ document_id: number; from_passage?: number; from_char?: number }> = {
name: ReadKnowledgebaseDocumentToolName,
description: `Reads a knowledgebase document's own text, when a passage alone isn't enough: from a passage a search returned ("from_passage"), from where the last read stopped ("from_char"), or from the top.`,
schema: z.object({
document_id: z.number().int().positive(),
from_passage: z.number().int().min(0).optional(),
from_char: z.number().int().min(0).optional(),
}),
run: async ({ args }) => {
const document = await readKnowledgebaseDocument({
knowledgebase_ids: ids,
document_id: args.document_id,
from_passage: args.from_passage,
from_char: args.from_char,
max_chars: AppData.KnowledgebaseReadMaxChars,
});
if (!document.text) return `${document.name} has no text from there (it's ${document.length} characters long).`;
const block = knowledgebasePassageBlock({
source: document.name,
document_id: args.document_id,
at: `characters ${document.start} to ${document.start + document.text.length} of ${document.length}`,
text: document.text,
});
return document.next == null
? `${block}\n\n(End of ${document.name}.)`
: `${block}\n\n(${document.name} goes on: read on with from_char ${document.next}.)`;
},
};
return { tools: [search, read], note: KnowledgebasesNote, ids };
}