我们如何构建知识库

作者:@hi_im_isaac_@learnwdaniel@gaozenghao
注:完整技术博客的交互版见: https://www.cerebras.ai/blog/how-we-built-our-knowledge-base

员工每天会向我们的内部知识库提出超过 15,000 个问题。自 3 个月前上线以来,它已经成为公司内部采用最广的工具之一。使用它的不只是人,还有自动化流程和 agent。

在 Cerebras,我们的团队横跨数据中心运维、芯片设计、硬件、训练、推理、云平台等多个领域。每年都有数百名新员工加入,公司的沟通渠道里也不断出现同样的问题:

- “我在哪里能找到 X?”
- “谁是 Y 方面的专家?”
- “Z 是什么?”

我们构建 Cerebras Knowledge,是为了把人和系统连接到有用的信息上。

让数据留在它本来所在的地方

在组织内部找信息很难。数据散落在各种工具里,而每隔一个季度左右,总会有人提出同一个“绝妙”方案:把所有东西都记录到一个平台里,让所有信息集中在同一个地方。当然,“单一事实来源”这个理想在实践中很少真正奏效。

信息会在最方便、最顺手的地方产生:文档里的建议修改、Slack 里的对话、GitHub 里的代码引用、Jira 里的状态元数据。这些平台都是为各自领域量身打造的,经过多年产品工程和数据分析优化。要是在 Google Docs 里讨论 pull request,体验会非常糟糕。

所以我们要设计一个系统,尽量不改变大家现有的行为。在数据收集这一侧,这意味着直接从每个平台抽取数据。

知识库的组成

我们的知识库提供三件事:

- 一个收集和存储内部数据的平台。
- 一个查询这些数据的平台。
- 一层用于执行认证和授权的机制,同时提供审计和分析。

核心是一张 Postgres 表,用来保存来自许多来源的 embedding、原始摘要和元数据。系统会持续摄取公司各处的数据,并维护一个随时可查询的数据存储。

我们想要一个足够简单、但能适配大多数数据形态的数据接口。我们也希望 Cerebras 的其他开发者可以构建自定义连接器。最终结果刻意保持简单:从 Slack 对话到 netlist,每一种来源都会进入同一张 embeddings 表;表里的任何内容都能立刻通过同一个接口查询:

每个数据源都会定义数据是什么、如何连接、以及多久抓取一次。无论一行 embedding 来自 Slack、代码仓库、文档系统,还是自定义数据库,它都遵循同一个接口。

我们如何处理非结构化 Slack 对话

Slack 是我们最需要重点设计的数据源。公司里最新的工程讨论大多发生在那里。

一开始,我们测试了直接对原始文本做简单 embedding 是否足够好。很快我们就意识到,只靠向量搜索不足以匹配所有相关数据。

Slack 消息有几个挑战:

- 信息密度差异极大:“hey yeah sure mike”和一段详细的 kernel 解释都是消息。
- 消息长度不一,而且在余弦相似度上,短消息常常会压过更长、更详细的消息。
- 一条消息的含义往往取决于它周围的对话。

我们需要一种混合方法。我们构建 Slack 摄取流程,让每段对话都能同时通过多种搜索技术被找回,每种技术都用来弥补其他技术的短板:

- 全文搜索能抓住那些会被 embedding 混在一起的精确 token: 错误字符串、flag 名、主机名。当工程师粘贴一条字面错误信息时,精确的词法匹配几乎总是最好的证据,再高的语义相似度也不应该排在它前面。

- Embedding 搜索能抓住改写表达。 提问的人说“restore hangs after manifest load”(manifest 加载后 restore 卡住),回答的人说“checkpoint stalls on the NFS mount”(checkpoint 在 NFS 挂载处停住),两边可能没有任何相同词汇。向量相似度把用不同措辞写出的提问和回答连接起来。(1)

