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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user