跳到主要内容

设计知识库系统

问题

如何设计一个面向团队/企业的知识库系统?需要考虑哪些核心功能和技术方案?

答案

知识库系统是企业内部信息沉淀和知识共享的核心工具,典型产品包括 Notion、语雀、Confluence、飞书文档等。本文从前端架构和全栈视角,系统梳理知识库的设计要点。

系统架构全景


1. 文档模型设计

数据结构

types/document.ts
// 文档核心模型
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[];
}

目录树结构

知识库的核心交互是树形目录导航,设计上需要支持:

  • 无限层级嵌套
  • 拖拽排序
  • 延迟加载(大量文档时不一次性加载)
api/document-tree.ts
// 方案一:邻接表(最常用)
// 每个文档存 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. 富文本编辑器

编辑器是知识库的核心组件,主流技术选型:

编辑器核心库数据模型适用场景
TipTapProseMirrorJSON(Schema 约束)最推荐,生态丰富
Slate.js自研JSON(自定义 Schema)高度定制化需求
LexicalMeta 开发JSON(不可变状态树)React 生态
Editor.js自研JSON(Block 式)块编辑器(类 Notion)
Quill自研Delta(操作序列)简单场景
推荐方案

中大型知识库推荐 TipTap(基于 ProseMirror):Schema 约束保证数据一致性、插件生态丰富、协同编辑支持好(配合 Y.js)。详见 设计富文本编辑器

components/Editor.tsx
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 式的 / 命令菜单,是知识库编辑器的标配交互:

extensions/slash-command.ts
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 表大规模元数据和内容分离,减轻主表压力
schema/document.prisma
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])
}

版本历史

services/version.ts
// 保存版本快照
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 方案

services/search.ts
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,
})),
};
}

搜索索引同步

services/search-sync.ts
// 文档变更时同步到 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
PostgreSQL 替代方案

如果不想引入 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. 权限系统

知识库的权限通常分为空间级文档级两层。

types/permission.ts
// 角色体系
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';
}
middleware/permission.ts
// 权限检查中间件
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]);
}

分享与公开链接

services/share.ts
// 生成分享链接(带 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),这需要一套完整的索引管线。

services/embedding-pipeline.ts
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。

Embedding 索引的注意事项
  1. 异步处理:索引操作耗时较长,通过消息队列异步执行,不阻塞文档保存
  2. 增量更新:文档更新时全量替换该文档的所有 chunks,避免旧数据残留
  3. 权限过滤:向量检索时 必须spaceId 过滤,防止跨空间信息泄露
  4. Token 成本:text-embedding-3-small 约 0.02/1Mtokens10万文档约0.02/1M tokens,10 万文档约 2-5

6.2 AI 问答(RAG)

RAG(Retrieval-Augmented Generation)是知识库 AI 问答的核心架构。

services/ai-qa.ts
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 问答界面

components/AIChat.tsx
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 处理的详细方案详见 流式渲染与 MarkdownAI 聊天界面设计


6.3 AI 写作辅助

编辑器内的 AI 辅助写作,用户选中文本后通过浮动工具栏或 / 斜杠命令触发。

services/ai-writing.ts
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 工具栏

components/AIToolbar.tsx
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 智能摘要

自动为文档生成摘要,展示在文档列表、搜索结果、分享卡片中。

services/ai-summary.ts
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 自动标签与分类

services/ai-tagging.ts
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,
},
});
}
Structured Output

使用 Vercel AI SDK 的 generateObject + Zod Schema,可以保证 LLM 输出格式严格符合预期,避免解析 JSON 的各种边界问题。详见 前端接入大模型 API


6.6 语义搜索 + 关键词搜索混合

单纯的关键词搜索(ES)和单纯的语义搜索(向量)各有盲区,混合搜索效果最佳。

services/hybrid-search.ts
// 混合搜索:关键词 + 语义,加权合并
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(Reciprocal Rank Fusion)

RRF 是一种简单有效的排序融合算法。每个搜索引擎给出的排名 rr,按公式 1k+r\frac{1}{k + r} 计算贡献分数,k 通常取 60。两路得分相加即为最终排名。不需要归一化,效果稳定。


6.7 相关文档推荐

services/ai-recommend.ts
// 基于当前文档的 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 使用情况记录问题、检索结果、生成内容用于质量分析
services/ai-cost-control.ts
// 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];
}
相关文档

7. 实时协作

多人同时编辑同一文档时,需要解决冲突和同步问题。

services/collaboration.ts
// 基于 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. 导入导出

services/import-export.ts
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 压缩
hooks/useAutoSave.ts
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/cLIKE 查询快移动需更新子树读多写少
嵌套集存 left/right 值查询子树极快插入需更新大量行几乎不修改的树
闭包表存所有祖先-后代关系对查询灵活存储空间大需要频繁查祖先链