- 逆文档频率把信号和填充内容区分开。 一条围绕罕见 token 写成的短消息,比如某个冷门 config flag,应该获得较高排名。“sounds good, thanks!”(听起来不错,谢谢!)在 embedding 空间里会离很多查询都很近,但一旦把词项稀有度算进去,它的得分就接近于零。

- 年龄衰减体现的是 Slack 答案会过期。 两段对话可能回答同一个问题,其中一段来自 6 个月前,描述的基础设施也许已经不存在了。当相关性在其他方面相当时,更新的对话胜出。

我们不会单独信任任何一个评分器。每种技术都会对同一个语料库产生自己的排序视图,这些视图会在查询时融合起来(见“重新排序”)。

Socket Mode

为了实时收集数据,我们在工作区里安装了一个 Slack bot,并让它以 Socket Mode 运行。Slack 会通过持久 WebSocket 把每个消息事件推送给我们,所以我们不用轮询 Web API,也不会因此耗尽它的速率限制,就能拿到实时更新。

事件到达时,我们会立刻确认收到,用稳定的 event ID 去重,并把这条消息标记给摄取消费者处理。

摄取消费者不会孤立地保存一条新消息。它会找出这条消息所属的那段对话,并从 Slack API 重新抓取完整对话,包括父消息和所有回复。然后它把整段对话作为一行写回。因此,既有对话里新增一条回复时,系统会重新拉取父消息和所有同级回复,让存储下来的内容、参与者列表和最后活跃时间始终反映完整对话。

系统里的每个 Slack 频道都有自己的数据源。这让我们可以细粒度地调整数据新鲜度。例如,某个团队可以选择更频繁地摄取繁忙的事故频道。

对话和消息

原始 Slack 文本一进入系统就可以按关键词搜索,因为我们在原始内容上维护了一套 Postgres 全文(GIN)索引。不过,为了启用有用的向量搜索,我们还会做一些额外处理。(8)

在蒸馏过程中,LLM 会从完整对话中抽取结构化数据:

- 工程师真正会拿来搜索的一行问题。
- 一段简短摘要。
- 解决方案。
- 被提到的系统和代码引用。

我们会对这些数据点做 embedding,并写入共享的 embeddings 表。原始对话记录不会被直接嵌入。在我们的实验中,把对话整理成一致格式后,准确率有显著提升。(7,9) 额外的元数据也为语义匹配提供了更有用的信号。

Bursting

到这一步,Slack 搜索已经很好了,但我们不断遇到同一个问题:很长的对话里,有些重要消息并不总是会体现在整段对话的摘要里。

为了增强单条消息带来的信号,我们使用 bursting。一个 burst 指同一作者连续发出的几条消息。我们会在单个 burst 前面加上对话主题作为上下文,再对它做 embedding(2),因为有时答案藏在某条岔开的消息里,而这条消息的词汇从来没有进入对话摘要。Burst embedding 让这条消息本身也能被找到。

为了防止低信号数据进入数据库,每个 burst 都会根据一组加权信号打分,必须超过阈值才会被嵌入:

- 它包含在整个语料库中相对罕见的 token,IDF 至少为 4.0。
- 合并后的 burst 至少有 200 个字符。
- burst 中有一条或多条消息带有表情回应,提供社交信号加成。

蒸馏之后,符合条件的 burst 会被嵌入,并与整段对话层面的记录一起存入 embeddings 表。

代码仓库

一开始,我们也讨论过是否真的有必要对代码仓库做 embedding。随着 Claude Code 和其他命令行工具兴起,在“grep 就够了”看起来成立的时候,创建代码 embedding 似乎有点反直觉。后来,我们和业界其他人交流,也读了 Cursor 关于大型代码库语义搜索的发现,决定试一试。

我们有许多内部仓库,其中一些超过 40 GB。我们最关心的问题,是如何高效地让它们保持最新。

用 @cocoindex_io 维护代码 embedding

几轮实验之后,我们选择了 CocoIndex。它是一个开源文档 embedding 框架,专门用于对代码库做向量化。

