news 2026/8/26 21:09:58

AI Agent + RAG:从零搭建类飞书文档知识库全流程实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
AI Agent + RAG:从零搭建类飞书文档知识库全流程实战

博主们好,今天分享一套我最近从零搭建的“类飞书文档知识库”全套实战记录。整个项目围绕 AI Agent 与 RAG 展开,前端覆盖文档管理、知识库配置、在线问答交互,后端串联向量检索、多路召回、重排和大模型应答。内容偏企业级落地,不玩概念,直接给你能跑的代码、能理解的原理和真实会踩的坑。

1. 项目背景:为什么要把 AI Agent 和 RAG 放进前端项目

1.1 业务场景:从文档堆积到智能问答

很多团队内部都有大量飞书文档、语雀笔记、Confluence 页面,但真正需要某个历史决策依据、某个接口字段定义、某条运维操作规范时,往往要翻很久群聊天记录和文档目录。搜索功能只能做关键词匹配,同义词、口语化提问、跨文档归纳基本无能为力。

这个项目的目标是做一个企业级知识库问答系统,让用户像使用飞书文档一样管理资料,同时通过 AI Agent 完成自然语言问答。举个例子:

  • 用户提问:采购审批流程中,金额超过 5 万需要谁签字?
  • 传统搜索:必须包含“采购审批”“5万”“签字”等关键词才能命中。
  • RAG 方案:先召回相关文档片段,再交给大模型归纳回答,口语化提问也能命中。

项目形态上更像“飞书文档 + 企业问答机器人”的结合体,前端体验很重,包括目录树、文档预览、知识库管理后台、问答对话面板、命中溯源展示等。

1.2 技术选型:为什么是 AI Agent、RAG、向量检索三件套

RAG 全称 Retrieval-Augmented Generation,检索增强生成。它解决的问题是大模型不懂企业私有数据,同时存在幻觉问题。通过把私有文档切成片段,转成向量存入向量数据库,问答时先做相似度检索,再把命中的片段塞进 Prompt 上下文,最后让大模型基于这些片段回答。

AI Agent 在这套体系里承担更复杂的编排工作:识别用户意图、决定是否需要检索、判断走单轮问答还是多轮追问、遇到知识不足时触发补充检索。它与普通 RAG 的区别在于:

能力传统 RAGAI Agent + RAG
检索触发每次提问都检索根据意图决定是否检索
多轮对话较弱,前后文割裂维护上下文并拆解追问
检索策略单一向量检索多路召回、重排、混合检索
工具调用不支持可调用搜索、文档 API 等外部工具
答案生成直接生成结合工具结果、记忆、约束生成

前端开发者在 2026 年面试或项目中接触 AI Agent 的频率明显变高,核心原因是大模型应用已经从前端聊天框走向业务系统集成。作为前端,需要掌握的不只是调 API,而是要理解向量检索、知识库切片、召回策略,才能设计出真正好用的交互界面。

1.3 整体架构:前端在整个链路中的位置

本项目不是一个简单的“前端 + 大模型 API”应用,而是完整的数据链路:

文档上传/解析 -> 切片 -> Embedding 向量化 -> 存入向量库 ↓ 用户提问 -> 意图识别 -> 多路召回(向量 + BM25) -> 重排 -> 构造 Prompt -> 大模型回答 ↓ 前端展示:答案 + 溯源片段

前端在这个链路中承担四类职责:

  1. 知识库管理:文档列表、上传、删除、切片预览、向量同步状态。
  2. 问答界面:流式输出、引用标注、追问、对话历史。
  3. 可视化反馈:命中片段高亮、相似度展示、召回来源文档。
  4. 系统配置页:向量模型选择、切片策略、检索参数、Prompt 模板。

本文的重点放在全链路实现思路与前端关键代码上,后端会给出可运行的 Node.js 实现。

2. 环境准备与版本说明

2.1 运行环境

本文示例环境如下:

  • 操作系统:macOS 14 / Ubuntu 22.04 均可
  • Node.js:18+
  • pnpm:8+
  • 包管理器:npm/pnpm
  • 数据库:SQLite(开发环境)/ PostgreSQL(生产环境)
  • 对象存储:本地文件系统(开发)/ S3 兼容存储(生产)
  • 向量数据库:支持 vector 存储的 SQLite 扩展或独立向量库,例如 sqlite-vec、pgvector,不要用生产环境未验证的向量库
  • 大模型 API:OpenAI 兼容接口,可替换为本地模型或国内云厂商模型

