我们团队现在问“某个内部服务的鉴权签名到底怎么生成”,基本30秒内就能拿到带代码示例的答案。换一年前,这种问题最少得翻15分钟:先在Wiki里猜关键词,打开四五个页面,再对着过时的格式说明猜参数,最后可能还得找后端同事确认一遍。我们把PandaWiki从“给人看的文档库”改造成“给模型读取的智能知识库”之后,这个流程彻底变了。这篇文章不绕弯子,主要分享我们怎么搭、大模型怎么配置、哪些坑必须踩,给想给内部API文档加一个“搜索问答”入口的团队一个可复制的参考。
1. 先搞清楚:知识库为什么要用大模型?——从15分钟到30秒的解题思路
1.1 原来的API查询为什么慢?慢在哪三个环节
先说个真实场景。一个新来的同事要对接订单服务的查询接口,通常他先去Wiki搜“订单查询API”,看到三个类似页面:一个用旧接口,一个是新格式,还有一个是草稿。他得逐个点开,对比参数差异,再翻代码仓库里有没有对应示例。运气好五分钟能定位,运气不好要翻半小时,中间还可能被“token过期”这种伪问题绊住。
这里的慢,本质上是三件事:信息分散、版本混乱、需要人工组合。信息分散是指文档存在于Wiki、代码仓库、接口管理工具、聊天记录里,互不同步;版本混乱是指存在多个历史页面,没有明确的“当前生效版本”标识;人工组合是说,使用者要自己把参数表、鉴权逻辑、调用示例拼到一起,才能得出一个完整操作步骤。这三步每步都消耗几分钟,加起来自然就是15分钟起步。
更麻烦的是,这种查询发生的频率很高。只要新员工入职、老员工跨服务协作、接口升级,就会重复发生。而且很多时候提问的人根本不知道自己在找什么,连关键词都猜不准,只能靠同事“喂答案”。所以解决API查询慢,本质上不是加快某一页面的打开速度,而是要把“找、读、组合”这三步自动化。
1.2 大模型能解决什么,解决不了什么
大模型本身很强,但很多人对它有个误区,以为把Wiki所有内容导出成文本,然后丢给模型训练一遍,之后就能随便问了。这条路不仅成本高,而且更新困难,私有文档还会被模型“记串”产生幻觉。更合理的做法是RAG:检索增强生成。让大模型当一个“阅读理解器”,而不是一个“记忆库”。
在API文档这个场景里,大模型擅长的是把检索到的零散片段整理成结构化回答,比如“先做A,再调B接口,参数是C”,甚至可以补一个可执行的curl示例。它不擅长的是准确记住你们团队独有的字段、接口名和权限规则。所以我们必须先把可信的信息从知识库里检索出来,再让模型基于这些信息回答。
这也解释了为什么我们最终不选“直接用大模型预设指令”的方案。模型几亿参数记不住你们几十个服务里每个接口的细节,而且训练数据里根本不可能包含内部API的私有信息。只有把检索放在生成之前,才能保证答案来源可控、可追溯。
1.3 为什么选PandaWiki作为底座:自建Wiki + 私有化大模型的组合
市面上可用的文档系统很多,但我们最终在PandaWiki上搭,是因为它同时满足了几个硬性条件。数据安全是第一位的,内部API文档涉及核心业务细节,不能放在外部的商用SaaS平台;其次是可扩展性,PandaWiki自带页面API、附件接口和Webhook,我们能写脚本按需拉取所有页面内容和更新时间;最后是团队组织能力,大家已经习惯在Wiki里写方案、留记录,迁移成本最低。
如果你们还在选底座,我的建议是不要只看界面好不好看,关键要确认三件事:页面内容能不能通过API导出成纯文本或Markdown;有没有清晰的页面分类/空间概念,方便按权限隔离;能不能记录每页最后更新时间,否则后续索引的增量同步会非常痛苦。不具备这三个条件的系统,接大模型会变成接泥潭。
2. PandaWiki智能知识库的整体架构与组件选型
2.1 一条链路看懂:从文档到答案的数据流
我习惯把整套系统拆成四层。第一层是内容源,也就是PandaWiki本身,所有API文档、使用指南、维护记录都在这里。第二层是处理层,负责定时抓取Wiki页面,把HTML或富文本转成Markdown,按标题和段落规则切成小段,然后调用嵌入模型给每个段落生成向量,写入向量数据库。第三层是查询层,一个内部网关服务接收“自然语言查询”,把问题向量化,从向量库召回最相关的文档片段,同时根据用户权限做过滤。第四层是生成层,把召回片段、用户问题、系统约束拼成一个Prompt,交给对话模型,模型返回答案后再拼上原文链接返回给调用方。
整套链路是单向的、模块化的。Wiki源和查询网关之间只通过向量库和元数据通信,互不阻塞。这样的好处是:如果模型服务挂了,网关至少还能把检索到的文档片段返回给用户;如果Wiki改版,只需要调整同步脚本,不影响下游。
2.2 关键组件选型:为什么不建议“一把梭”
很多文章喜欢推荐“全家桶方案”,但实际落地要克制。我们的选型原则是:每个组件只解决一个问题,避免绑定过深。
切片策略上,不要用固定500字硬切。API文档里经常出现参数表格和代码块,如果切成一半,语义就断了。我的做法是在Markdown层面解析标题、段落、表格、代码块。标题作为切片的自然边界,表格单独成一个块,代码块单独成一个块,每个块再配上所属的页面标题和链接。这样检索时,模型拿到的是一段语义相对完整的片段。
嵌入模型要考虑“中文+代码+表格”混合的文档。有些模型对中文理解很好,但对代码不敏感,导致用户问“怎么用Python签名”时,召回的是参数说明而不是代码示例。我们测试后选了一个兼顾中英文和代码的开源嵌入模型,向量维度控制在768左右,既不太占存储,也能保留足够语义信息。
向量数据库也不必上重型分布式系统,内部知识库的规模撑死几十万片段,用轻量级的开源向量库就够。重点要确认它支持余弦距离、过滤查询,以及持久化。我们会把文档空间、权限组、最后更新时间写进元数据,在查询时用这些字段过滤,否则权限隔离会很难做。
对话模型我们优先选私有化部署,或至少接入公司已有的模型网关。关键指标是支持4K以上上下文,因为一次查询要放5个左右的片段;推理延迟要可控,量化之后要能在单卡上跑起来。后面做性能优化时,我们会把输出长度和并发数卡死。
2.3 时间账怎么算:为什么架构能实现“15分钟变30秒”
在线查询链路有三部分耗时。首先是向量化查询文本,用CPU也能几十毫秒完成,如果嵌入模型在GPU上,速度更快;然后是向量库召回,只查top-5到top-20的索引项,毫秒级;最后最不可控的是大模型生成,一次完整生成500字可能要2到10秒。也就是说,纯算法和检索的耗时其实只有两三百毫秒,真正的“大块头”在生成阶段。所以30秒这个数字主要是给模型生成留了预算,而不是系统本身太重。
这里要澄清一点:我们说的30秒不是每次都要30秒。第一次未命中缓存时可能要8到10秒,命中缓存后基本1秒内返回。30秒是端到端稳定上限,也是标题里用来对比原来15分钟的直观数字。如果你做完后,检索部分花了超过1秒,那一定是什么地方没配对,正常情况不会这么慢。
3. 大模型配置指南:从0到1的落地步骤
3.1 环境和PandaWiki部署,服务器配置怎么给
我们先说硬件基线。如果文档量在几千页级别,同时对话模型要自己跑,我建议至少准备一台CPU 16核、内存32G、显存24G的机器。Wiki本身、向量库、同步脚本可以先放在同一台机器上,对话模型也用同一块GPU,但如果并发上来了,最好把模型推理放到单独的推理服务。
PandaWiki部署用Docker compose最省心。下面的配置是示意,重点是把自己要暴露的端口和持久化目录都明确出来:
# docker-compose.yml(示意) services: panda-wiki: image: pandawiki/pandawiki:stable ports: - "8080:8080" volumes: - ./wiki-data:/data environment: - PANDA_APP_ROLE=internal vector-store: image: vendor/vector-store:latest ports: - "6333:6333" volumes: - ./vdb:/vector-data部署完成后,在PandaWiki后台创建一个只读的API Token,专门给同步脚本用。这个Token的空间范围最好只勾选允许进入知识库的文档空间,不要把管理员权限交出去。接下来要开启页面的“导出为Markdown”功能,如果没有现成插件,可以让同步脚本拉HTML后再用通用解析库转。
3.2 文档同步与切片怎么做,细节全在这
同步脚本的核心是增量拉取。PandaWiki页面列表接口会返回每页的更新时间,我们每次只抓最近24小时改过的页面,保存到一个本地目录。然后做两步清洗:去掉导航、侧边栏、编辑按钮等杂质;把表格转成Markdown格式,保留列名;代码块保留语言标记。下面是个简单的切片示例:
def split_page(title: str, md_content: str): chunks = [] current_heading = title for block in parse_blocks(md_content): if block.type == "heading": current_heading = block.text continue # 小于100字的段落往往没有检索价值 if len(block.text) < 100: continue chunks.append({ "title": current_heading, "content": block.text, "page_title": title, }) return chunks经验值上,一个片段在200到500个汉字之间比较合适。太短会丢失上下文,太长又会掺入无关信息。如果遇到特别大的页面,比如一个页面里有几十个接口,我会额外按“接口名”再拆一层,避免一个片段里塞满太多内容。
切片时还有一个容易被忽略的点:代码块里面的变量名和参数名,往往就是用户搜索的关键词。所以不要把代码注释删掉,反而要在向量化时把这部分加重在文本里,这样检索效果会好很多。
3.3 向量化与向量库构建,元数据一定不能省
把切片内容交给嵌入模型之前,我习惯把标题和正文拼在一起做encode,因为标题本身就是很好的检索线索。每个向量的元数据至少要包括页面URL、最后更新时间、所属空间、权限标签。这样查询时能过滤,答案里能带回链接。写入向量库的代码类似于:
from vector_store import Client store = Client("http://127.0.0.1:6333") for idx, chunk in enumerate(chunks): vec = embed_model.encode(chunk["title"] + "\n" + chunk["content"]) store.insert({ "id": f"{chunk['page_id']}_{idx}", "vector": vec, "meta": { "title": chunk["title"], "url": chunk["url"], "space": chunk["space"], "updated_at": chunk["updated_at"], } })索引构建不是一次性的。我们每晚固定时间跑一次增量同步,只处理更新过的页面,删除标记为“已下线”的页面向量。如果不做增量,向量库会越滚越大,而且旧版本文档的向量会一直污染检索结果。这个坑我们在早期踩过,后来加了更新时间过滤才稳定下来。
3.4 Prompt设计与查询网关实现,这是体验的分水岭
查询网关是一个内部接口服务,为所有API查询请求提供统一入口。请求长这样:
curl -X POST http://knowledge.internal/api/v1/query \ -H "Content-Type: application/json" \ -d '{"query": "怎么生成API签名?", "top_k": 5, "user_group": "backend"}'网关内部逻辑四步走:第一,调嵌入模型把query变成向量;第二,在向量库召回候选片段,按相似度排序;第三,根据user_group映射到可读空间,过滤掉无权访问的片段;第四,把片段和用户query拼成Prompt,调用对话模型,拿到答案后包装成统一JSON返回。
Prompt模板是整套系统很容易被忽视但又极其重要的部分。我现在用的核心模板如下:
你是团队内部的 API 文档助手。请严格根据下面给出的参考片段回答问题。 参考片段: {chunks} 用户问题:{query} 要求: 1. 如果片段中没有相关信息,明确回答“知识库中没有找到”,不要编造。 2. 回答中包含可执行的步骤或代码示例。 3. 在最后给出参考文档链接。大模型参数方面,我把temperature调到0.2以下,避免自由发挥;max_tokens控制在800以内,因为答案太长不仅慢,而且容易输出无关内容。如果接入的模型支持system prompt,我会在system里再加一句“如果参考片段互相矛盾,以最近更新时间的片段为准”,能缓解版本冲突问题。
3.5 一个小实验记录:为什么从15分钟变成30秒
我专门做了一次计时对照。问“订单服务如何刷新access_token”,开发人员人工操作:搜索关键词、翻页面、找示例、确认新旧接口,总共用了14分多。接入网关后,端到端请求发出到模型返回第一个token大概3秒,完整答案8秒左右,首字和完整答案都远远低于30秒。后来我把max_tokens从2000调低到800,又开了流式输出,响应速度更快。
所以最后形成的配置经验是:性能主要靠控制输出长度、缓存和流式输出来解决,不是靠无限升级GPU。你甚至可以降低模型量化等级,只要Prompt和切片质量足够好,答案质量依然稳定。
4. 性能调优:怎么稳定在30秒以内
4.1 把大模型推理延迟“藏”起来:流式与缓存
大模型生成速度受输出长度影响很大,这也是我们控制max_tokens的原因。给调用方返回时,网关有两种模式:如果调用方是交互式对话,用流式输出,先把片段检索结果回传出去,再逐字输出答案,用户体感会好很多;如果调用方是程序,需要完整JSON,就等模型生成完毕。为了让绝大多数查询不必等模型,我们对高频问题做了答案缓存,TTL设为24小时,key用query重写后的归一化文本。命中缓存时网关直接返回,耗时不到500毫秒。
缓存不是为了省GPU,而是为了稳定性。很多内部工具会在每天早上集中调用同一批问题,如果每次都压给模型,GPU会被打满,反而拖慢正常请求。
4.2 检索质量决定生成质量:粗排加重排
向量召回有一个常见问题:query和片段在语义上相似,但片段并没有直接回答用户需求。比如用户搜“删除用户失败怎么排查”,召回出来的却是“用户管理接口列表”,因为语义相关,但不对症。
我们的解决办法是两步召回。第一步用向量召回top-50;第二步加一层轻量级重排,把候选片段按BM25关键词得分和向量相似度做加权融合,再取top-5给模型。重排也能利用标题和关键词,效果非常明显。如果没有独立的重排模型,直接用RRF(Reciprocal Rank Fusion)融合两个召回结果,也能在最少改动的情况下提升准确率。
4.3 并发与限流设置,不能忽视的稳定性底线
内部知识库不是高并发业务,但也要做限流和降级。我们在网关上对每个调用方设置了QPS限制,同一IP或服务账号最多同时2个查询;模型服务端也设置了最大并发数,防止多路请求把显存占满。
更关键的是降级策略。如果模型服务超时或异常,网关不会直接报错,而是把Top-5检索片段和原文链接返回给调用方,并提示“智能答案生成失败,请参考原始文档”。这样虽然体验稍差,但核心知识检索能力还在,不至于整个系统瘫痪。这个“部分成功”的降级方案在内部工具里非常实用。
5. 我踩过的坑:常见问题与排查实录
5.1 检索不到准确内容:问题往往不在模型而在切片和权限
有段时间我们被反复反馈“搜不到”。排查后发现两个原因。第一个是切片太碎,比如一个接口的参数表格和请求示例被切到两个块,单独拿哪个块都回答不完整。后来调整了切片规则,把“接口名+参数表+请求示例”尽量放在同一块。第二个是权限过滤太严格,一些公共文档被打上了“内部保密”标签,导致普通用户查不到。权限标签归权限,检索范围不能一刀切,我们根据用户组重新映射了可见空间,才解决。
这里给一个排查技巧:把网关中间产物打印出来,看模型到底收到了哪些片段。如果片段列表里没有相关文档,那就是索引或权限问题;如果片段有相关文档但回答不对,那就是Prompt或模型问题。用这个二分法定位,比瞎调模型参数快得多。
5.2 大模型一本正经地编造不存在的字段
模型幻觉在API文档场景里非常危险。有一次它回答“refresh_token需要传grant_type=client_credentials”,但文档里根本没有这个字段描述,开发照着调了半天才发现是自己脑补的。根因是Prompt约束不够,模型看到“refresh_token”相关的片段就顺着常识补了OAuth内容。后来我在Prompt里明确加了一条:“不要补充参考片段之外任何字段或参数;如果片段不足以说明,则标注待确认。”同时建了一个回归测试集,把常见的20个高频问题答案逐条和原文比对,模型更新后先跑一遍,防止回归。
5.3 API查询超时频繁:问题出在并发排队和单次生成过长
系统刚上线时,经常出现请求超过30秒。查日志发现是多个请求同时到来,模型服务排队,本质是并发控制没有做。我们给模型服务加了队列,并限制最大并发数,同时在网关上设置20秒超时,超时后只返回检索片段。另一个原因是max_tokens设置太高,有一次输出竟然生成了2000字,耗时20多秒。压到800字后,单次时长显著下降。
5.4 权限隔离没做干净导致越权
权限隔离是这类系统的安全底线。我们第一次实现时只在Prompt里提示模型“忽略无关文档”,但模型并不真正理解权限,结果就是后端用户可能搜到财务空间的文档。正确做法是在向量库查询阶段就根据用户组映射可读空间,对片段集合做硬过滤,而不是事后让模型判断。这个过程要放在检索的核心链路里,绝对不能省。
5.5 知识库运营比模型配置更重要
所有技术做完之后,最影响体验的反而是文档本身。如果页面过期、权限混乱、内容含糊,再强的大模型也只能把错误包装得更流畅。我们现在每两周让各服务负责人核对一次核心文档,在页面上标记“已验证”或“需更新”,同步脚本只索引“已验证”的版本。这套流程走出来后,智能问答的可用率明显上升。说到底,大模型只是把文档工程做好之后的放大器,而不是替代品。
最后再分享一个小技巧:在Prompt里让模型先复述用户问题、再给答案,能有效减少答非所问的情况。比如固定输出结构为“问题复述:...;回答:...”。虽然会多几十个token,但回答质量会稳很多。我们在内部用了这个改动之后,同事们的信任度一下就上来了。你可以直接抄这个思路回去试。