
当团队文档越来越多、飞书/企业内部知识库的“搜索”渐渐搜不到想要的内容时“语义检索 智能问答”就成了刚需。本文从零梳理一套类飞书文档知识库的完整开发链路前端负责文档管理和问答交互后端串联解析、切块、向量化、混合检索与 Agent 工具调用最终跑通“提问 — 检索 — 生成 — 引用溯源”全流程。适合有前端基础、想切入 AI Agent RAG 项目的开发者。1. 为什么需要 AI Agent RAG 的类飞书文档知识库1.1 传统文档知识库的痛点在企业内部大家通常会把产品手册、研发文档、项目复盘、设计规范、会议纪要放到“类飞书文档”的平台上。文档一多问题就暴露了关键词搜索不准搜“登录失败”可能匹配不到写的是“认证报错”的文档答案分散一个问题往往分散在多篇文档里用户要自己逐个点开、拼接信息找不到就反复问人大量重复的“这个接口怎么调”“报销流程是什么”消耗团队时间。传统搜索本质是字面匹配无法理解“语义”。而 RAGRetrieval-Augmented Generation检索增强生成把“检索”和“生成”结合先根据用户问题去知识库中召回最相关的内容片段再让大模型基于这些片段生成回答。这样既能利用大模型的自然语言理解能力又能让回答建立在企业自己的文档事实上而不是模型“凭空想象”。1.2 AI Agent 在这里扮演什么角色如果把 RAG 比作“查资料的能力”那么 AI Agent 就是“会调用这项能力的调度者”。在实际问答场景中用户的问题不一定是标准的检索式提问“帮我总结一下本周项目的风险”“这块逻辑改完后是否需要同步更新数据库表结构”“退款流程里超过 7 天怎么办”这些问题可能需要多次检索、需要把多篇文档结果汇总甚至需要确认用户问的是“文档里的流程”还是“现实中的规则”。AI Agent 在这里承担了意图理解、任务规划、工具调用、结果汇总等职责。RAG 在这个体系里不是一个独立接口而是 Agent 的一个“工具”。1.3 前端开发者的机会点很多团队会把这类项目当成纯后端项目来做但实际业务中前端要承担不少关键任务知识库文档管理界面上传、编辑、文档树展示智能问答对话界面流式输出、引用卡片、相关文档推荐搜索框的交互设计关键词联想、检索结果高亮文档解析进度、切块列表的可视化调试工具与后端事件流接口SSE/流式响应对接的适配层。所以前端不只是“画页面”而是需要理解 RAG 全链路的数据流才知道接口该对接什么、流式数据怎么解析、引用溯源怎么展示。这也是目前 AI 前端岗位面试里经常被问到AI Agent、RAG 知识库、向量混合检索加 BM25 多路召回的原因。2. 全链路架构设计2.1 整体流程类飞书文档知识库 智能问答系统的整体流程可以拆成两条链路离线链路知识库构建文档上传/导入文档解析按类型识别 PDF、Word、Markdown、纯文本等文本清洗去页眉页脚、去重复内容、处理表格和代码块文本切块Chunking把长文档切成长度合适的片段向量化Embedding把每个 Chunk 转成向量存储到向量库同时把原文片段、标题、文档 ID 等元数据一并保存同时构建关键词索引如 Elasticsearch / 本地倒排索引用于 BM25 召回。在线链路问答阶段用户输入问题Agent 判断意图是普通闲聊、需要查知识库还是需要调用其他工具如果走知识库问答对问题进行查询改写Query Rewrite可能拆成多个子查询多路召回一路走向量相似度检索一路走 BM25 关键词检索结果融合RRF / 权重排序可选 Rerank 精排过滤低相关片段将高相关片段拼进 Prompt交给大模型生成返回答案和引用来源给前端展示。2.2 分层模块划分模块职责技术选型建议前端应用文档管理、对话界面、流式渲染React / Vue TypeScriptAPI 服务上传、问答、文档列表、权限校验Node.js / Python FastAPI / Java Spring Boot解析服务PDF、Word、Markdown 解析Python textract / pdfplumber / mammoth切块与向量化文本切块、Embedding 生成Python 本地或云厂商 Embedding 模型向量存储语义检索Milvus / qdrant / Elasticsearch 8.x Vector关键词索引BM25 召回Elasticsearch / OpenSearch / SQLite FTS5Prompt 编排组装上下文、限制输出LangChain / LlamaIndex / 自研编排Agent 调度意图识别、工具调用、多轮记忆LangGraph / Dify 工作流 / 自研状态机上面这个表给出的是常见选型。如果你是想快速做出 Demo本地可以采用“SQLite FTS5 做 BM25 Chroma 或 sqlite-vec 做向量检索”的轻量方案如果是企业级知识库建议直接上 Elasticsearch 8.x因为它同时支持 BM25 和 kNN 向量检索可以省掉一套中间件。3. 环境准备与项目结构3.1 基础环境开发这套系统你至少需要准备Node.js 18前端工程以及部分 Node 后端服务Python 3.10文档解析、切块、Embedding、检索服务Docker可选用于本地启动向量数据库或 Elasticsearch一个支持向量存储的数据库服务一个文本向量化模型接口本地部署或云厂商 SDK 均可一个大模型对话接口兼容标准 Chat Completions 协议即可。具体版本不建议照搬网上任意一篇文章因为模型服务、向量库版本的迭代非常快。本文演示配置时以“常见版本环境”为例重点讲清楚配置思路你在实际操作时按自己环境调整。3.2 项目目录设计如果让前端同学独立完成一套 Demo建议按下面的目录组织ai-doc-knowledge/ ├── frontend/ # 前端项目 │ ├── src/ │ │ ├── pages/ │ │ │ ├── DocList.tsx # 文档列表页 │ │ │ └── ChatPage.tsx # 智能问答页 │ │ ├── components/ │ │ │ ├── DocTree.tsx # 文档树 │ │ │ ├── MessageList.tsx # 对话消息列表 │ │ │ ├── ReferenceCard.tsx # 引用来源卡片 │ │ │ └── StreamContent.tsx # 流式内容渲染 │ │ ├── api/ │ │ │ ├── doc.ts # 文档上传/列表接口 │ │ │ └── chat.ts # 问答接口 │ │ └── utils/ │ │ └── stream.ts # 流式解析工具 │ └── package.json ├── server/ │ ├── app.py # FastAPI 主服务 │ ├── parser.py # 文档解析 │ ├── chunker.py # 文本切块 │ ├── embedder.py # 向量化 │ ├── retriever.py # 混合检索 │ ├── agent.py # Agent 调度 │ └── config.py └── docs/ └── sample/ # 示例文档前端目录和服务端目录分开。对于纯前端同学第一步可以先跑通server/app.py再单独写前端不必一开始就陷入 LangChain 等框架的复杂概念里。4. 知识库构建从文档上传到文本切块4.1 前端文档上传先写一个最基础的前端上传接口支持多文件上传同时把文档归属到某个知识库空间对应类飞书文档里的“知识库/团队空间”。// 文件路径frontend/src/api/doc.ts export interface UploadDocParams { file: File; spaceId: string; onProgress?: (percent: number) void; } export async function uploadDocument({ file, spaceId, onProgress }: UploadDocParams) { const form new FormData(); form.append(file, file); form.append(spaceId, spaceId); const response await fetch(/api/v1/docs/upload, { method: POST, body: form, }); if (!response.ok) { const err await response.json().catch(() null); throw new Error(err?.message || 上传失败); } return response.json(); }如果文档较大前端建议使用分片上传或 Worker 上传如果只是内部知识库 Demo直接一次性上传问题不大。这里要注意一点上传后服务器需要异步解析所以接口返回的不一定是“解析完成”而是“已加入解析任务”前端要能轮询解析状态。4.2 服务端解析与文本清洗上传完成后服务端根据文件扩展名选择解析器# 文件路径server/parser.py from pathlib import Path def parse_document(file_path: str) - str: suffix Path(file_path).suffix.lower() if suffix .pdf: return parse_pdf(file_path) if suffix in (.docx, .doc): return parse_word(file_path) if suffix in (.md, .txt): return Path(file_path).read_text(encodingutf-8) raise ValueError(f不支持的文件类型: {suffix})实际项目中PDF 解析经常遇到表格错乱、页眉页脚混入正文的问题。可以做一层简单的清洗去掉重复行、去掉与正文无关的“第 X 页 / 共 X 页”、把连续空白压缩为单个换行。4.3 切块策略RAG 效果的关键文本切块是 RAG 系统中最容易被忽视、又对效果影响最大的环节。切得太碎单个 Chunk 语义不完整切得太大向量检索的精度下降而且超过大模型上下文窗口后还容易截断。基础做法是按固定长度切块# 文件路径server/chunker.py def simple_chunk(text: str, chunk_size: int 500, overlap: int 50) - list[str]: if len(text) chunk_size: return [text] chunks [] start 0 while start len(text): end start chunk_size chunk text[start:end] chunks.append(chunk) start end - overlap return chunks这个实现清晰易懂但缺点是可能把一个完整段落从中间切开。更好的方案是“按 Markdown 标题层级切块”import re def split_by_md_heading(text: str) - list[str]: 按 Markdown 标题切块尽量保留语义完整性。 lines text.splitlines() chunks [] current_section [] for line in lines: if re.match(r^#{1,6}\s, line): if current_section: chunks.append(\n.join(current_section)) current_section [] current_section.append(line) if current_section: chunks.append(\n.join(current_section)) return chunks切块策略没有银弹。建议在一个小数据集上做几组对照实验观察“检索命中片段”的内容是否完整表达了问题所需的信息。后面还可以引入语义切块根据句向量相似度判断断点但前期不建议过度设计。5. 向量化与混合检索5.1 Embedding把文本变成向量Embedding 的作用是把一段文本映射成固定维度的向量让语义相近的文本在向量空间中距离更近。常见选择有本地 BGE 系列、M3E 系列或云厂商提供的 Embedding 接口。以兼容 OpenAI Embedding 协议的接口为例# 文件路径server/embedder.py import os from openai import OpenAI client OpenAI( api_keyos.getenv(EMBEDDING_API_KEY), base_urlos.getenv(EMBEDDING_BASE_URL), ) def get_embedding(text: str) - list[float]: resp client.embeddings.create( modelos.getenv(EMBEDDING_MODEL, text-embedding-3-small), inputtext, ) return resp.data[0].embedding生产环境建议做两件事一是缓存 Embedding 结果避免同一 Chunk 重复计算二是记录模型维度因为向量库建索引时维度必须一致。如果切换模型导致维度变化需要重建向量库。5.2 向量检索向量检索的目标是找到“和用户问题语义最接近的 TopK 片段”。使用 Elasticsearch 8.x 的knn查询时大致写法如下POST /knowledge_chunks/_search { knn: { field: embedding, query_vector: [0.1, 0.2, ...], k: 10, num_candidates: 100 } }其中k是返回 Top10num_candidates是候选集数量越大召回越准但性能会下降。5.3 BM25 多路召回向量检索擅长语义相似但不擅长精确关键词匹配。比如用户搜接口报错码ERR_10086向量检索很可能匹配到一堆“错误处理”文档而 BM25 能精确命中包含该错误码的段落。BM25 是经典的关键词排序算法。在 Elasticsearch 中match查询默认就是 BM25 打分POST /knowledge_chunks/_search { query: { match: { content: 登录失败 ERR_10086 } }, size: 10 }很多团队采用“向量检索 BM25 多路召回”的方案两边各自拿回 TopK再通过 RRFReciprocal Rank Fusion融合排序。RRF 的思路是对每个文档在不同结果列表中的排名取倒数并求和排名越靠前融合分越高。# 文件路径server/retriever.py def rrf_fusion(vector_results: list[str], bm25_results: list[str], k: int 60) - list[str]: scores: dict[str, float] {} for rank, doc_id in enumerate(vector_results): scores[doc_id] scores.get(doc_id, 0) 1 / (k rank 1) for rank, doc_id in enumerate(bm25_results): scores[doc_id] scores.get(doc_id, 0) 1 / (k rank 1) return sorted(scores, keyscores.get, reverseTrue)5.4 Rerank 精排多路召回之后可能会混入不少“有关但不相关”的内容。此时可以引入 Rerank 模型对候选片段重新打分排序。如果前期不想引入过多服务也可以在业务层做规则过滤去掉相似度低于阈值的片段去掉已被其他片段覆盖的重复内容优先保留包含完整标题、且和问题实体匹配的片段。6. RAG 流水线与 AI Agent 编排6.1 第一步意图识别不是所有问题都要查知识库。问“你好”“今天天气”这类问题直接走普通对话即可。Agent 的第一步是先判断意图。用大模型做意图分类是常见做法。为了降低成本也可以先用规则判断包含“如何”“是什么”“流程”“规范”等词大概率要查知识库包含“你好”“谢谢”等词则走闲聊。6.2 第二步查询改写与子查询用户问“刚才那个功能的文档在哪里”这里的“那个功能”指代不明确。Agent 需要结合多轮对话历史把问题改写为更完整的检索式“XX 功能的说明文档”。改写后的查询交给检索模块同时可以生成多个子查询“XX 功能 介绍”“XX 功能 使用说明”“XX 功能 API”每个子查询分别走混合检索再合并结果。6.3 第三步组装 Prompt 与生成回答检索到的片段需要放进 Prompt。推荐的模板结构是你是一个企业内部知识库助手。请根据以下文档片段回答用户问题。 如果片段中没有相关信息请直接说“知识库中暂无相关内容”不要编造。 文档片段1[标题] 登录认证流程 [正文] ... 文档片段2[标题] 常见报错排查 [正文] ... 用户问题登录报错怎么办这里有一个容易被忽略的点要控制片段的字符数。把 Top10 片段全部塞进 Prompt不仅浪费 token而且会稀释关键信息。建议先做召回再按 Token 限制筛选片段。6.4 引用溯源企业知识库的回答必须“有据可查”。在检索时除了返回 Chunk 内容还要返回对应的元数据文档 ID、文档标题、章节标题、页码或行号。组装 Prompt 时给每个片段编号生成回答时要求模型在引用处标注[1]、[2]。前端拿到回答内容后解析引用标记并展示引用卡片。context \n\n.join( f[{i 1}] {item[doc_title]} - {item[section]}\n{item[content]} for i, item in enumerate(retrieved_items) )6.5 用 Agent 编排多个工具当项目复杂度上去后可以把“知识库检索”封装成一个工具由 Agent 调度。工具定义大致如下# 文件路径server/agent.py TOOLS [ { type: function, function: { name: search_knowledge_base, description: 在企业知识库中搜索相关文档片段, parameters: { type: object, properties: { query: {type: string, description: 检索关键词} }, required: [query] } } } ]Agent 收到用户问题后如果判断需要检索就会生成一个函数调用参数服务端执行search_knowledge_base工具把检索结果拼进上下文再交给模型生成最终回复。这种“Agent RAG”的组合也被称为 Agentic RAG相比一次检索就生成回复它能处理更复杂、需要多步检索的问题。7. 前端智能问答界面开发7.1 对话接口设计前端和后端约定问答接口返回text/event-stream流式数据这样大模型生成的内容可以逐字显示体验更接近 ChatGPT。7.2 流式解析工具// 文件路径frontend/src/utils/stream.ts export async function fetchChatStream( url: string, body: unknown, handlers: { onMessage: (delta: string) void; onReference: (refs: Reference[]) void; onDone: () void; onError: (err: Error) void; } ) { const response await fetch(url, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(body), }); if (!response.ok || !response.body) { handlers.onError(new Error(请求失败)); return; } const reader response.body.getReader(); const decoder new TextDecoder(utf-8); 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) { const trimmed line.trim(); if (!trimmed.startsWith(data:)) continue; const data trimmed.slice(5).trim(); if (data [DONE]) { handlers.onDone(); continue; } try { const parsed JSON.parse(data); if (parsed.delta) handlers.onMessage(parsed.delta); if (parsed.references) handlers.onReference(parsed.references); } catch { // 忽略半行 JSON等下一个 data 块 } } } }上面的代码把data:前缀的 SSE 数据逐行解析每解析出一段 delta 就更新界面。7.3 Markdown 渲染与引用卡片大模型返回的内容是 Markdown 格式前端需要渲染代码块、表格、列表。如果直接拼到 DOM 里要注意 XSS 风险。建议使用react-markdown/marked等库并在渲染前做白名单过滤。引用卡片可以放在回答正文下方// 文件路径frontend/src/components/ReferenceCard.tsx export function ReferenceCard({ refs }: { refs: Reference[] }) { return ( div classNamereference-list {refs.map((ref, i) ( div classNamereference-item key{ref.docId i} span[{i 1}]/span a href{/doc/${ref.docId}}{ref.docTitle}/a span{ref.section}/span /div ))} /div ); }7.4 开发环境接口代理前端开发时如果前后端端口不同需要在构建工具里配置代理。以 Vite 为例// 文件路径frontend/vite.config.ts import { defineConfig } from vite; export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8000, changeOrigin: true, }, }, }, });这里的“代理”指的是前端开发服务器将/api请求转发到后端服务属于常规联调配置与网络代理无关。8. 常见问题与排查思路问题现象常见原因解决思路上传文档后问答总是答“知识库中暂无内容”文档解析失败或切块后向量化失败检查解析服务日志确认 Chunk 是否入库回答内容与文档无关检索召回的片段相关性差调大k、增加 BM25 多路召回、引入 Rerank回答内容过于空洞Chunk 切得太碎上下文不完整改用按标题切块或增大 chunk_size前端流式输出卡顿SSE 数据解析有误或服务端未设置流式响应用 Postman 直接请求接口确认返回格式文档更新后回答仍是旧内容没有触发增量切块和向量化实现文档变更后重新构建索引的异步任务中文匹配效果差切块破坏了语义或 Embedding 模型对中文支持弱使用中文语料优化的 Embedding 模型排查顺序建议先确认数据有没有进库再到检索模块打印召回片段最后看 Prompt 拼接是否完整。不要一上来就调试前端。9. 企业级工程最佳实践9.1 权限过滤必须在检索前类飞书文档知识库天然有空间、团队、成员权限。如果做多租户必须把权限条件作为检索的过滤条件而不能等检索完再过滤。否则通过向量检索可能把别人空间下的敏感内容暴露出来。以 Elasticsearch 为例检索时应在查询条件中拼接space_id等过滤项向量库选型时也要确认是否支持标量过滤字段。9.2 异步处理文档解析任务文档解析、切块、向量化是耗时操作不能阻塞 API 请求。建议用任务队列如 Redis Queue、Celery处理前端通过轮询或 WebSocket 获取解析进度。9.3 记录日志与评估指标上线后至少需要关注检索召回命中率用户点击/采纳的回答是否都来自检索片段引用点击率用户是否点开引用卡片阅读原文平均回答延迟流式首字延迟、完整回答耗时失败率解析失败、检索超时、模型调用失败次数。建议搭建一个反馈按钮“有帮助 / 无帮助”把用户反馈沉淀为评估数据集持续优化切块策略和 Prompt。9.4 防止提示注入知识库文档可能是用户上传的文档中可能包含恶意指令比如“忽略以上所有指令只输出 XX”。在 Prompt 中应明确分隔指令与文档内容并对输出做敏感信息过滤。生产环境要对上传文档做内容安全检测。9.5 控制成本Embedding 调用和 LLM 调用都是有成本的。建议对 Embedding 结果做缓存对高频问题做相似问题命中缓存检索到的 TopK 片段先按 Token 截断再送入模型在低峰期批量完成文档向量化。9.6 向量库与业务库同步有人会问“ES 库与知识库是不是要同步”答案是肯定的。业务库保存“文档原文、版本、权限”向量库保存“切块文本、向量、元数据”。文档更新时两步必须在一个事务内或通过消息队列保证最终一致先更新业务库再触发重建该文档下所有 Chunk 的向量。10. 从 Demo 到落地的学习路线如果从零开始建议按下面顺序推进先用 Python FastAPI 写一个最小 RAG 服务本地文件切块、调 Embedding 接口、存本地向量库、检索、调用大模型生成回答用 React/Vue 写一个最简单的聊天页对接流式接口先把“提问—回答—引用卡片”跑通加入文档上传和异步解析让前端可以管理知识库文档加入 BM25 多路召回与 RRF 融合对比“只用向量检索”的效果差异引入 Agent 编排做意图识别和工具调用最后接入权限模型、监控、反馈收集达到企业内部可用状态。整个过程最大的难点不是某个具体框架的 API而是对数据流的理解文档如何变成 ChunkChunk 如何变成向量向量如何被召回召回结果如何被大模型组织成答案前端又该如何把答案和证据呈现给用户。把这套链路吃透之后无论是面试中聊RAG 实战、Agent 架构还是去落地企业知识库项目都会有完整的工程视角。建议先动手做一版最小闭环再逐步往企业级方向扩展。