版本说明:大模型与向量化框架迭代很快,本文以思路和协议标准为主线,代码在不同环境可能需要微调。示例项目可直接跑通,但生产环境需要根据你的部署方式调整。

2.2 依赖清单

前端部分:

  • React 18 / Next.js 14 或 Vite + React
  • Tailwind CSS 用于页面样式
  • Zustand 做全局状态管理
  • React Markdown 渲染大模型回答
  • SSE 客户端用于流式接收

后端部分:

  • Express / Fastify
  • better-sqlite3
  • sqlite-vec
  • openai Node SDK
  • cheerio 解析 HTML 文档
  • pdf-parse 解析 PDF
  • mammoth 解析 docx
  • commander 编写脚本

如果你不想从零搭建,也可以参考 Dify、FastGPT 等开源知识库平台的设计思路。Dify 的核心是“知识库流水线 + 可视化编排”,开源项目可以直接内部部署,很多企业第一步都会选择这类平台验证效果。

3. 核心原理拆解:向量化、切片与多路召回

3.1 为什么不能直接把文档丢给大模型

大模型有上下文窗口限制。GPT-4o 级别模型的上下文虽然已经很大,但把一本几百页的文档全部塞进 Prompt,成本极高且响应很慢,甚至很多模型仍然放不下。更关键的是,大模型训练数据不包含企业内部文档,不检索就直接问,模型只能“编”,这就是幻觉。

RAG 的思路是把“检索”和“生成”分开:

  1. 离线阶段:把文档切片、向量化、构建索引。
  2. 在线阶段:把问题向量化,在向量库中找最相似的片段。
  3. 生成阶段:把检索到的片段作为参考资料放入 Prompt,大模型基于资料回答问题。

这个方案的核心收益是:不用微调模型,也能让模型掌握企业私有知识;每次回答都有可追溯的文档来源;新文档上线后立刻可被检索到,知识更新实时。

3.2 向量化与 Embedding 模型

Embedding 模型的作用是把文本变成一串浮点数向量,语义相近的文本在向量空间中距离更近。

以 OpenAI 的 text-embedding-3-small 为例:

curl https://api.openai.com/v1/embeddings \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $OPENAI_API_KEY" \ -d '{ "input": "采购审批流程", "model": "text-embedding-3-small" }'

返回结果中data[0].embedding是一个 1536 维的向量数组,实际使用时通常直接调用 SDK。

国内目前更常使用兼容 OpenAI 协议的 embedding 接口,切换成本很低。另一种常用方案是 BGE 系列模型或 M3E 模型,部署在本地 GPU 服务器上,这样企业数据不需要出内网。

对于前端开发者来说,需要理解的核心点是:向量化本质上是“把语义变成可计算的距离”,所以前端展示相似度时,数值越高代表语义越接近。

3.3 切片策略:直接影响召回效果

切片是整个 RAG 链路中最容易被忽视的环节。切片策略不当,会导致以下问题:

  • 切片太小:语义不完整,回答缺乏上下文。
  • 切片太大:包含大量噪声,检索精度下降,还可能超过模型上下文窗口。
  • 标题被截断:后续切片关键信息丢失。
  • 跨表格、跨列表切片中断:语义破损严重。

推荐的基础策略是“固定大小 + 重叠窗口”。