实际推荐:邻接表 + Redis 缓存。大部分知识库的树深度不超过 5-6 层,邻接表的递归查询完全够用,配合 Redis 缓存目录树 JSON,读性能很好。

Q2: 编辑器内容用什么格式存储?

答案

推荐存储结构化 JSON(如 TipTap / ProseMirror 的 JSON 格式),而非 HTML 或 Markdown:

格式优点缺点
JSON(推荐)结构清晰、易扩展、支持协同需要渲染层转换
HTML通用、浏览器直接渲染XSS 风险、结构松散
Markdown人类可读、轻量不支持复杂格式(表格合并等)
Delta(Quill)紧凑、操作级别生态局限

同时存一份纯文本bodyText),用于全文搜索索引。

Q3: 全文搜索用 Elasticsearch 还是 PostgreSQL?

答案

维度PostgreSQLElasticsearch
运维成本低(已有)高(额外服务)
中文分词需安装 zhparser内置 IK 分词
搜索质量基本够用更好(BM25、模糊匹配、高亮)
性能万级文档 OK百万级文档 OK
高级功能有限聚合、近义词、拼音搜索

建议:文档量 < 10 万用 PostgreSQL 全文搜索起步,后期再迁移到 ES。

Q4: 如何实现文档的实时协同编辑?

答案

主流方案是 CRDT(Y.js)+ WebSocket

  1. Y.js:CRDT 库,自动解决编辑冲突,无需中心化排序
  2. HocusPocus:Y.js 的服务端,处理 WebSocket 连接和状态持久化
  3. TipTap Collaboration:编辑器层面的协同插件

与 OT(Operational Transformation)对比:

维度OTCRDT (Y.js)
冲突解决需要中心服务器排序去中心化,自动合并
复杂度实现复杂库封装好,使用简单
离线支持好(离线编辑后自动合并)
代表产品Google DocsNotion、Figma

Q5: 知识库的权限系统怎么设计?

答案

推荐 空间角色 + 文档级覆盖 两层模型:

  1. 空间级:Owner / Admin / Editor / Viewer 四个角色
  2. 文档级:可对特定文档/目录单独设置权限(覆盖空间级)
  3. 继承规则:子文档默认继承父文档权限,可单独覆盖

权限检查优先级:文档级权限 > 空间角色权限

此外还需支持:

  • 分享链接(带密码、带过期时间的只读链接)
  • 公开发布(文档公开到互联网)
  • 评论权限(可评论但不可编辑)

Q6: 如何在知识库中接入 AI 能力?

答案

核心是 RAG(检索增强生成)

  1. 文档入库:将文档拆分为 chunks → 生成 Embedding → 存入向量数据库
  2. 用户提问:问题 Embedding → 向量检索 Top-K → LLM 基于上下文生成回答
  3. 引用溯源:回答附带引用的文档来源,可点击跳转

AI 还可以做:

  • 智能摘要:自动生成文档摘要
  • 写作辅助:续写、扩展、润色、翻译
  • 自动标签:基于内容自动打标签
  • 相似文档推荐:基于 Embedding 相似度

Q7: 知识库的文档如何做 SEO?

答案

如果知识库有公开文档(对外 Help Center),SEO 很重要:

  1. SSR / SSG:Next.js 的 generateStaticParams 预渲染文档页
  2. 语义化 HTML<article><nav><h1>-<h6> 结构清晰
  3. Meta 标签<title><meta description>、Open Graph
  4. 结构化数据:JSON-LD 标记文章类型、作者、日期
  5. Sitemap:自动生成 sitemap.xml,文档更新时刷新
  6. 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.5sSSR + CDN + 骨架屏
编辑器加载< 500ms按需加载编辑器、代码分割
搜索响应< 200msES 索引优化、结果缓存
自动保存< 100ms(感知)防抖 + 乐观更新
协同同步延迟< 100msWebSocket + Y.js 增量更新
目录树加载< 300ms两级预加载 + Redis 缓存

Q10: 从 Notion / Confluence 迁移数据怎么做?

答案

  1. 导出格式:Notion 导出 Markdown + CSV,Confluence 导出 HTML / XML
  2. 解析转换:Markdown → TipTap JSON(marked + generateJSON)
  3. 附件迁移:下载附件 → 上传到 OSS → 替换文档中的 URL
  4. 目录还原:根据导出的目录结构重建 parentId 关系
  5. 增量迁移:先迁移目录结构,再逐批迁移内容,最后校验

常见坑:

  • Notion 的 database / toggle / synced block 等特殊块需要额外适配
  • Confluence 的宏(macro)需要映射到对应的组件
  • 图片和附件的相对路径处理

相关链接