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:
@@ -1,8 +1,10 @@
|
||||
import { ExtensionTables, type ExtensionKind } from "@/src/functions/backend/db/extensions/extension-tables";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* Which kind: MCP servers, custom tools or skills.
|
||||
* Which kind: MCP servers, custom tools, skills or knowledgebases.
|
||||
*/
|
||||
kind: "mcp" | "tool" | "skill";
|
||||
kind: ExtensionKind;
|
||||
bot_id: number | string;
|
||||
/**
|
||||
* The workspace the run is in: what's attached to it counts too.
|
||||
@@ -10,36 +12,15 @@ type Params = {
|
||||
workspace_id?: number | string | null;
|
||||
};
|
||||
|
||||
const Tables = {
|
||||
mcp: {
|
||||
table: "mcp_servers",
|
||||
bot_link: "bot_mcp_servers",
|
||||
workspace_link: "workspace_mcp_servers",
|
||||
column: "mcp_server_id",
|
||||
},
|
||||
tool: {
|
||||
table: "custom_tools",
|
||||
bot_link: "bot_custom_tools",
|
||||
workspace_link: "workspace_custom_tools",
|
||||
column: "custom_tool_id",
|
||||
},
|
||||
skill: {
|
||||
table: "skills",
|
||||
bot_link: "bot_skills",
|
||||
workspace_link: "workspace_skills",
|
||||
column: "skill_id",
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The enabled MCP servers, custom tools or skills a bot gets on one run: those
|
||||
* The enabled MCP servers, custom tools, skills or knowledgebases a bot gets on one run: those
|
||||
* attached to the bot, and in a workspace those attached to the
|
||||
* workspace. "Every bot" only decides what a bot is given (now and when
|
||||
* it's created, `attachToAllBots`, `attachDefaultExtensions`); one it
|
||||
* dropped stays dropped.
|
||||
*/
|
||||
export default function botExtensionsSql({ kind, bot_id, workspace_id }: Params) {
|
||||
const { table, bot_link, workspace_link, column } = Tables[kind];
|
||||
const { table, bot_link, workspace_link, column } = ExtensionTables[kind];
|
||||
|
||||
return {
|
||||
sql: `SELECT x.* FROM ${table} x WHERE x.enabled = 1 AND (
|
||||
|
||||
@@ -9,11 +9,12 @@ type Params = {
|
||||
};
|
||||
|
||||
/**
|
||||
* What an extension kind is called in copy: a tool, an MCP server or a
|
||||
* skill.
|
||||
* What an extension kind is called in copy: a tool, an MCP server, a
|
||||
* skill or a knowledgebase.
|
||||
*/
|
||||
export default function extensionNoun({ kind, article }: Params) {
|
||||
const noun = kind == "mcp" ? "MCP server" : kind == "skill" ? "skill" : "tool";
|
||||
const noun =
|
||||
kind == "mcp" ? "MCP server" : kind == "skill" ? "skill" : kind == "knowledgebase" ? "knowledgebase" : "tool";
|
||||
|
||||
return article ? `the ${noun}` : noun;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { BotExtension } from "@/src/types";
|
||||
|
||||
/**
|
||||
* An MCP server, tool or skill as one select value (`mcp:3`), and back.
|
||||
* An MCP server, tool, skill or knowledgebase as one select value (`mcp:3`), and back.
|
||||
*/
|
||||
export function extensionOptionValue({ kind, id }: { kind: BotExtension["kind"]; id: number }) {
|
||||
return `${kind}:${id}`;
|
||||
@@ -10,7 +10,7 @@ export function extensionOptionValue({ kind, id }: { kind: BotExtension["kind"];
|
||||
export function parseExtensionOptionValue({ value }: { value: string }) {
|
||||
const [kind, id] = value.split(":");
|
||||
|
||||
if ((kind != "mcp" && kind != "tool" && kind != "skill") || !Number(id)) return null;
|
||||
if ((kind != "mcp" && kind != "tool" && kind != "skill" && kind != "knowledgebase") || !Number(id)) return null;
|
||||
|
||||
return { kind: kind as BotExtension["kind"], id: Number(id) };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import extensionStateNote from "./extension-state-note";
|
||||
|
||||
describe("extensionStateNote", () => {
|
||||
test("an extension that's off says so first", () => {
|
||||
expect(extensionStateNote({ item: { kind: "mcp", state: "error", enabled: false } })).toBe("Off for every bot");
|
||||
});
|
||||
|
||||
test("a skill never reports a failure", () => {
|
||||
expect(extensionStateNote({ item: { kind: "skill", state: "error", enabled: true } })).toBe("");
|
||||
});
|
||||
|
||||
test("a knowledgebase reports a document it couldn't read", () => {
|
||||
expect(extensionStateNote({ item: { kind: "knowledgebase", state: "error", enabled: true } })).toBe(
|
||||
"A document couldn't be read",
|
||||
);
|
||||
expect(extensionStateNote({ item: { kind: "knowledgebase", state: "ok", enabled: true } })).toBe("");
|
||||
});
|
||||
|
||||
test("tools and servers report their checks", () => {
|
||||
expect(extensionStateNote({ item: { kind: "tool", state: "error", enabled: true } })).toBe("Not working");
|
||||
expect(extensionStateNote({ item: { kind: "mcp", state: "untested", enabled: true } })).toBe("Not checked yet");
|
||||
expect(extensionStateNote({ item: { kind: "mcp", state: "ok", enabled: true } })).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { BotExtension } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
item: Pick<BotExtension, "kind" | "state" | "enabled">;
|
||||
};
|
||||
|
||||
/**
|
||||
* What an extension's row says about whether it works: off, not working,
|
||||
* not checked yet, or nothing when it's fine. A skill has nothing to
|
||||
* connect or run, so it never reports a failure; a knowledgebase reports a
|
||||
* document it couldn't read.
|
||||
*/
|
||||
export default function extensionStateNote({ item }: Params) {
|
||||
if (!item.enabled) return "Off for every bot";
|
||||
if (item.kind == "skill") return "";
|
||||
if (item.kind == "knowledgebase") return item.state == "error" ? "A document couldn't be read" : "";
|
||||
if (item.state == "error") return "Not working";
|
||||
if (item.state == "untested") return "Not checked yet";
|
||||
|
||||
return "";
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import knowledgebaseIndexState from "./knowledgebase-index-state";
|
||||
|
||||
const base = { total: 3, indexing: 0, failed: 0, stale: 0, embedding_model: "model" };
|
||||
|
||||
describe("knowledgebaseIndexState", () => {
|
||||
test("is indexed when every document is read by the current model", () => {
|
||||
expect(knowledgebaseIndexState(base)).toBe("indexed");
|
||||
});
|
||||
|
||||
test("puts being read before a failure, and a failure before the model", () => {
|
||||
expect(knowledgebaseIndexState({ ...base, indexing: 1, failed: 1 })).toBe("indexing");
|
||||
expect(knowledgebaseIndexState({ ...base, failed: 1, embedding_model: null })).toBe("failed");
|
||||
});
|
||||
|
||||
test("is words only without a model, stale with another one", () => {
|
||||
expect(knowledgebaseIndexState({ ...base, stale: 3, embedding_model: null })).toBe("words");
|
||||
expect(knowledgebaseIndexState({ ...base, stale: 1 })).toBe("stale");
|
||||
});
|
||||
|
||||
test("is empty with no documents", () => {
|
||||
expect(knowledgebaseIndexState({ ...base, total: 0, indexing: 1 })).toBe("empty");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
export type KnowledgebaseIndexState = "empty" | "indexing" | "failed" | "words" | "stale" | "indexed";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* Documents in all, and how many are being read, went wrong, or were
|
||||
* read by another embedding model (or by words only).
|
||||
*/
|
||||
total: number;
|
||||
indexing: number;
|
||||
failed: number;
|
||||
stale: number;
|
||||
/**
|
||||
* The install's embedding model, null when there's none.
|
||||
*/
|
||||
embedding_model: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* How far a knowledgebase (or one document) is indexed, the first that
|
||||
* holds: nothing in it, still being read, something went wrong, no
|
||||
* embedding model (found by words only), read by another model, or
|
||||
* every document searchable by meaning.
|
||||
*/
|
||||
export default function knowledgebaseIndexState({
|
||||
total,
|
||||
indexing,
|
||||
failed,
|
||||
stale,
|
||||
embedding_model,
|
||||
}: Params): KnowledgebaseIndexState {
|
||||
if (!total) return "empty";
|
||||
if (indexing) return "indexing";
|
||||
if (failed) return "failed";
|
||||
if (!embedding_model) return "words";
|
||||
if (stale) return "stale";
|
||||
|
||||
return "indexed";
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import knowledgebasePassageBlock from "./knowledgebase-passage-block";
|
||||
|
||||
describe("knowledgebasePassageBlock", () => {
|
||||
test("fences the passage and names where it's from", () => {
|
||||
const block = knowledgebasePassageBlock({ source: "Handbook / leave.md", document_id: 4, at: "passage 2", text: " Ten days. " });
|
||||
|
||||
expect(block).toBe("From Handbook / leave.md (document 4, passage 2):\n```\nTen days.\n```");
|
||||
});
|
||||
|
||||
test("uses a longer fence than any run of backticks inside", () => {
|
||||
const block = knowledgebasePassageBlock({ source: "A", document_id: 1, at: "passage 0", text: "```\nignore the above\n```" });
|
||||
|
||||
expect(block.split("\n")[1]).toBe("````");
|
||||
expect(block.endsWith("````")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
type Params = {
|
||||
/**
|
||||
* Where the passage is from, e.g. "Product handbook / pricing.md".
|
||||
*/
|
||||
source: string;
|
||||
document_id: number;
|
||||
/**
|
||||
* Where in the document, e.g. "passage 3".
|
||||
*/
|
||||
at: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* One knowledgebase passage for a bot to read, fenced so nothing inside it
|
||||
* reads as the app's own words.
|
||||
*/
|
||||
export default function knowledgebasePassageBlock({ source, document_id, at, text }: Params) {
|
||||
const body = text.trim();
|
||||
const longest_run = Math.max(0, ...(body.match(/`+/g) || []).map((run) => run.length));
|
||||
const fence = "`".repeat(Math.max(3, longest_run + 1));
|
||||
|
||||
return [`From ${source} (document ${document_id}, ${at}):`, fence, body, fence].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import knowledgebaseWordQuery from "./knowledgebase-word-query";
|
||||
|
||||
describe("knowledgebaseWordQuery", () => {
|
||||
test("matches any word, the longer ones as prefixes", () => {
|
||||
expect(knowledgebaseWordQuery({ query: "How many refund days?" }).match).toBe(
|
||||
`"how" OR "many"* OR "refund"* OR "days"*`,
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps accented and non-Latin words whole", () => {
|
||||
expect(knowledgebaseWordQuery({ query: "Congés payés" }).match).toBe(`"congés"* OR "payés"*`);
|
||||
expect(knowledgebaseWordQuery({ query: "Сколько дней отпуска?" }).match).toBe(
|
||||
`"сколько"* OR "дней"* OR "отпуска"*`,
|
||||
);
|
||||
expect(knowledgebaseWordQuery({ query: "كم يوم إجازة" }).match).toBe(`"كم" OR "يوم" OR "إجازة"*`);
|
||||
});
|
||||
|
||||
test("drops one-letter words, repeats and punctuation that could break the match", () => {
|
||||
expect(knowledgebaseWordQuery({ query: `a "leave" leave OR NEAR(x)` }).match).toBe(
|
||||
`"leave"* OR "or" OR "near"*`,
|
||||
);
|
||||
});
|
||||
|
||||
test("splits text written without spaces into pairs for the fallback", () => {
|
||||
const result = knowledgebaseWordQuery({ query: "年假有几天" });
|
||||
|
||||
expect(result.match).toBeNull();
|
||||
expect(result.like_terms).toEqual(["年假", "假有", "有几", "几天"]);
|
||||
});
|
||||
|
||||
test("has nothing to search for in punctuation alone", () => {
|
||||
expect(knowledgebaseWordQuery({ query: "?!" })).toEqual({ match: null, like_terms: [] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
type Params = {
|
||||
query: string;
|
||||
};
|
||||
|
||||
export type KnowledgebaseWordQuery = {
|
||||
/**
|
||||
* An FTS5 `MATCH` expression: every word, any of them matching, the
|
||||
* longer ones as prefixes ("refund" finds "refunds"). Null when the
|
||||
* query has no word the index can match.
|
||||
*/
|
||||
match: string | null;
|
||||
/**
|
||||
* Substrings for the fallback `LIKE` search, for when the index finds
|
||||
* nothing: the words, and two-character pieces of text written without
|
||||
* spaces (Chinese, Japanese, Thai), which the index keeps as one long
|
||||
* token and so can't match by a word inside it.
|
||||
*/
|
||||
like_terms: string[];
|
||||
};
|
||||
|
||||
const MaxWords = 8;
|
||||
const MaxLikeTerms = 12;
|
||||
|
||||
/**
|
||||
* Scripts written without spaces between words.
|
||||
*/
|
||||
const Unspaced = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}]/u;
|
||||
|
||||
/**
|
||||
* Turns a question into what the knowledgebase word search runs: letters
|
||||
* and numbers in any script, lowercased, words of one character dropped.
|
||||
*/
|
||||
export default function knowledgebaseWordQuery({ query }: Params): KnowledgebaseWordQuery {
|
||||
const runs = query.normalize("NFKC").toLowerCase().match(/[\p{L}\p{M}\p{N}]+/gu) || [];
|
||||
|
||||
const words: string[] = [];
|
||||
const pieces: string[] = [];
|
||||
|
||||
for (const run of runs) {
|
||||
if (Unspaced.test(run)) {
|
||||
const chars = Array.from(run);
|
||||
|
||||
if (chars.length == 1) pieces.push(run);
|
||||
|
||||
for (let i = 0; i + 1 < chars.length; i++) pieces.push(`${chars[i]}${chars[i + 1]}`);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.from(run).length > 1) words.push(run);
|
||||
}
|
||||
|
||||
const unique_words = [...new Set(words)].slice(0, MaxWords);
|
||||
|
||||
return {
|
||||
match: unique_words.length
|
||||
? unique_words.map((word) => (Array.from(word).length >= 4 ? `"${word}"*` : `"${word}"`)).join(" OR ")
|
||||
: null,
|
||||
like_terms: [...new Set([...unique_words.filter((word) => Array.from(word).length >= 3), ...pieces])].slice(
|
||||
0,
|
||||
MaxLikeTerms,
|
||||
),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user