对于每个仓库,我们会使用按语言定制的正则边界来切分代码,顺序从粗到细。切分器会先尝试类这样的较高层级边界。如果得到的 chunk 仍然太大,就退回到方法边界,再退到更小的块。我们对这些 chunk 做 embedding,并把向量写入 Postgres。同一个文件可能在不同粒度上生成多条 embedding,例如文件级记录和函数级记录。

CocoIndex 会在 Postgres 中跟踪同步元数据。每次 commit 时,它只会重新嵌入并重新导出发生变化的代码 chunk,而不是重新计算整个仓库。这一点对我们尤其有效,因为同步状态和 embedding 存储都在同一个数据库里。

随着代码库数量增加,我们把仓库接入流程搬进了配置文件,让团队可以自己提交,包括文件路径级别的允许列表和拒绝列表。

自定义数据源

有些团队已经有自己的数据库,并不想只是为了接入知识库,就把数据搬进 Slack 或文档系统。他们希望能在既有表上获得同样的查询入口。

为此,我们把自定义来源当作插件脚本处理。团队可以提交一个 pull request,里面包含一个小型 Python 模块;这个模块知道如何从自己的系统读取数据,并产出形状与我们的 embeddings 表一致的行,同时配上对应的数据源条目。

只要脚本按照和其他 embedding 行相同的数据模式写入共享数据库,其余技术栈就无需改变。数据会和 Slack、代码、文档一起变得可查询,系统其他地方不需要做特殊处理。

规划和并行调用工具

对于每个查询,我们首先运行一次简短的规划步骤,让 LLM 判断哪些工具和数据源可能相关。主要工具包括:

- subsystem_index:每个文件的 LLM 摘要。
- search:跨 Slack、wiki、代码和其他已索引来源的统一向量流水线,内部会合并并重新排序。
- search_slack:直接检索 Slack。
- search_code:在源代码仓库上运行 ripgrep。
- recent_prs:与问题相关的近期 pull request。
- who_knows:在某个主题上已经展现出专长的人。

规划器会读取一份简短描述:我们已经索引了哪些项目、每个项目里有哪些来源、每个来源擅长回答什么问题。给定用户查询和当前作用域后,它会输出工具选择;执行器再并行调用这些工具,把结果整理成统一的证据格式,并传给最后负责综合的 LLM。(4)

重新排序(Reranking)

一篇文档可能只是因为和查询共享词汇,就排到很靠前的位置,哪怕它回答的是另一个问题。在重新排序之前,我们先用 reciprocal rank fusion(RRF,倒数排名融合)合并各个检索器互不兼容的结果列表。对于每篇文档,只要它出现在某个列表里,我们就加上 weight / (60 + rank);默认 weight 为 1.0,平滑常数为 60。

平滑常数让“共识”比单个强投票更重要:一篇文档如果在多个检索器里都排在前面,就能击败只在一个检索器里排名第一的文档。然后我们把重复 chunk 合并回同一个来源,限制每个文件能贡献的结果数量,最终得到更多样的前二十个结果。

我们把原始查询和这些候选项发给一个小型 reranker 模型。它会给每篇文档打 0 到 10 分,我们保留前十个。(6)

排名确定后,我们会给入选结果补回上下文。例如,如果匹配到一个 wiki 小节,我们会拉入它相邻的两个小节,这样标题、前置条件和注意事项就不会因为分块而丢失。这让读者看到的是完整片段,而不是一个缺少重要上下文的孤零零段落。

所以,search 输出的是一个信息丰富的证据包:结果来自不同检索器的融合,在来源层面去重,按真实问题重新排序,然后才补上周边上下文。

MCP

在 MCP 集成里,我们把检索构件直接暴露为工具,而不是把它们藏在一个“回答这个问题”的端点后面。这些工具刻意保持简单,并且尽可能不依赖 LLM,这样客户端就能快速、低成本地查询它们。(5)