function splitText(text, chunkSize = 512, overlap = 64) { const chunks = []; let start = 0; while (start < text.length) { let end = start + chunkSize; if (end < text.length) { // 尽量在句号、换行处截断 const lastBreak = text.lastIndexOf('\n', end); const lastDot = text.lastIndexOf('。', end); const cut = Math.max(lastBreak, lastDot); if (cut > start + chunkSize * 0.6) { end = cut + 1; } } chunks.push(text.slice(start, end)); start = end - overlap; } return chunks; }

实践建议是在真实文档上做验证,而不是只按字符数切。比较好的策略是结合文档结构:

  • Markdown 文档按标题层级切分,保证每个切片属于同一章节。
  • PDF 按页切分后,再做二次切分,避免跨页把表格切断。
  • 表格类内容尽量整表作为一个切片,不要拆散单元格。

切完之后要为每个切片写入metadata,包括文档 ID、标题路径、页码、切片序号。这部分信息是前端做“命中溯源”展示的数据基础。

3.4 多路召回:向量 + BM25 混合检索

纯向量检索的缺点是关键词精确匹配能力弱。典型场景是:用户输入“K8s Pod 重启策略”,模型向量化后可能找到语义相似的片段,但对“Pod”“重启策略”这类强专有名词,BM25 的精确匹配更有效。

所以项目采用多路召回:

  1. 向量召回:对用户问题进行 Embedding,从向量库中取 Top K。
  2. BM25 召回:对用户问题进行关键词分词,从倒排索引中取 Top K。
  3. 融合:两个结果集合并,按融合分数排序。
召回方式优点缺点适用场景
向量召回语义理解强、同义词有效对专有名词不敏感口语化提问、跨领域搜索
BM25 召回精确匹配强、速度稳定无法理解同义表达代码片段、专有名词、编号查询
混合检索兼顾两者需要调融合权重企业知识库通用问题

一个简单有效的融合方法是 Score 归一化后加权:

function fusedScore(vectorScore, bm25Score, alpha = 0.7) { const normVector = 1 / (1 + Math.exp(-vectorScore)); const normBm25 = 1 / (1 + Math.exp(-bm25Score)); return alpha * normVector + (1 - alpha) * normBm25; }

这里的alpha是向量得分权重。如果业务强依赖关键词编号查询,可以调高 BM25 权重;如果文档包含大量同义表达,则调高向量权重。

3.5 重排:解决“召回多而精排差”的问题

经过多路召回后,候选片段可能有 20~50 条,但真正适合作为答案依据的可能只有 3~5 条。重排模型的作用是对候选集做二次精排。

常见重排方案:

  1. 交叉编码器 Rerank(如 bge-reranker):效果最好,但速度较慢。
  2. 大模型重排:适合小流量场景,直接让 LLM 判断候选片段与问题的相关性。
  3. 规则重排:按文档来源优先级、关键词命中数量、元数据时效排序,成本最低。

对企业内部知识库来说,最稳妥的方案是先用规则或轻量模型过滤一遍,再对大模型重排结果的 Top N 片段构造 Prompt。

4. 从零搭建:类飞书文档知识库全流程实战

4.1 项目结构规划

整个项目采用 monorepo 结构,核心分两个包:

ai-knowledge-base/ ├── apps/ │ ├── web/ # 前端 React 应用 │ │ ├── src/ │ │ │ ├── pages/ │ │ │ │ ├── HomePage.jsx │ │ │ │ ├── DocumentList.jsx │ │ │ │ ├── KnowledgeBase.jsx │ │ │ │ └── ChatPage.jsx │ │ │ ├── components/ │ │ │ │ ├── Sidebar.jsx │ │ │ │ ├── DocEditor.jsx │ │ │ │ ├── ChatPanel.jsx │ │ │ │ └── SearchResult.jsx │ │ │ ├── services/ │ │ │ │ ├── api.js │ │ │ │ └── stream.js │ │ │ └── stores/ │ │ │ └── appStore.js │ │ └── package.json │ └── server/ # Node.js 后端 │ ├── src/ │ │ ├── routes/ │ │ │ ├── documents.js │ │ │ ├── knowledge.js │ │ │ └── chat.js │ │ ├── services/ │ │ │ ├── embedding.js │ │ │ ├── splitter.js │ │ │ ├── retriever.js │ │ │ ├── reranker.js │ │ │ └── vectorStore.js │ │ ├── db/ │ │ │ ├── schema.sql │ │ │ └── index.js │ │ └── index.js │ └── package.json └── package.json

这个结构既适合学习,也适合团队后续扩展成独立后端服务。

4.2 后端数据库设计:SQLite + sqlite-vec

先初始化数据库表结构。项目使用 SQLite 的sqlite-vec扩展,让向量存储不依赖额外的向量数据库服务,非常适合开发阶段快速验证。

-- apps/server/src/db/schema.sql CREATE TABLE documents ( id TEXT PRIMARY KEY, title TEXT NOT NULL, file_type TEXT, storage_path TEXT, status TEXT DEFAULT 'pending', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE chunks ( id TEXT PRIMARY KEY, document_id TEXT NOT NULL, content TEXT NOT NULL, metadata TEXT, token_count INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (document_id) REFERENCES documents(id) ); CREATE VIRTUAL TABLE vec_chunks USING vec0( chunk_id TEXT PRIMARY KEY, embedding FLOAT[1024] );

vec_chunks是虚拟表,专门存向量。FLOAT[1024]需要与 Embedding 模型输出维度一致。如果你的模型输出 1536 维,就把这个值改成FLOAT[1536]

初始化数据库:

// apps/server/src/db/index.js import Database from 'better-sqlite3'; import { vec } from 'sqlite-vec'; const db = new Database('knowledge.db'); db.loadExtension(vec); db.exec(` CREATE TABLE IF NOT EXISTS documents (...); CREATE TABLE IF NOT EXISTS chunks (...); CREATE TABLE IF NOT EXISTS vec_chunks USING vec0(...); `); export default db;

4.3 文档解析与切片

实际项目中文档来源可能有 Markdown、PDF、Word、HTML,解析方式各不相同。

// apps/server/src/services/splitter.js import { splitText } from './splitter.js'; export async function processDocument(document, content) { const rawText = parseDocument(content); const chunks = splitText(rawText, 512, 64); const chunkRows = chunks.map((text, index) => ({ id: `${document.id}_chunk_${index}`, documentId: document.id, content: text, metadata: JSON.stringify({ title: document.title, chunkIndex: index, }), })); return chunkRows; }

这里要注意一个细节:切片后要计算每个切片的 token 数,便于后续控制 Prompt 大小。

4.4 Embedding 向量化入库

向量化的核心是调用 Embedding API,并把结果写入向量表。

// apps/server/src/services/embedding.js import OpenAI from 'openai'; const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY, baseURL: process.env.OPENAI_BASE_URL, }); export async function getEmbedding(text) { const response = await client.embeddings.create({ model: process.env.EMBEDDING_MODEL || 'text-embedding-3-small', input: text, }); return response.data[0].embedding; }

入库脚本如下:

// scripts/ingest.js import { processDocument } from '../src/services/splitter.js'; import { getEmbedding } from '../src/services/embedding.js'; async function ingestDocument(document) { const content = await loadFileContent(document.storagePath); const chunks = await processDocument(document, content); for (const chunk of chunks) { const embedding = await getEmbedding(chunk.content); insertChunk(chunk); insertVector(chunk.id, embedding); } updateDocumentStatus(document.id, 'completed'); }

向量化的耗时与文档长度成正比,建议处理时加入队列机制,前端展示“同步中”“已完成”的进度状态。

4.5 检索服务:向量检索 + BM25 + 融合排序

检索服务是一个 RAG 系统的心脏。用户在前端输入问题后,后端需要并行执行两类检索。

向量检索:

// apps/server/src/services/retriever.js export async function vectorSearch(queryEmbedding, limit = 10) { const rows = db.prepare(` SELECT chunk_id, distance FROM vec_chunks WHERE embedding MATCH ? ORDER BY distance LIMIT ? `).all(queryEmbedding, limit); return rows.map((row) => ({ chunkId: row.chunk_id, score: 1 / (1 + row.distance), })); }

BM25 检索:

export async function bm25Search(queryText, limit = 10) { const terms = tokenize(queryText); const placeholders = terms.map(() => '?').join(', '); const rows = db.prepare(` SELECT id, content, document_id FROM chunks WHERE id IN ( SELECT chunk_id FROM chunk_terms WHERE term IN (${placeholders}) GROUP BY chunk_id ORDER BY COUNT(*) DESC ) LIMIT ? `).all(...terms, limit); return rows.map((row) => ({ chunkId: row.id, score: rows[0].score, })); }

这里为了演示做了简化,生产建议使用 SQLite FTS5 或独立搜索引擎实现 BM25。

融合:

export async function hybridRetrieve(query, topK = 10) { const queryEmbedding = await getEmbedding(query); const [vectorResults, bm25Results] = await Promise.all([ vectorSearch(queryEmbedding, topK), bm25Search(query, topK), ]); const merged = new Map(); for (const item of vectorResults) { merged.set(item.chunkId, { chunkId: item.chunkId, score: fusedScore(item.score, 0), }); } for (const item of bm25Results) { if (merged.has(item.chunkId)) { merged.get(item.chunkId).score += fusedScore(0, item.score); } else { merged.set(item.chunkId, { chunkId: item.chunkId, score: fusedScore(0, item.score), }); } } return [...merged.values()] .sort((a, b) => b.score - a.score) .slice(0, topK); }

这里要注意一个检索的关键点:企业知识库中,ES 库与知识库的关系是很多人会搞混的。ES(Elasticsearch)本身是一个全文检索引擎,负责 BM25 关键词检索;向量库负责语义检索。两者不是谁替代谁的关系,而是混合检索的两个数据源。如果你已经有了 ES 库,不要急着把数据同步到向量库,正确的做法是让 ES 继续承担关键词索引,向量库承担 Embedding 索引,多路召回后合并排序。

4.6 大模型问答:流式输出与引用溯源

问答环节需要把检索到的片段作为上下文传入大模型,并且要求模型输出引用来源。这里的 Prompt 设计需要花心思,否则回答内容看起来有依据,但实际是模型自己编造的。

// apps/server/src/services/chat.js export async function chatWithContext(question, retrievedChunks) { const context = retrievedChunks .map((chunk, index) => `[${index + 1}] ${chunk.content}`) .join('\n\n'); const prompt = ` 你是一个企业知识库助手。请基于以下参考资料回答问题。 如果资料中没有相关信息,请直接说“根据当前文档无法回答该问题”,不要编造。 回答中引用资料时,请在对应句末用[数字]标注来源。 参考资料: ${context} 问题:${question} `; const response = await client.chat.completions.create({ model: process.env.LLM_MODEL || 'gpt-4o-mini', messages: [ { role: 'system', content: '你是一个严谨的企业知识库助手。' }, { role: 'user', content: prompt }, ], temperature: 0.2, stream: true, }); return response; }

前端使用 SSE 接收流式数据时要注意解析格式。OpenAI 兼容接口的流式响应格式如下:

data: {"choices":[{"delta":{"content":"采购"}}]} data: {"choices":[{"delta":{"content":"审批"}}]} data: [DONE]

前端解析示例:

// apps/web/src/services/stream.js export async function streamChat(question, chunks, onMessage) { const response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ question, chunks }), }); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { if (line.startsWith('data: ')) { const data = line.replace('data: ', ''); if (data === '[DONE]') return; try { const json = JSON.parse(data); const content = json.choices?.[0]?.delta?.content || ''; if (content) onMessage(content); } catch (e) { console.warn('SSE parse error:', e); } } } } }

这个实现里“断句缓冲区”是必须的,因为 SSE 流式传输时,一行数据可能被拆成多个 TCP 包,直接按行处理会丢失数据。这也是前端接流式接口最容易踩的坑。

4.7 前端核心页面:文档管理与知识库配置

前端页面设计主要分三个区域:左侧文档目录、中部编辑/预览区、右侧智能问答面板。

文档列表组件:

// apps/web/src/pages/DocumentList.jsx import { useEffect, useState } from 'react'; import { fetchDocuments } from '../services/api'; export default function DocumentList() { const [documents, setDocuments] = useState([]); useEffect(() => { fetchDocuments().then(setDocuments); }, []); return ( <div> <h2 className="text-lg font-semibold mb-4">知识库文档</h2> <ul className="space-y-2"> {documents.map((doc) => ( <li key={doc.id} className="flex justify-between border rounded p-3"> <span>{doc.title}</span> <span>{doc.status}</span> </li> ))} </ul> </div> ); }

问答面板是前端体现“AI 感”最明显的部分,这里需要兼顾流式渲染、Markdown 渲染和引用标注。

// apps/web/src/components/ChatPanel.jsx import { useState } from 'react'; import ReactMarkdown from 'react-markdown'; import { streamChat } from '../services/stream'; export default function ChatPanel() { const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); async function handleSend() { const question = input.trim(); if (!question) return; setMessages((prev) => [...prev, { role: 'user', content: question }]); setInput(''); const assistantMsg = { role: 'assistant', content: '' }; setMessages((prev) => [...prev, assistantMsg]); await streamChat(question, [], (delta) => { setMessages((prev) => { const last = prev[prev.length - 1]; last.content += delta; return [...prev]; }); }); } return ( <div className="flex flex-col h-full"> <div className="flex-1 overflow-y-auto p-4 space-y-4"> {messages.map((msg, idx) => ( <div key={idx}> <ReactMarkdown>{msg.content}</ReactMarkdown> </div> ))} </div> <div className="border-t p-4 flex gap-2"> <input className="flex-1 border rounded px-3 py-2" value={input} onChange={(e) => setInput(e.target.value)} placeholder="输入问题,例如:报销额度上限是多少?" /> <button onClick={handleSend} className="bg-blue-600 text-white rounded px-4 py-2"> 发送 </button> </div> </div> ); }

这里有一个性能细节:每收到一个 delta 就更新 state,如果内容长,会造成频繁重渲染。实际项目可以合并多个 delta 后再更新,或者使用可编辑 DOM 节点直接 append,避免 React 重渲染整个消息列表。

5. 前端进阶:命中溯源、知识库配置与状态可视化

5.1 答案溯源:让用户信任 AI 回答

类飞书文档知识库和 ChatGPT 的最大区别是:回答必须能溯源。用户需要知道这段回答来自哪份文档的哪个片段,否则企业内部无法信任这个系统。

后端在返回回答的同时,应该返回命中的 chunk 信息:

// apps/server/src/routes/chat.js { "answer": "根据知识库中的《采购管理制度》,金额超过 5 万的采购审批需要... [1]", "sources": [ { "chunkId": "doc123_chunk_12", "documentTitle": "采购管理制度", "content": "第五章 规定超过 5 万...", "score": 0.87 } ] }

前端在回答下方展示引用来源:

// apps/web/src/components/SourceList.jsx export default function SourceList({ sources }) { return ( <div className="mt-4 border-t pt-2"> <h3 className="text-sm font-medium text-gray-500">引用来源</h3> {sources.map((source, idx) => ( <button key={source.chunkId} className="block w-full text-left text-sm text-blue-600 hover:underline truncate mt-1" > {idx + 1}. {source.documentTitle} </button> ))} </div> ); }

溯源不仅在 UI 上增加可信度,在技术层面也方便排查问题。如果用户反馈回答错误,我们可以直接从sources里看出到底是检索没召回正确片段,还是大模型没有正确理解片段。

5.2 切片可视化:把 RAG 的“黑盒”变成可调参

前端一个很重要的设计是“切片预览”。当用户上传一份文档后,切片策略是 RAG 最关键的环节,如果没有可视化界面,很难判断切片是否合理。

切片预览组件可以做成类似飞书文档的目录结构:

// apps/web/src/components/ChunkPreview.jsx export default function ChunkPreview({ chunks }) { return ( <div className="space-y-2"> {chunks.map((chunk, idx) => ( <div key={chunk.id} className="border rounded p-3"> <div className="flex justify-between text-xs text-gray-500"> <span>Chunk {idx + 1}</span> <span>Tokens: {chunk.tokenCount}</span> </div> <p className="text-sm line-clamp-3">{chunk.content}</p> </div> ))} </div> ); }

有了这个界面,你可以:

  • 直观检查切片语义完整性。
  • 对比不同chunkSizeoverlap的效果。
  • 点击某个切片,查看它在向量库中的向量相似度排名。
  • 快速定位“切片过大导致上下文噪音”“切片过小导致语义不完整”的问题。

5.3 AI Agent 工具调用:让前端应用主动完成业务操作

前面的 RAG 解决了“从文档里找答案”的问题,但 AI Agent 更进阶的能力是调用工具。举个例子,用户提问“帮我把采购流程文档分享给李四”,单纯 RAG 做不到,AI Agent 需要调用shareDocument工具。

后端可以这样定义工具:

// apps/server/src/services/agent.js const tools = [ { type: 'function', function: { name: 'share_document', description: '分享知识库文档给指定用户', parameters: { type: 'object', properties: { documentId: { type: 'string' }, userId: { type: 'string' }, }, required: ['documentId', 'userId'], }, }, }, ];

前端需要感知工具的调用过程,否则用户会看到 AI 突然回复了一段奇怪的话。交互设计上建议增加“工具调用中”状态:

const [toolStatus, setToolStatus] = useState(null); // 接收服务端推送的工具调用状态 setToolStatus({ name: 'share_document', status: 'executing' });

6. 常见问题与排查思路

6.1 检索不到内容,但文档明明已入库

现象常见原因解决思路
检索结果为空向量化失败或维度不一致检查入库日志,确认 embedding 维度是否与表结构一致
检索为空但向量表有数据查询时 embedding 模型不同确保入库和查询使用同一个 Embedding 模型
检索到但不相关切片过大/过小调整切片策略,增加重叠部分
中文名称搜不到分词问题增加 BM25 关键词召回,检查分词器
新上传文档搜不到向量同步延迟检查队列或异步任务执行状态

排查向量检索问题,首先要确认 embedding 模型一致。很多人会犯的错误是:入库时用 A 模型,查询时因为 API 变更换成了 B 模型,两个模型的向量空间完全不同,语义相似度会完全失效。

6.2 前端流式输出不流畅

常见表现:

  • 打字机效果卡顿。
  • 内容一次性输出。
  • 中文乱码。

主要原因:

  1. 前端收到 SSE 数据后立即 setState,频繁触发重渲染。
  2. 没有正确处理 SSE 的 buffer,数据被截断导致 JSON parse 失败。
  3. 服务器响应头没有正确设置Content-Type: text/event-stream
  4. 网络代理或网关压缩了流式响应。

排查顺序:

# 1. 后端确认响应头 curl -N http://localhost:3000/api/chat -d '{"question":"1+1"}' -H "Content-Type: application/json" # 2. 前端看 network 面板,确认 data 是否分段到达 # 3. 检查是否加了缓存代理导致流被缓冲

6.3 大模型回答包含无关信息,甚至自己编造

这是 RAG 最常见的痛点。解决方向:

  1. Prompt 明确强调“只能根据参考资料回答,资料中没有要承认不知道”。
  2. 检索时提高相似度阈值,过滤低置信度片段。
  3. 引入重排,降低次要片段进入 Prompt 的概率。
  4. 展示引用来源,让人工可以追溯。
  5. 增加“拒答”逻辑,不强迫模型对每个问题都给出答案。

6.4 前端 React 重渲染导致问答卡顿

流式接口高频更新 state,会导致整个页面重渲染。优化方式:

// 使用 useCallback + 批量写入 const updateTimer = useRef(null); const bufferRef = useRef(''); function appendContent(delta) { bufferRef.current += delta; if (updateTimer.current) return; updateTimer.current = setTimeout(() => { setMessages((prev) => { const last = prev[prev.length - 1]; last.content += bufferRef.current; bufferRef.current = ''; return [...prev]; }); updateTimer.current = null; }, 50); }

这样把 50ms 内的多次 delta 合并成一次更新,页面流畅度会有明显提升。

7. 最佳实践与工程建议

7.1 切片策略要围绕真实文档调优

不要盲目使用固定chunk_size=500的默认值。企业文档类型多样,建议为不同文档类型配置不同策略。例如:

  • 开发文档:按 Markdown 标题分块,保留代码块完整性。
  • 审批制度 PDF:按章节和段落切分,避免把表格拆散。
  • 会议纪要:按天或按主题切分。
  • 帮助中心 FAQ:每条 FAQ 作为一个独立切片。

切完片后,用一批典型问题做回归测试,统计回答命中率和准确率,而不是凭感觉调参数。

7.2 向量化与检索的一致性

这一条怎么强调都不为过:

  • 同一个 Embedding 模型。
  • 同一套向量维度。
  • 相同的相似度计算方式。
  • 生产环境升级模型时,必须重建全量索引。

如果要升级 Embedding 模型,可以先把新模型生成的向量写入新表,对比新旧检索效果后再切换,不要直接覆盖旧索引。

7.3 安全与权限是知识库的底线

企业内部知识库必然涉及敏感信息,系统设计必须考虑:

  • 文档级权限控制。
  • 切片级访问过滤。
  • 检索结果按用户权限过滤。
  • 问答系统防止 Prompt 注入。
  • 敏感词检测和操作审计。
// 检索时根据用户权限过滤候选片段 export async function retrieveWithPermission(query, userId, topK = 10) { const results = await hybridRetrieve(query, topK * 2); const allowed = await filterChunksByUser(results, userId); return allowed.slice(0, topK); }

为了避免 Prompt 注入,这里不能直接拿用户输入拼接 Prompt,必须做角色隔离,让系统指令明确“你是知识库助手,不执行用户的附加指令”,并且对用户输入中的敏感指令做过滤。

7.4 从 Dify 等开源平台借鉴设计思路

现在很多团队会先搭 Dify 体验 RAG 流程,再考虑自研。Dify 在知识库流水线上有几个设计很有参考意义:

  1. 分段模式可视化:在上传文档时就能预览切片效果。
  2. 检索测试:支持直接对比不同检索策略的召回结果。
  3. 数据集召回测试:可以统计命中率和召回率。
  4. 知识库与应用的解耦:一个知识库可被多个应用复用。

自研时建议也按这个思路做:知识库配置、文档管理、问答应用三层解耦,前端页面分别对应配置页、数据管理页和问答页。

7.5 前端如何更好理解 RAG 并设计交互

前端负责的是用户和系统之间的桥梁,理解 RAG 的流程后,可以做出更有价值的交互设计:

  1. 检索过程可视化:展示“正在检索 3 篇文档,共 12 个片段”,增强控感。
  2. 相似度阈值提示:当所有召回片段分数偏低时,提示“知识库暂无高效匹配的答案”,避免用户误以为系统答错。
  3. 追问推荐:根据当前问题生成 3 个推荐追问,提升对话效率。
  4. 反馈闭环:在回答下方增加“有帮助/无帮助”按钮,帮助运营优化切片和检索策略。
  5. 多轮对话中的上下文刷新:用户在对话中提到“刚刚那份文档”,前端要带上上下文,而不是只把当前问题发给后端。

8. 总结与下一步学习建议

到这里,一套类飞书文档知识库的核心链路已经完整走通:文档解析、切片、Embedding 向量化、多路召回、重排、大模型回答、前端流式展示、引用溯源、权限过滤。前端开发者如果完整动手实现一遍,收获最大的不是“会调 OpenAI API”,而是真正理解了 RAG 系统里每一环对用户体验的影响:

  • 切片策略影响回答准确率。
  • 召回排序影响引用的可信度。
  • 流式输出影响交互流畅度。
  • 权限过滤影响产品的安全下限。

如果你准备继续深入,建议按以下顺序学习:

  1. 先跑通本文示例,替换成你自己的文档和问题,观察检索结果。
  2. 用开源的知识库平台或向量数据库对照实验,对比不同切片参数下的效果。
  3. 学习 Agentic RAG:让 AI Agent 自主决定何时检索、检索几轮、是否需要调用工具,这是从“问答机器人”走向“智能助手”的关键。
  4. 前端方向继续研究 AI Agent 交互设计,包括工具调用状态展示、多模态文档预览、人机协作编辑等。

知识库项目最大的魅力在于:它不是一个“跑通就结束”的 demo,而是一个需要持续调优、用数据说话的工程系统。从检索命中率、回答准确率、用户反馈率三个指标出发,你会不断找到可以优化的细节。下一篇我准备单独写“向量混合检索加 BM25 多路召回的调参实验”,包含不同权重组合在业务文档上的效果对比,欢迎持续关注。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/26 21:08:47

设备身份与访问控制:构建物联网安全信任基石

物联网安全系列写到第六篇&#xff0c;前几篇我分别梳理过威胁建模、嵌入式固件安全、通信加密、OTA升级安全这些方向。这一篇想认真聊聊设备身份与访问控制&#xff08;Device Identity and Access Control&#xff09;&#xff0c;因为做了这么多年的物联网安全项目&#xff…

作者头像 李华
网站建设 2026/8/26 21:00:59

生物质与煤共热解建模:从数学竞赛到工业优化

1. 这不是“抄答案”&#xff0c;而是用建模思维解真实工业问题“2024年数维杯数学建模B题&#xff1a;生物质和煤共热解问题的研究”——看到这个标题&#xff0c;很多同学第一反应是找“思路代码”速成&#xff0c;想在72小时内交出一份能拿奖的论文。但作为连续带队参加过8届…

作者头像 李华
网站建设 2026/8/26 20:59:30

生物多样性评估实战:从数学建模到SPSSPRO应用全解析

1. 从一道赛题到一套方法&#xff1a;生物多样性评估的实战拆解 如果你在搜索引擎里找过“数学建模”、“生物多样性评估”或者“SPSSPRO”&#xff0c;大概率会看到2011年认证杯数学建模B题&#xff08;第二阶段&#xff09;的身影。这道题之所以能成为经典&#xff0c;甚至十…

作者头像 李华
网站建设 2026/8/26 20:59:28

Nemotron-3-Ultra部署实战:vLLM/SGLang/TRT-LLM三大引擎避坑指南

1. 项目概述&#xff1a;为什么这个指南值得你花两小时认真读完 NVIDIA Nemotron-3-Ultra不是普通的大模型——它是NVIDIA官方发布的、专为强化学习对战&#xff08;RLHF对抗训练&#xff09;、模型蒸馏与合成数据生成而深度优化的“教练型”模型。它不主打通用对话&#xff0c…

作者头像 李华
网站建设 2026/8/26 20:59:00

2026版软件测试面试题解析与实战技巧

1. 软件测试面试题的价值与定位在技术岗位求职过程中&#xff0c;面试题库就像游戏玩家的装备库&#xff0c;准备得越充分&#xff0c;通关的可能性就越大。作为从业十余年的测试工程师&#xff0c;我整理过不下20个版本的面试题库&#xff0c;深知一套好的面试题对求职者和面试…

作者头像 李华