设计知识库系统
问题
如何设计一个面向团队/企业的知识库系统?需要考虑哪些核心功能和技术方案?
答案
知识库系统是企业内部信息沉淀和知识共享的核心工具,典型产品包括 Notion、语雀、Confluence、飞书文档等。本文从前端架构和全栈视角,系统梳理知识库的设计要点。
系统架构全景
1. 文档模型设计
数据结构
// 文档核心模型
interface Document {
id: string; // UUID
spaceId: string; // 所属空间
parentId: string | null; // 父文档(支持无限层级)
title: string;
content: DocumentContent; // 文档内容(结构化 JSON)
contentText: string; // 纯文本(用于搜索索引)
coverImage?: string;
icon?: string; // emoji 或自定义图标
slug: string; // URL 友好路径
status: 'draft' | 'published' | 'archived';
sortOrder: number; // 同级排序
createdBy: string;
updatedBy: string;
createdAt: Date;
updatedAt: Date;
publishedAt?: Date;
version: number; // 乐观锁版本号
wordCount: number;
}
// 结构化文档内容(兼容 ProseMirror / TipTap / Slate)
interface DocumentContent {
type: 'doc';
content: BlockNode[];
}
type BlockNode =
| ParagraphNode
| HeadingNode
| CodeBlockNode
| ImageNode
| TableNode
| CalloutNode
| EmbedNode;
interface HeadingNode {
type: 'heading';
attrs: { level: 1 | 2 | 3 | 4 | 5 | 6 };
content: InlineNode[];
}
目录树结构
知识库的核心交互是树形目录导航,设计上需要支持:
- 无限层级嵌套
- 拖拽排序
- 延迟加载(大量文档时不一次性加载)
// 方案一:邻接表(最常用)
// 每个文档存 parentId,通过递归查询构建树
interface TreeNode {
id: string;
parentId: string | null;
title: string;
icon?: string;
sortOrder: number;
hasChildren: boolean; // 是否有子文档(用于延迟加载)
children?: TreeNode[];
}
// 获取目录树(两级预加载 + 按需展开)
async function getDocumentTree(
spaceId: string,
parentId: string | null = null,
depth: number = 2,
): Promise<TreeNode[]> {
const docs = await prisma.document.findMany({
where: { spaceId, parentId, status: { not: 'archived' } },
select: {
id: true,
title: true,
icon: true,
parentId: true,
sortOrder: true,
_count: { select: { children: true } },
},
orderBy: { sortOrder: 'asc' },
});
return Promise.all(
docs.map(async (doc) => ({
id: doc.id,
parentId: doc.parentId,
title: doc.title,
icon: doc.icon ?? undefined,
sortOrder: doc.sortOrder,
hasChildren: doc._count.children > 0,
children: depth > 1 ? await getDocumentTree(spaceId, doc.id, depth - 1) : undefined,
})),
);
}
// 方案二:物化路径(适合读多写少,查询祖先链快)
// path: "/root-id/parent-id/current-id"
// 查询某节点的所有子孙:WHERE path LIKE '/root-id/parent-id/%'
| 方案 | 优点 | 缺点 |
|---|---|---|
整数排序 sortOrder: 1, 2, 3 | 简单直观 | 拖拽插入需更新多行 |
分数排序 sortOrder: 1.5(位于 1 和 2 之间) | 插入不影响其他行 | 精度有限,需定期重排 |
字符串排序 aaa, aab, aac | 插入方便 | 可读性差 |
链表 prevId / nextId | 插入 O(1) | 查询全序需遍历 |
2. 富文本编辑器
编辑器是知识库的核心组件,主流技术选型:
| 编辑器 | 核心库 | 数据模型 | 适用场景 |
|---|---|---|---|
| TipTap | ProseMirror | JSON(Schema 约束) | 最推荐,生态丰富 |
| Slate.js | 自研 | JSON(自定义 Schema) | 高度定制化需求 |
| Lexical | Meta 开发 | JSON(不可变状态树) | React 生态 |
| Editor.js | 自研 | JSON(Block 式) | 块编辑器(类 Notion) |
| Quill | 自研 | Delta(操作序列) | 简单场景 |
中大型知识库推荐 TipTap(基于 ProseMirror):Schema 约束保证数据一致性、插件生态丰富、协同编辑支持好(配合 Y.js)。详见 设计富文本编辑器。
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Placeholder from '@tiptap/extension-placeholder';
import Image from '@tiptap/extension-image';
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
import Table from '@tiptap/extension-table';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
import { common, createLowlight } from 'lowlight';
const lowlight = createLowlight(common);
function KBEditor({
content,
onUpdate,
}: {
content: DocumentContent;
onUpdate: (content: DocumentContent) => void;
}) {
const editor = useEditor({
extensions: [
StarterKit.configure({
codeBlock: false, // 用 CodeBlockLowlight 替换
}),
Placeholder.configure({ placeholder: '输入 / 唤起命令菜单...' }),
Image.configure({ allowBase64: false }),
CodeBlockLowlight.configure({ lowlight }),
Table.configure({ resizable: true }),
TaskList,
TaskItem.configure({ nested: true }),
],
content,
onUpdate: ({ editor }) => {
onUpdate(editor.getJSON() as DocumentContent);
},
});
return <EditorContent editor={editor} />;
}
斜杠命令(Slash Commands)
Notion 式的 / 命令菜单,是知识库编辑器的标配交互:
import { Extension } from '@tiptap/core';
import Suggestion from '@tiptap/suggestion';
interface CommandItem {
title: string;
description: string;
icon: string;
command: (editor: Editor) => void;
}
const commands: CommandItem[] = [
{
title: '标题 1',
description: '大标题',
icon: 'H1',
command: (editor) => editor.chain().focus().toggleHeading({ level: 1 }).run(),
},
{
title: '代码块',
description: '插入代码',
icon: 'Code',
command: (editor) => editor.chain().focus().toggleCodeBlock().run(),
},
{
title: '图片',
description: '上传或嵌入图片',
icon: 'Image',
command: (editor) => {
// 触发图片上传
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
const url = await uploadImage(file);
editor.chain().focus().setImage({ src: url }).run();
};
input.click();
},
},
{
title: '表格',
description: '插入表格',
icon: 'Table',
command: (editor) =>
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(),
},
{
title: '待办列表',
description: '可勾选的任务列表',
icon: 'CheckSquare',
command: (editor) => editor.chain().focus().toggleTaskList().run(),
},
];
3. 文档存储与版本管理
内容存储方案
| 方案 | 适用场景 | 优缺点 |
|---|---|---|
| PostgreSQL JSONB | 中小规模 | 查询灵活,支持 JSON 路径查询 |
| MongoDB | 文档结构频繁变化 | Schema-less,天然适合文档 |
| PostgreSQL + 单独 content 表 | 大规模 | 元数据和内容分离,减轻主表压力 |
model Document {
id String @id @default(uuid())
spaceId String
parentId String?
title String
slug String
status DocumentStatus @default(DRAFT)
sortOrder Float @default(0)
version Int @default(1)
wordCount Int @default(0)
createdBy String
updatedBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
space Space @relation(fields: [spaceId], references: [id])
parent Document? @relation("DocTree", fields: [parentId], references: [id])
children Document[] @relation("DocTree")
content DocumentContent?
versions DocumentVersion[]
@@index([spaceId, parentId, sortOrder])
@@index([spaceId, status])
@@unique([spaceId, slug])
}
// 内容独立表(避免大 JSON 拖慢列表查询)
model DocumentContent {
id String @id @default(uuid())
documentId String @unique
body Json // TipTap JSON
bodyText String // 纯文本(搜索用)
document Document @relation(fields: [documentId], references: [id])
}
版本历史
// 保存版本快照
async function saveVersion(docId: string, userId: string): Promise<void> {
const doc = await prisma.document.findUnique({
where: { id: docId },
include: { content: true },
});
if (!doc || !doc.content) return;
await prisma.documentVersion.create({
data: {
documentId: docId,
title: doc.title,
body: doc.content.body as any,
version: doc.version,
createdBy: userId,
},
});
}
// 自动保存策略:防抖 + 定期快照
// - 每次编辑后 3 秒防抖自动保存(草稿)
// - 每 10 分钟或重大编辑时创建版本快照
// - 手动保存时创建版本快照
// 版本对比(Diff)
import { diffChars } from 'diff';
function compareVersions(oldText: string, newText: string) {
return diffChars(oldText, newText).map((part) => ({
value: part.value,
type: part.added ? 'added' : part.removed ? 'removed' : 'unchanged',
}));
}
4. 全文搜索
搜索是知识库的核心体验,需要支持标题搜索、内容全文搜索、标签筛选。
Elasticsearch 方案
import { Client } from '@elastic/elasticsearch';
const esClient = new Client({ node: 'http://localhost:9200' });
// 索引映射
const indexMapping = {
mappings: {
properties: {
title: {
type: 'text',
analyzer: 'ik_max_word', // 中文分词
search_analyzer: 'ik_smart',
boost: 3, // 标题权重更高
},
content: {
type: 'text',
analyzer: 'ik_max_word',
},
tags: { type: 'keyword' },
spaceId: { type: 'keyword' },
status: { type: 'keyword' },
createdBy: { type: 'keyword' },
updatedAt: { type: 'date' },
},
},
};
// 搜索接口
async function searchDocuments(params: {
query: string;
spaceId: string;
page?: number;
pageSize?: number;
tags?: string[];
}): Promise<SearchResult> {
const { query, spaceId, page = 1, pageSize = 20, tags } = params;
const must: any[] = [
{ term: { spaceId } },
{ term: { status: 'published' } },
{
multi_match: {
query,
fields: ['title^3', 'content'],
type: 'best_fields',
fuzziness: 'AUTO', // 模糊匹配,容忍拼写错误
},
},
];
if (tags?.length) {
must.push({ terms: { tags } });
}
const result = await esClient.search({
index: 'knowledge-base',
body: {
query: { bool: { must } },
highlight: {
fields: {
title: { number_of_fragments: 0 },
content: { fragment_size: 150, number_of_fragments: 3 },
},
pre_tags: ['<mark>'],
post_tags: ['</mark>'],
},
from: (page - 1) * pageSize,
size: pageSize,
sort: [{ _score: 'desc' }, { updatedAt: 'desc' }],
},
});
return {
total: (result.hits.total as any).value,
items: result.hits.hits.map((hit: any) => ({
id: hit._id,
title: hit.highlight?.title?.[0] || hit._source.title,
excerpt: hit.highlight?.content?.join('...') || '',
score: hit._score,
updatedAt: hit._source.updatedAt,
})),
};
}
搜索索引同步
// 文档变更时同步到 ES
async function syncToSearch(docId: string): Promise<void> {
const doc = await prisma.document.findUnique({
where: { id: docId },
include: { content: true },
});
if (!doc) {
await esClient.delete({ index: 'knowledge-base', id: docId }).catch(() => {});
return;
}
await esClient.index({
index: 'knowledge-base',
id: docId,
body: {
title: doc.title,
content: doc.content?.bodyText || '',
tags: doc.tags,
spaceId: doc.spaceId,
status: doc.status,
createdBy: doc.createdBy,
updatedAt: doc.updatedAt,
},
});
}
// 通过消息队列异步同步(避免影响写入性能)
// Document Service → MQ → Search Sync Worker → Elasticsearch
如果不想引入 ES,PostgreSQL 自带全文搜索也能满足中小规模需求:
-- 创建全文搜索索引
ALTER TABLE documents ADD COLUMN search_vector tsvector;
CREATE INDEX idx_search ON documents USING gin(search_vector);
-- 中文分词需安装 zhparser 插件
-- 查询
SELECT * FROM documents
WHERE search_vector @@ plainto_tsquery('chinese', '知识库设计')
ORDER BY ts_rank(search_vector, plainto_tsquery('chinese', '知识库设计')) DESC;
5. 权限系统
知识库的权限通常分为空间级和文档级两层。
// 角色体系
type SpaceRole = 'owner' | 'admin' | 'editor' | 'viewer';
// 权限矩阵
const permissionMatrix: Record<SpaceRole, string[]> = {
owner: ['*'], // 所有权限
admin: ['doc:create', 'doc:edit', 'doc:delete', 'doc:publish', 'member:manage'],
editor: ['doc:create', 'doc:edit', 'doc:publish'],
viewer: ['doc:read'],
};
// 文档级权限(可覆盖空间级权限)
interface DocumentPermission {
documentId: string;
targetType: 'user' | 'group';
targetId: string;
permission: 'read' | 'edit' | 'admin';
}
// 权限检查中间件
async function checkDocPermission(
userId: string,
docId: string,
action: 'read' | 'edit' | 'delete' | 'publish',
): Promise<boolean> {
const doc = await prisma.document.findUnique({
where: { id: docId },
select: { spaceId: true, createdBy: true },
});
if (!doc) return false;
// 1. 检查空间角色
const membership = await prisma.spaceMember.findUnique({
where: { spaceId_userId: { spaceId: doc.spaceId, userId } },
});
if (!membership) return false;
const spacePermissions = permissionMatrix[membership.role as SpaceRole];
if (spacePermissions.includes('*')) return true;
// 2. 检查文档级权限覆盖
const docPerm = await prisma.documentPermission.findFirst({
where: { documentId: docId, targetId: userId },
});
if (docPerm) {
return checkActionAllowed(docPerm.permission, action);
}
// 3. 回退到空间角色权限
const actionMap: Record<string, string> = {
read: 'doc:read',
edit: 'doc:edit',
delete: 'doc:delete',
publish: 'doc:publish',
};
return spacePermissions.includes(actionMap[action]);
}
分享与公开链接
// 生成分享链接(带 Token 的只读链接)
async function createShareLink(
docId: string,
options: { expiresIn?: number; password?: string },
): Promise<string> {
const token = crypto.randomUUID();
await prisma.shareLink.create({
data: {
documentId: docId,
token,
password: options.password ? await bcrypt.hash(options.password, 10) : null,
expiresAt: options.expiresIn
? new Date(Date.now() + options.expiresIn * 1000)
: null,
},
});
return `${BASE_URL}/share/${token}`;
}
6. AI 增强
AI 是现代知识库的差异化核心能力,覆盖从创作到消费的全生命周期。
AI 能力全景
| 能力 | 触发方式 | 核心技术 | 用户价值 |
|---|---|---|---|
| AI 问答 | 聊天框提问 | RAG(检索 + 生成) | 快速从海量文档中获取答案 |
| 写作辅助 | 编辑器内选中文本 / 斜杠命令 | Prompt Engineering + 流式输出 | 提升写作效率 |
| 智能摘要 | 查看文档时自动生成 | LLM Summarization | 快速了解长文档要点 |
| 自动标签 | 发布文档时自动触发 | LLM Classification | 减少人工打标成本 |
| 语义搜索 | 搜索框输入 | Embedding 相似度 | 搜索"意思"而非"关键词" |
| 相关推荐 | 文档底部推荐区 | Embedding 近邻查询 | 发现关联知识 |
| AI 翻译 | 编辑器工具栏 | LLM Translation | 知识库多语言化 |
6.1 Embedding 索引管线
所有 AI 能力的基础是将文档转换为向量(Embedding),这需要一套完整的索引管线。
import { openai } from '@ai-sdk/openai';
import { embed, embedMany } from 'ai';
// ========== 文档分块策略 ==========
interface Chunk {
index: number;
text: string;
metadata: {
headings: string[]; // 所属标题链
type: 'paragraph' | 'code' | 'table' | 'list';
};
}
// 知识库分块:按标题层级 + 段落分割(比固定 token 切割更精准)
function splitByHeadings(content: DocumentContent): Chunk[] {
const chunks: Chunk[] = [];
let currentHeadings: string[] = [];
let currentText = '';
let chunkIndex = 0;
for (const node of content.content) {
if (node.type === 'heading') {
// 遇到新标题 → 保存当前块,开始新块
if (currentText.trim()) {
chunks.push({
index: chunkIndex++,
text: currentText.trim(),
metadata: { headings: [...currentHeadings], type: 'paragraph' },
});
currentText = '';
}
// 更新标题链
const level = node.attrs.level;
currentHeadings = currentHeadings.slice(0, level - 1);
currentHeadings[level - 1] = extractText(node);
} else {
currentText += nodeToText(node) + '\n';
}
// 单块超过 maxTokens 时强制截断
if (estimateTokens(currentText) > 1000) {
chunks.push({
index: chunkIndex++,
text: currentText.trim(),
metadata: { headings: [...currentHeadings], type: 'paragraph' },
});
currentText = '';
}
}
// 最后一块
if (currentText.trim()) {
chunks.push({
index: chunkIndex++,
text: currentText.trim(),
metadata: { headings: [...currentHeadings], type: 'paragraph' },
});
}
return chunks;
}
// ========== Embedding 生成与存储 ==========
async function indexDocument(doc: {
id: string;
title: string;
spaceId: string;
content: DocumentContent;
}): Promise<void> {
const chunks = splitByHeadings(doc.content);
// 批量生成 Embedding(减少 API 调用次数)
const texts = chunks.map((c) => {
// 将标题链拼入文本,提升检索时的上下文匹配度
const headingContext = c.metadata.headings.join(' > ');
return headingContext ? `${headingContext}\n${c.text}` : c.text;
});
const { embeddings } = await embedMany({
model: openai.embedding('text-embedding-3-small'),
values: texts,
});
// 先删除旧索引,再写入新索引(全量替换)
await vectorDB.deleteMany({
filter: { documentId: doc.id },
});
const vectors = chunks.map((chunk, i) => ({
id: `${doc.id}:${chunk.index}`,
values: embeddings[i],
metadata: {
documentId: doc.id,
title: doc.title,
spaceId: doc.spaceId,
text: chunk.text,
headings: chunk.metadata.headings,
chunkType: chunk.metadata.type,
},
}));
await vectorDB.upsertBatch(vectors);
}
| 策略 | 原理 | 适用场景 |
|---|---|---|
| 固定 Token | 每 512 tokens 切一块 + 重叠 50 | 通用场景 |
| 标题层级 | 按 H1/H2/H3 分割 | 结构化文档(知识库推荐) |
| 语义分割 | 用 LLM 判断段落边界 | 长文、无明确结构 |
| 递归分割 | 先按标题 → 再按段落 → 再按句子 | LangChain 默认 |
知识库文档通常有清晰的标题结构,推荐标题层级分割,并将标题链作为上下文拼入 chunk。
- 异步处理:索引操作耗时较长,通过消息队列异步执行,不阻塞文档保存
- 增量更新:文档更新时全量替换该文档的所有 chunks,避免旧数据残留
- 权限过滤:向量检索时 必须 带
spaceId过滤,防止跨空间信息泄露 - Token 成本:text-embedding-3-small 约 2-5
6.2 AI 问答(RAG)
RAG(Retrieval-Augmented Generation)是知识库 AI 问答的核心架构。
import { openai } from '@ai-sdk/openai';
import { streamText, embed } from 'ai';
// ========== 完整的 RAG 问答流程 ==========
interface QAResult {
stream: ReadableStream;
sources: Source[];
}
interface Source {
documentId: string;
title: string;
excerpt: string;
headings: string[];
score: number;
}
async function askQuestion(
question: string,
spaceId: string,
options?: {
conversationHistory?: { role: 'user' | 'assistant'; content: string }[];
topK?: number;
},
): Promise<QAResult> {
const { conversationHistory = [], topK = 8 } = options ?? {};
// 1. Query 改写(HyDE:假设性文档嵌入)
// 先让 LLM 生成一段"假设性回答",用它来检索,比直接用问题效果更好
const { text: hypotheticalAnswer } = await generateText({
model: openai('gpt-4o-mini'),
system: '你是知识库助手。请根据问题写出一段可能的回答(不需要准确,用于检索)。',
prompt: question,
maxTokens: 200,
});
// 2. 双路检索:原始问题 + HyDE 假设回答
const [questionEmbedding, hydeEmbedding] = await Promise.all([
embed({ model: openai.embedding('text-embedding-3-small'), value: question }),
embed({ model: openai.embedding('text-embedding-3-small'), value: hypotheticalAnswer }),
]);
const [results1, results2] = await Promise.all([
vectorDB.query({
vector: questionEmbedding.embedding,
topK,
filter: { spaceId },
}),
vectorDB.query({
vector: hydeEmbedding.embedding,
topK,
filter: { spaceId },
}),
]);
// 3. 合并去重 + Reranker 重排序
const mergedChunks = deduplicateByDocChunk(
[...results1.matches, ...results2.matches],
);
// 用 Cohere Reranker 或交叉编码器精排
const reranked = await rerankChunks(question, mergedChunks, { topN: 5 });
// 4. 构造上下文
const sources: Source[] = reranked.map((chunk) => ({
documentId: chunk.metadata.documentId,
title: chunk.metadata.title,
excerpt: chunk.metadata.text.slice(0, 150),
headings: chunk.metadata.headings,
score: chunk.score,
}));
const context = reranked
.map((chunk, i) => `[来源${i + 1}: ${chunk.metadata.title}]\n${chunk.metadata.text}`)
.join('\n\n---\n\n');
// 5. LLM 流式生成回答
const result = streamText({
model: openai('gpt-4o'),
system: `你是「知识库助手」,基于提供的文档内容回答用户问题。
规则:
- 只基于文档内容回答,不要编造
- 如果文档中没有相关信息,如实说"知识库中暂无相关信息"
- 回答中引用来源时使用 [来源N] 标记
- 回答后给出 2-3 个用户可能想继续问的问题`,
messages: [
// 对话历史(支持多轮)
...conversationHistory,
{
role: 'user',
content: `参考文档:\n${context}\n\n用户问题:${question}`,
},
],
});
return { stream: result.toDataStream(), sources };
}
// ========== 辅助函数 ==========
// 合并去重:同一文档的同一 chunk 只保留得分最高的
function deduplicateByDocChunk(matches: VectorMatch[]): VectorMatch[] {
const map = new Map<string, VectorMatch>();
for (const m of matches) {
const key = m.id;
const existing = map.get(key);
if (!existing || m.score > existing.score) {
map.set(key, m);
}
}
return [...map.values()].sort((a, b) => b.score - a.score);
}
// Reranker 重排序(提升检索精度)
async function rerankChunks(
query: string,
chunks: VectorMatch[],
options: { topN: number },
): Promise<VectorMatch[]> {
// 方案 1:Cohere Rerank API
const response = await fetch('https://api.cohere.ai/v1/rerank', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.COHERE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query,
documents: chunks.map((c) => c.metadata.text),
top_n: options.topN,
model: 'rerank-multilingual-v3.0',
}),
});
const data = await response.json();
return data.results.map((r: any) => ({
...chunks[r.index],
score: r.relevance_score,
}));
}
前端 AI 问答界面
import { useChat } from 'ai/react';
import { useState } from 'react';
interface SourceItem {
documentId: string;
title: string;
excerpt: string;
headings: string[];
}
function AIChat({ spaceId }: { spaceId: string }) {
const [sources, setSources] = useState<SourceItem[]>([]);
const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({
api: '/api/ai/chat',
body: { spaceId },
onResponse: async (response) => {
// 从自定义 header 获取引用来源
const sourcesHeader = response.headers.get('X-Sources');
if (sourcesHeader) {
setSources(JSON.parse(sourcesHeader));
}
},
});
return (
<div className="ai-chat">
{/* 消息列表 */}
<div className="messages">
{messages.map((msg) => (
<div key={msg.id} className={`message ${msg.role}`}>
{msg.role === 'assistant' ? (
<MarkdownRenderer content={msg.content} />
) : (
<p>{msg.content}</p>
)}
</div>
))}
{isLoading && <TypingIndicator />}
</div>
{/* 引用来源卡片 */}
{sources.length > 0 && (
<div className="sources">
<h4>参考来源</h4>
{sources.map((source, i) => (
<a
key={source.documentId}
href={`/docs/${source.documentId}`}
className="source-card"
>
<span className="source-index">[{i + 1}]</span>
<span className="source-title">{source.title}</span>
<span className="source-path">{source.headings.join(' > ')}</span>
<p className="source-excerpt">{source.excerpt}</p>
</a>
))}
</div>
)}
{/* 输入框 */}
<form onSubmit={handleSubmit}>
<input
value={input}
onChange={handleInputChange}
placeholder="基于知识库提问..."
disabled={isLoading}
/>
</form>
</div>
);
}
流式渲染和 Markdown 处理的详细方案详见 流式渲染与 Markdown、AI 聊天界面设计。
6.3 AI 写作辅助
编辑器内的 AI 辅助写作,用户选中文本后通过浮动工具栏或 / 斜杠命令触发。
import { openai } from '@ai-sdk/openai';
import { streamText } from 'ai';
type AIAction =
| 'continue' // 续写
| 'summarize' // 摘要
| 'expand' // 扩展
| 'simplify' // 简化
| 'translate' // 翻译
| 'fix_grammar' // 修正语法
| 'generate_outline' // 生成大纲
| 'rewrite_tone'; // 改变语气
// AI 写作辅助(流式输出)
async function aiWritingAssist(params: {
action: AIAction;
selectedText: string;
documentContext?: string; // 选中文本前后的上下文
targetLanguage?: string; // 翻译目标语言
}): Promise<ReadableStream> {
const { action, selectedText, documentContext, targetLanguage } = params;
const systemPrompts: Record<AIAction, string> = {
continue: `你是知识库写作助手。请基于上下文自然续写内容,保持风格一致。`,
summarize: `你是知识库写作助手。请生成简洁的摘要,保留关键信息,使用要点列表。`,
expand: `你是知识库写作助手。请扩展内容,补充更多细节、示例和解释。`,
simplify: `你是知识库写作助手。请用更简洁易懂的方式重写,适合新手阅读。`,
translate: `你是专业翻译。请将内容翻译为${targetLanguage || '英文'},保持技术术语准确。`,
fix_grammar: `你是知识库写作助手。请修正语法和拼写错误,保持原意不变。只输出修正后的内容。`,
generate_outline: `你是知识库写作助手。请为给定主题生成详细的文档大纲,使用 Markdown 标题格式。`,
rewrite_tone: `你是知识库写作助手。请将内容改写为更正式/专业的语气。`,
};
const userPrompt = documentContext
? `上下文:\n${documentContext}\n\n需要处理的内容:\n${selectedText}`
: selectedText;
const result = streamText({
model: openai('gpt-4o'),
system: systemPrompts[action],
prompt: userPrompt,
maxTokens: 2000,
});
return result.toDataStream();
}
编辑器中集成 AI 工具栏
import { useState, useCallback } from 'react';
import { Editor } from '@tiptap/react';
function AIToolbar({ editor }: { editor: Editor }) {
const [isLoading, setIsLoading] = useState(false);
// 获取选中文本
const getSelectedText = useCallback(() => {
const { from, to } = editor.state.selection;
return editor.state.doc.textBetween(from, to, '\n');
}, [editor]);
// 获取选中文本前后的上下文
const getContext = useCallback(() => {
const { from, to } = editor.state.selection;
const before = editor.state.doc.textBetween(Math.max(0, from - 500), from, '\n');
const after = editor.state.doc.textBetween(to, Math.min(editor.state.doc.content.size, to + 500), '\n');
return `...${before}\n[选中内容]\n${after}...`;
}, [editor]);
const handleAIAction = async (action: AIAction) => {
const selectedText = getSelectedText();
if (!selectedText) return;
setIsLoading(true);
const response = await fetch('/api/ai/writing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action,
selectedText,
documentContext: getContext(),
}),
});
// 流式插入到编辑器
const reader = response.body!.getReader();
const decoder = new TextDecoder();
const { to } = editor.state.selection;
// 在选中文本后插入 AI 生成内容
let insertPos = to;
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
editor.chain().insertContentAt(insertPos, text).run();
insertPos += text.length;
}
setIsLoading(false);
};
return (
<div className="ai-toolbar">
<button onClick={() => handleAIAction('continue')} disabled={isLoading}>
续写
</button>
<button onClick={() => handleAIAction('summarize')} disabled={isLoading}>
摘要
</button>
<button onClick={() => handleAIAction('expand')} disabled={isLoading}>
扩展
</button>
<button onClick={() => handleAIAction('simplify')} disabled={isLoading}>
简化
</button>
<button onClick={() => handleAIAction('translate')} disabled={isLoading}>
翻译
</button>
<button onClick={() => handleAIAction('fix_grammar')} disabled={isLoading}>
修正
</button>
{isLoading && <span className="loading">AI 生成中...</span>}
</div>
);
}
6.4 智能摘要
自动为文档生成摘要,展示在文档列表、搜索结果、分享卡片中。
import { generateText } from 'ai';
import { openai } from '@ai-sdk/openai';
// 文档发布时异步生成摘要
async function generateDocSummary(docId: string): Promise<void> {
const doc = await prisma.document.findUnique({
where: { id: docId },
include: { content: true },
});
if (!doc?.content) return;
const bodyText = doc.content.bodyText;
// 短文档不需要摘要
if (bodyText.length < 200) {
await prisma.document.update({
where: { id: docId },
data: { summary: bodyText.slice(0, 100) },
});
return;
}
// 截取前 3000 字(避免 Token 过多)
const truncated = bodyText.slice(0, 3000);
const { text } = await generateText({
model: openai('gpt-4o-mini'), // 摘要用小模型即可
system: '你是知识库助手。请为以下文档生成一句话摘要(不超过 100 字),概括核心内容。',
prompt: `标题:${doc.title}\n\n内容:${truncated}`,
maxTokens: 100,
});
await prisma.document.update({
where: { id: docId },
data: { summary: text },
});
}
6.5 自动标签与分类
import { generateObject } from 'ai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
// 文档发布时自动打标签
async function autoTagDocument(docId: string): Promise<void> {
const doc = await prisma.document.findUnique({
where: { id: docId },
include: { content: true },
});
if (!doc?.content) return;
// 获取空间内已有的标签列表(让 AI 优先选择已有标签)
const existingTags = await prisma.tag.findMany({
where: { spaceId: doc.spaceId },
select: { name: true },
});
const { object } = await generateObject({
model: openai('gpt-4o-mini'),
schema: z.object({
tags: z.array(z.string().max(20)).min(1).max(5),
category: z.string().max(30),
}),
system: `你是知识库内容分类助手。
已有标签列表:${existingTags.map(t => t.name).join(', ')}
规则:
- 优先从已有标签中选择匹配的
- 如果已有标签不够,可以新建(但要简洁通用)
- 标签数量 1-5 个
- 同时给出一个最匹配的分类`,
prompt: `标题:${doc.title}\n\n内容:${doc.content.bodyText.slice(0, 2000)}`,
});
await prisma.document.update({
where: { id: docId },
data: {
tags: object.tags,
category: object.category,
},
});
}
使用 Vercel AI SDK 的 generateObject + Zod Schema,可以保证 LLM 输出格式严格符合预期,避免解析 JSON 的各种边界问题。详见 前端接入大模型 API。
6.6 语义搜索 + 关键词搜索混合
单纯的关键词搜索(ES)和单纯的语义搜索(向量)各有盲区,混合搜索效果最佳。
// 混合搜索:关键词 + 语义,加权合并
async function hybridSearch(params: {
query: string;
spaceId: string;
page?: number;
pageSize?: number;
}): Promise<SearchResult> {
const { query, spaceId, page = 1, pageSize = 20 } = params;
// 并行执行关键词搜索和语义搜索
const [keywordResults, semanticResults] = await Promise.all([
// 关键词搜索(ES)
searchByKeyword({ query, spaceId, page: 1, pageSize: 50 }),
// 语义搜索(向量)
searchBySemantic({ query, spaceId, topK: 50 }),
]);
// RRF(Reciprocal Rank Fusion)融合排序
const rrfScores = new Map<string, number>();
const k = 60; // RRF 参数
keywordResults.items.forEach((item, rank) => {
const score = 1 / (k + rank + 1);
rrfScores.set(item.id, (rrfScores.get(item.id) || 0) + score);
});
semanticResults.items.forEach((item, rank) => {
const score = 1 / (k + rank + 1);
rrfScores.set(item.id, (rrfScores.get(item.id) || 0) + score);
});
// 按融合分数排序
const allItems = new Map<string, SearchItem>();
[...keywordResults.items, ...semanticResults.items].forEach((item) => {
allItems.set(item.id, item);
});
const sorted = [...rrfScores.entries()]
.sort((a, b) => b[1] - a[1])
.slice((page - 1) * pageSize, page * pageSize)
.map(([id, score]) => ({ ...allItems.get(id)!, score }));
return { total: rrfScores.size, items: sorted };
}
RRF 是一种简单有效的排序融合算法。每个搜索引擎给出的排名 ,按公式 计算贡献分数,k 通常取 60。两路得分相加即为最终排名。不需要归一化,效果稳定。
6.7 相关文档推荐
// 基于当前文档的 Embedding 查找最相似的文档
async function getRelatedDocuments(
docId: string,
limit: number = 5,
): Promise<RelatedDoc[]> {
// 取该文档所有 chunks 的平均 Embedding
const docVectors = await vectorDB.query({
filter: { documentId: docId },
topK: 100,
includeValues: true,
});
if (docVectors.matches.length === 0) return [];
// 计算平均向量
const avgVector = averageVectors(docVectors.matches.map((m) => m.values));
// 检索最相似的其他文档
const results = await vectorDB.query({
vector: avgVector,
topK: limit * 3, // 多取一些,后续按文档去重
filter: {
documentId: { $ne: docId }, // 排除自身
spaceId: docVectors.matches[0].metadata.spaceId,
},
});
// 按文档去重,保留每个文档最高分
const docScores = new Map<string, { title: string; score: number }>();
for (const match of results.matches) {
const existing = docScores.get(match.metadata.documentId);
if (!existing || match.score > existing.score) {
docScores.set(match.metadata.documentId, {
title: match.metadata.title,
score: match.score,
});
}
}
return [...docScores.entries()]
.sort((a, b) => b[1].score - a[1].score)
.slice(0, limit)
.map(([documentId, { title, score }]) => ({ documentId, title, score }));
}
6.8 AI 功能的工程化考量
| 维度 | 考量 | 方案 |
|---|---|---|
| 成本控制 | LLM 调用费用 | 摘要/标签用 4o-mini;缓存高频问题的回答 |
| 延迟优化 | 首 Token 时间 | 流式输出;Embedding 批量处理 |
| 幻觉防控 | LLM 可能编造 | RAG 限定上下文;Prompt 强调"不要编造" |
| Token 限制 | 上下文窗口有限 | 文档分块控制在 512-1000 tokens;检索后截断 |
| 权限隔离 | AI 不能跨空间泄露 | 向量检索必须带 spaceId 过滤 |
| 离线回退 | AI 服务不可用时 | 降级为纯关键词搜索 |
| 审计追踪 | 记录 AI 使用情况 | 记录问题、检索结果、生成内容用于质量分析 |
// AI 响应缓存(相同问题 + 相同知识库版本 → 直接返回缓存)
async function cachedAsk(
question: string,
spaceId: string,
): Promise<QAResult | null> {
// 知识库版本号(文档有更新时递增)
const spaceVersion = await redis.get(`space:version:${spaceId}`);
const cacheKey = `ai:qa:${spaceId}:${spaceVersion}:${hashString(question)}`;
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
return null; // 未命中缓存,走正常 RAG 流程
}
// 使用量统计与限额
async function checkAIQuota(userId: string): Promise<boolean> {
const key = `ai:usage:${userId}:${getToday()}`;
const count = Number(await redis.get(key)) || 0;
const plan = await getUserPlan(userId);
const limits = { free: 20, pro: 200, enterprise: Infinity };
return count < limits[plan];
}
- RAG 检索增强生成 - RAG 完整架构、分块策略、检索优化
- 向量搜索与 Embedding - Embedding 模型、向量数据库选型
- 流式渲染与 Markdown - SSE、ReadableStream、流式 Markdown
- AI 应用性能优化 - TTFT、缓存、成本控制
- AI 应用安全 - Prompt 注入防御、数据隔离
7. 实时协作
多人同时编辑同一文档时,需要解决冲突和同步问题。
// 基于 Y.js + HocusPocus 的协同编辑
import { Server } from '@hocuspocus/server';
import { Database } from '@hocuspocus/extension-database';
import { Logger } from '@hocuspocus/extension-logger';
const server = Server.configure({
port: 1234,
extensions: [
new Logger(),
new Database({
fetch: async ({ documentName }) => {
// 从数据库加载 Y.js 文档状态
const doc = await prisma.documentCollabState.findUnique({
where: { documentId: documentName },
});
return doc?.state ? Buffer.from(doc.state) : null;
},
store: async ({ documentName, state }) => {
// 持久化 Y.js 文档状态
await prisma.documentCollabState.upsert({
where: { documentId: documentName },
create: { documentId: documentName, state: Buffer.from(state) },
update: { state: Buffer.from(state) },
});
},
}),
],
// 鉴权
async onAuthenticate({ token, documentName }) {
const user = await verifyToken(token);
const hasAccess = await checkDocPermission(user.id, documentName, 'edit');
if (!hasAccess) throw new Error('Forbidden');
return { user };
},
});
协同编辑的详细原理(OT、CRDT、Y.js)详见 设计在线协同编辑系统。
8. 导入导出
import TurndownService from 'turndown';
import { marked } from 'marked';
// Markdown → TipTap JSON
async function importMarkdown(markdown: string): Promise<DocumentContent> {
const html = await marked(markdown);
// 使用 TipTap 的 HTML 解析
return generateJSON(html, extensions);
}
// TipTap JSON → Markdown
function exportMarkdown(content: DocumentContent): string {
const html = generateHTML(content, extensions);
const turndown = new TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced',
});
return turndown.turndown(html);
}
// 批量导出为 PDF
async function exportPDF(docId: string): Promise<Buffer> {
const html = await renderDocumentToHTML(docId);
// 使用 Puppeteer 生成 PDF
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setContent(html);
const pdf = await page.pdf({ format: 'A4', margin: { top: '1cm', bottom: '1cm' } });
await browser.close();
return pdf;
}
// 支持的导入格式
// Markdown (.md) → 直接解析
// Notion 导出 (.zip) → 解压后解析 Markdown + 附件
// Confluence (.html) → HTML 转 TipTap JSON
// Word (.docx) → mammoth 转 HTML → TipTap JSON
9. 性能优化
| 场景 | 优化策略 |
|---|---|
| 目录树加载慢 | 两级预加载 + 按需展开 + Redis 缓存 |
| 编辑器卡顿 | 大文档分块渲染、延迟解析代码高亮 |
| 搜索延迟高 | ES 索引优化、搜索结果缓存、防抖 |
| 图片加载慢 | CDN + WebP + 懒加载 + 缩略图 |
| 首屏白屏 | SSR/SSG 预渲染文档页、骨架屏 |
| 协同卡顿 | Y.js 增量同步、WebSocket 压缩 |
import { useCallback, useRef } from 'react';
import { useDebouncedCallback } from 'use-debounce';
// 自动保存:防抖 3 秒 + 定期快照
function useAutoSave(docId: string) {
const lastSavedRef = useRef<string>('');
const save = useDebouncedCallback(
async (content: DocumentContent) => {
const contentStr = JSON.stringify(content);
if (contentStr === lastSavedRef.current) return; // 无变化不保存
await fetch(`/api/documents/${docId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
});
lastSavedRef.current = contentStr;
},
3000,
{ maxWait: 10000 }, // 最长 10 秒必存一次
);
return { save };
}
常见面试问题
Q1: 知识库的文档树应该用什么数据结构存储?
答案:
| 方案 | 原理 | 读性能 | 写性能 | 适用场景 |
|---|---|---|---|---|
| 邻接表 | 每行存 parentId | 需递归查询 | 插入 O(1) | 最通用,推荐 |
| 物化路径 | 存完整路径 /a/b/c | LIKE 查询快 | 移动需更新子树 | 读多写少 |
| 嵌套集 | 存 left/right 值 | 查询子树极快 | 插入需更新大量行 | 几乎不修改的树 |
| 闭包表 | 存所有祖先-后代关系对 | 查询灵活 | 存储空间大 | 需要频繁查祖先链 |
实际推荐:邻接表 + Redis 缓存。大部分知识库的树深度不超过 5-6 层,邻接表的递归查询完全够用,配合 Redis 缓存目录树 JSON,读性能很好。
Q2: 编辑器内容用什么格式存储?
答案:
推荐存储结构化 JSON(如 TipTap / ProseMirror 的 JSON 格式),而非 HTML 或 Markdown:
| 格式 | 优点 | 缺点 |
|---|---|---|
| JSON(推荐) | 结构清晰、易扩展、支持协同 | 需要渲染层转换 |
| HTML | 通用、浏览器直接渲染 | XSS 风险、结构松散 |
| Markdown | 人类可读、轻量 | 不支持复杂格式(表格合并等) |
| Delta(Quill) | 紧凑、操作级别 | 生态局限 |
同时存一份纯文本(bodyText),用于全文搜索索引。
Q3: 全文搜索用 Elasticsearch 还是 PostgreSQL?
答案:
| 维度 | PostgreSQL | Elasticsearch |
|---|---|---|
| 运维成本 | 低(已有) | 高(额外服务) |
| 中文分词 | 需安装 zhparser | 内置 IK 分词 |
| 搜索质量 | 基本够用 | 更好(BM25、模糊匹配、高亮) |
| 性能 | 万级文档 OK | 百万级文档 OK |
| 高级功能 | 有限 | 聚合、近义词、拼音搜索 |
建议:文档量 < 10 万用 PostgreSQL 全文搜索起步,后期再迁移到 ES。
Q4: 如何实现文档的实时协同编辑?
答案:
主流方案是 CRDT(Y.js)+ WebSocket:
- Y.js:CRDT 库,自动解决编辑冲突,无需中心化排序
- HocusPocus:Y.js 的服务端,处理 WebSocket 连接和状态持久化
- TipTap Collaboration:编辑器层面的协同插件
与 OT(Operational Transformation)对比:
| 维度 | OT | CRDT (Y.js) |
|---|---|---|
| 冲突解决 | 需要中心服务器排序 | 去中心化,自动合并 |
| 复杂度 | 实现复杂 | 库封装好,使用简单 |
| 离线支持 | 差 | 好(离线编辑后自动合并) |
| 代表产品 | Google Docs | Notion、Figma |
Q5: 知识库的权限系统怎么设计?
答案:
推荐 空间角色 + 文档级覆盖 两层模型:
- 空间级:Owner / Admin / Editor / Viewer 四个角色
- 文档级:可对特定文档/目录单独设置权限(覆盖空间级)
- 继承规则:子文档默认继承父文档权限,可单独覆盖
权限检查优先级:文档级权限 > 空间角色权限
此外还需支持:
- 分享链接(带密码、带过期时间的只读链接)
- 公开发布(文档公开到互联网)
- 评论权限(可评论但不可编辑)
Q6: 如何在知识库中接入 AI 能力?
答案:
核心是 RAG(检索增强生成):
- 文档入库:将文档拆分为 chunks → 生成 Embedding → 存入向量数据库
- 用户提问:问题 Embedding → 向量检索 Top-K → LLM 基于上下文生成回答
- 引用溯源:回答附带引用的文档来源,可点击跳转
AI 还可以做:
- 智能摘要:自动生成文档摘要
- 写作辅助:续写、扩展、润色、翻译
- 自动标签:基于内容自动打标签
- 相似文档推荐:基于 Embedding 相似度
Q7: 知识库的文档如何做 SEO?
答案:
如果知识库有公开文档(对外 Help Center),SEO 很重要:
- SSR / SSG:Next.js 的
generateStaticParams预渲染文档页 - 语义化 HTML:
<article>、<nav>、<h1>-<h6>结构清晰 - Meta 标签:
<title>、<meta description>、Open Graph - 结构化数据:JSON-LD 标记文章类型、作者、日期
- Sitemap:自动生成
sitemap.xml,文档更新时刷新 - URL 设计:
/docs/space-slug/doc-slug,可读的语义化路径
Q8: 如何处理文档中的图片和附件?
答案:
| 环节 | 方案 |
|---|---|
| 上传 | 前端直传 OSS(STS 临时凭证),避免经过应用服务器 |
| 存储 | 对象存储(S3 / 阿里云 OSS / Cloudflare R2) |
| 访问 | CDN 加速 + 图片处理(WebP、缩放、水印) |
| 引用 | 文档内容中存图片 URL,删除文档时异步清理孤立附件 |
| 大小限制 | 单文件 10MB、单文档总附件 100MB |
// 前端直传 OSS 流程
const { uploadUrl, fileUrl } = await fetch('/api/upload/presign').then(r => r.json());
await fetch(uploadUrl, { method: 'PUT', body: file });
editor.chain().focus().setImage({ src: fileUrl }).run();
Q9: 知识库有哪些关键性能指标?
答案:
| 指标 | 目标 | 优化手段 |
|---|---|---|
| 首屏渲染 | < 1.5s | SSR + CDN + 骨架屏 |
| 编辑器加载 | < 500ms | 按需加载编辑器、代码分割 |
| 搜索响应 | < 200ms | ES 索引优化、结果缓存 |
| 自动保存 | < 100ms(感知) | 防抖 + 乐观更新 |
| 协同同步延迟 | < 100ms | WebSocket + Y.js 增量更新 |
| 目录树加载 | < 300ms | 两级预加载 + Redis 缓存 |
Q10: 从 Notion / Confluence 迁移数据怎么做?
答案:
- 导出格式:Notion 导出 Markdown + CSV,Confluence 导出 HTML / XML
- 解析转换:Markdown → TipTap JSON(marked + generateJSON)
- 附件迁移:下载附件 → 上传到 OSS → 替换文档中的 URL
- 目录还原:根据导出的目录结构重建 parentId 关系
- 增量迁移:先迁移目录结构,再逐批迁移内容,最后校验
常见坑:
- Notion 的 database / toggle / synced block 等特殊块需要额外适配
- Confluence 的宏(macro)需要映射到对应的组件
- 图片和附件的相对路径处理
相关链接
- 设计富文本编辑器 - Slate.js、ProseMirror、ContentEditable
- 设计在线协同编辑系统 - OT、CRDT、Y.js
- RAG 检索增强生成 - RAG 架构、文档分块、向量数据库
- 向量搜索与 Embedding - Embedding 生成、相似度搜索
- 设计权限管理系统 - RBAC、ABAC、动态路由
- 搜索引擎 - Elasticsearch、倒排索引
- Elasticsearch 官方文档
- TipTap 官方文档
- Y.js 官方文档
- HocusPocus 官方文档