每个 MCP 工具都对应一个底层检索基础能力,比如 search_slack、search_code、search 或 who_knows。工具的输入和输出范围很窄、结构化且稳定,因此任何客户端或 agent 都能轻松调用,不需要在工具本身再嵌入额外的编排逻辑。

大多数工具只运行一条查询流水线,比如向量搜索、词法搜索或 ripgrep,应用轻量级评分启发式,然后返回原始证据行。

Claude Code,或任何兼容 MCP 的 agent,会成为编排引擎。它决定调用哪些工具、按什么顺序调用,以及如何把结果组装成最终答案或代码修改。检索层本身在服务请求时并不依赖这些 LLM 决策。

Web UI

在 Web UI 里,同样的工具也存在,但它们连接到一条完整的查询流水线,会为每个用户问题端到端运行。UI agent 负责规划和执行步骤。

- Planner:一次轻量级 LLM 调用会检查查询和当前项目,然后选择要调用哪些检索工具,例如 search、search_slack 和 subsystem_index。

- Executor:系统把这些工具调用并行扇出,收集结果,并把它们规范化成共享的证据结构,包含分数、新鲜度和来源提示。

- Synthesis:最后一次 LLM 调用接收带类型的证据包和原始问题,然后生成 UI 中显示的答案,其中包括引用、限制说明和跨来源综合。

从用户视角看,Web UI 就是“问一个问题,得到一个答案”。在底层,它运行的是同一个“规划器 → 执行器 → 综合器”模式,MCP 客户端也可以显式地复现这套模式。

组织方式

随着语料库增长,“到处搜索所有东西”很快就不再有用了。编译器团队的工程师不希望结果里出现基础设施 runbook,反过来也一样。我们靠项目让搜索结果默认就相关。

项目和限定范围搜索

我们引入项目,作为组织查询工作空间的主要方式。一个项目是一组命名的数据源集合:与某个团队或计划相关的特定 Slack 频道、代码仓库、内部数据库和文档空间。

项目被刻意设计得很轻量。同一个数据源,比如共享的事故频道或中央平台仓库,可以被多个项目引用,而不是被复制多份。

入职引导和默认值

在入职引导期间,系统会提示用户选择或创建一个默认项目,使它匹配自己的工作内容,例如 ML 训练基础设施、Compiler 或 Data Center Operations。

这个默认项目会存储在用户资料上,并自动限定查询范围。新工程师不需要先学会哪些 Slack 频道、仓库或文档空间重要,就能得到高度相关的答案。

最后的想法

归根结底,这个知识库之所以有效,是因为它贴近信息原本所在的地方,而不是强迫所有东西进入一个僵硬系统。通过组合多种搜索技术,我们可以快速浮现证据。最终得到的搜索体验既足够灵活,能应对真实公司数据,又足够结构化,可以随着 Cerebras 继续增长而保持有用。

如果你读到这里,并且觉得这件事有意思,ai/growth 团队正在招聘;如果你感兴趣,可以联系 @learnwdaniel。

参考资料

  1. Malkov and Yashunin, Efficient and Robust Approximate Nearest Neighbor Search Using Hierarchical Navigable Small World Graphs, arXiv:1603.09320 / IEEE TPAMI 2018.
  2. Anthropic, Introducing Contextual Retrieval, 2024.
  3. Cormack, Clarke, and Büttcher, Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods, SIGIR 2009.
  4. Li et al., Search-o1: Agentic Search-Enhanced Large Reasoning Models, arXiv:2501.05366, 2025.
  5. Anthropic, Code Execution with MCP, 2025.
  6. Liu et al., Lost in the Middle: How Language Models Use Long Contexts, arXiv:2307.03172, 2023.
  7. Anthropic, Use XML Tags.
  8. Salesforce/Slack Engineering, How Slack AI Processes Billions of Messages.
  9. Improving Agents, Best Nested Data Format.
  10. Cursor, Improving Agent with Semantic Search, 2025.