作者:@hi_im_isaac_、@learnwdaniel、@gaozenghao 注:完整技术博客的互动版请访问:https://www.cerebras.ai/blog/how-we-built-our-knowledge-base
员工每天向我们的内部知识库提问超过 15,000 次。上线 3 个月以来,它已成为公司内部最广泛使用的工具之一——被人类、自动化和智能体共同使用。
在 Cerebras,我们的团队涵盖数据中心运营、芯片设计、硬件、训练、推理、云平台等多个领域。每年有数百名新员工加入,我们的沟通渠道里总是充斥着同样的问题:
- "X 在哪里能找到?"
- "谁是 Y 的专家?"
- "Z 是什么?"
我们构建了 Cerebras Knowledge,帮助人们将人和系统与有用的信息连接起来。
数据在哪里,我们就去哪里
在组织内部查找信息很难。数据散布在各种工具中,每个季度左右总会有人提出同一个绝妙的方案:我们把所有东西都记录在一个平台上,这样所有信息就都在一个地方了。当然,单一事实来源的梦想在实践中很少行得通。
信息在方便和顺手的地方产生:文档中的建议修改、Slack 中的讨论线程、GitHub 中的代码引用、Jira 中的状态元数据。这些平台是为各自的特定领域量身定制的,经过多年的产品工程和分析优化。在 Google Docs 里讨论 PR 会是一种糟糕的体验。
因此,我们设计了一个对现有行为改动最小的系统。在数据采集方面,这意味着直接从每个平台提取数据。
知识库的构成
我们的知识库提供三样东西:
- 一个收集和存储内部数据的平台。
- 一个查询这些数据的平台。
- 一个强制执行身份验证和授权,附带审计和分析的层。
核心是一个单一的 Postgres 表,保存来自多个来源的嵌入向量、原始摘要和元数据。系统持续从公司各处摄入数据,并维护一个可随时查询的数据存储。
我们希望数据接口简单,但能处理大多数形式的数据。我们也希望 Cerebras 的其他开发者能够构建自定义连接器。最终的结果故意保持简单:每个数据源——从 Slack 线程到网表——都落入同一个嵌入表,该表中的任何内容都可以通过同一接口立即查询:
每个数据源定义了数据是什么、如何连接以及多久获取一次。每个生成的嵌入行都遵循相同的接口,无论它来自 Slack、代码仓库、文档系统还是自定义数据库。
我们如何处理非结构化的 Slack 对话
Slack 是我们需要设计的最重要的数据源。这里是全公司最新工程讨论发生的地方。
我们最初测试了直接在原始文本上做简单嵌入是否足够。很快发现仅靠向量搜索不足以匹配所有相关数据。
Slack 消息面临着几个挑战:
- 信息密度差异巨大:"嗯是的没问题麦克"和一段详细的内核解释都是消息。
- 消息长度不一,较短的消息在余弦相似度上经常胜过更长、更详细的消息。
- 一条消息的含义往往取决于周围的对话。
我们需要一种混合方法。我们构建了 Slack 摄入机制,使每个线程可以通过多种搜索技术同时检索,每种技术弥补其他技术的不足:
- 全文搜索捕获嵌入向量模糊化了的确切 token:错误字符串、标志名称、主机名。当工程师粘贴一条字面错误消息时,精确的词汇匹配几乎总是最好的证据,再多的语义相似度也不应超越它。
- 嵌入搜索捕获意译。问"manifest 加载后恢复挂起"的人和回答"检查点在 NFS 挂载上卡住"的人可能永远不会共享相同的词汇。向量相似度将用不同措辞写的问题和答案连接起来。(1)
- **逆文档频率(IDF)**将信号与填充内容分开。一条围绕稀有 token 构建的短消息——比如一个晦涩的配置标志——应该排在前面。"好的谢谢!"在嵌入空间里离许多查询都很近,但一旦考虑了词项稀有性,得分就几乎为零。
- 时间衰减编码了 Slack 答案会过期的事实。两个线程可能回答同一个问题,六个月前的那个描述的可能是不再存在的基础设施。当相关性相同时,更新的线程胜出。
没有一个单独的评分器被信任。每种技术对同一个语料库产生自己的排序视图,这些视图在查询时融合(参见重排序)。
Socket 模式
为了实时收集数据,我们在工作区安装了一个 Slack 机器人,以 Socket 模式运行。Slack 通过持久的 WebSocket 将每条消息事件推送给我们在,这样我们无需轮询 Web API 并消耗其速率限制就能获得实时更新。
当事件到达时,我们立即确认它,使用稳定的事件 ID 去重,并将消息标记给摄入消费者处理。
摄入消费者不会单独保存一条新消息。它会解析该消息所属的线程,并从 Slack API 重新获取整个对话,包括父消息和每条回复。然后将整个线程写回为一行。因此,对现有线程的回复会重新拉取父消息和所有兄弟消息,使得存储的内容、参与者列表和最后活动时间戳始终反映完整的对话。
我们系统中的每个 Slack 频道都有自己的数据源。这为数据新鲜度提供了细粒度的调优。例如,团队可以选择更频繁地摄入一个繁忙的事件频道。
线程与消息
原始 Slack 文本一旦落地即可进行关键词搜索,因为我们维护了一个在原始内容上的 Postgres 全文搜索(GIN)索引。然而,为了实现有用的向量搜索,我们还需要做一些额外的处理。(8)
在提炼过程中,LLM 从完整线程中提取结构化数据:
- 工程师实际会搜索的一行问题。
- 简短的摘要。
- 解决方案。
- 提到的系统和代码引用。
我们对这些数据点进行嵌入,并写入共享的嵌入表。原始对话记录不会被直接嵌入。在我们的实验中,当线程被规范化为一致的格式时,准确率显著提高。(7,9) 额外的元数据也为语义匹配提供了更有用的信号。
爆裂(Bursting)
到了这一步,Slack 搜索已经不错了,但我们不断遇到同一个问题:长线程中的重要消息并不总是在线程级摘要中得到体现。
为了提升单条消息的信号,我们使用了爆裂。一次爆裂是来自同一作者的连续消息序列。我们用线程主题作为前缀上下文对单个爆裂进行嵌入(2),因为有时答案存在于某条题外话消息中,而该消息的词汇永远不会出现在线程摘要里。爆裂嵌入使那条消息可以独立被找到。
为了防止低信号数据进入数据库,每个爆裂会根据加权信号组合进行评分,并必须达到阈值才会被嵌入:
- 它包含语料库中相对稀有的 token,IDF 至少为 4.0。
- 合并后的爆裂至少 200 个字符。
- 爆裂中的一条或多条消息有表情反应,提供社交提升。
经过提炼后,合格的爆裂被嵌入并存储在嵌入表中,与线程级记录一起。
代码仓库
我们最初曾争论是否有必要对代码仓库进行嵌入。随着 Claude Code 和其他命令行工具的兴起,在"grep 就是一切"的时代,创建代码嵌入感觉有些反直觉。在与其他业内人士交流并阅读了 Cursor 关于大型代码库中语义搜索的研究后,我们决定一试。
我们有许多内部仓库,有些超过 40GB。我们主要关心的是如何高效地保持它们的最新状态。
使用 @cocoindex_io 维护代码嵌入
经过几次实验后,我们选择了 CocoIndex,一个专注于向量化代码库的开源文档嵌入框架。
对于每个仓库,我们使用语言特定的正则表达式边界从粗到细地拆分代码。拆分器首先尝试更高级别的边界,比如类。如果结果块仍然太大,它会回退到方法边界,然后更小的块。我们对结果块进行嵌入并将向量写入 Postgres。单个文件可能在不同粒度级别生成多个嵌入,比如文件级和函数级记录。
CocoIndex 在 Postgres 中跟踪同步元数据。每次提交时,它只重新嵌入和导出更改过的代码块,而不是重新计算整个仓库。这对我们特别有效,因为同步状态和嵌入存储位于同一个数据库中。
随着代码库数量的增长,我们将仓库接入迁移到配置文件,团队可以自行提交,包括文件路径级别的允许列表和拒绝列表。
自定义数据源
有些团队已经有自己的数据库,不想仅仅为了参与知识库而将数据搬进 Slack 或文档系统。他们希望能在现有表上进行相同的查询。
为了支持这一点,我们将自定义源视为插件脚本。团队打开一个包含小型 Python 模块的 PR,该模块知道如何从其系统读取数据并输出与我们的嵌入表格式一致的行,再加上匹配的数据源条目。
只要脚本使用与其他嵌入行相同的模式写入共享数据库,堆栈的其余部分就可以保持不变。该数据与 Slack、代码和文档一起变得可查询,系统中其他地方无需特殊处理。
规划和工具扇出
对于每个查询,我们首先运行一个简短的规划过程,LLM 决定哪些工具和数据源可能相关。主要工具:
- subsystem_index:每个文件的 LLM 摘要。
- search:跨 Slack、Wiki、代码和其他索引来源的统一向量管道,内部合并和重排序。
- search_slack:直接的 Slack 检索。
- search_code:对源代码仓库的 ripgrep 搜索。
- recent_prs:与问题相关的最近 PR。
- who_knows:在某个主题上展示出专业知识的专家。
规划器基于我们所索引内容的简洁描述工作:存在哪些项目、每个项目中有哪些可用数据源、以及每个数据源擅长回答什么。根据用户的查询和活动范围,它输出工具选择,执行器并行扇出这些工具,规范化为通用证据格式,并传递给最终的合成 LLM。(4)
重排序
一份文档可能仅仅因为与查询共享词汇就排到前面,实际上回答的却是不同的问题。在重排序之前,我们用互惠排名融合(RRF)来合并检索器的不兼容结果列表。对于每份文档,我们在其出现的每个列表中加上 weight / (60 + rank),默认权重为 1.0,平滑常数为 60。
平滑常数使得共识比单一的强投票更重要:一份在多个检索器中都排在前面的文档,可以击败在仅一个检索器中排名第一的文档。然后我们将重复的块合并回一个来源,限制每个文件可以贡献的结果数量,最终得到一个更加多样化的前 20 名。
我们将原始查询和这些候选结果发送给一个小型的重排序模型。它给每份文档打分 0 到 10 分,我们保留前 10 名。(6)
一旦排名确定,我们将上下文加回到胜出的结果中。例如,如果我们匹配到一个 Wiki 版块,我们会拉取相邻的两个版块,这样因为分块而被分开的标题、前提条件和注意事项就不会丢失。这给读者提供的是完整的片段,而不是缺少重要上下文的孤立段落。
因此,搜索的输出是一个丰富的证据包:来自不同检索器的结果融合,在来源级别去重,根据实际问题重排序,然后再扩展周围上下文。
MCP
在 MCP 集成中,我们将检索构建块暴露为直接工具,而不是将它们隐藏在"回答这个问题"端点后面。这些工具有意保持简单,尽可能少用 LLM,以便客户端可以快速、低成本地查询它们。(5)
每个 MCP 工具对应一个底层检索原语,比如 search_slack、search_code、search 或 who_knows。工具的输入和输出是窄的、结构化的、稳定的,使得任何客户端或智能体都可以轻松调用,而无需在工具本身内部嵌入额外的编排逻辑。
大多数工具运行一个查询管道——比如向量搜索、词汇搜索或 ripgrep——应用轻量级的评分启发式,并返回原始证据行。
Claude Code 或任何兼容 MCP 的智能体成为编排引擎。它决定调用哪些工具、按什么顺序调用,以及如何将结果组合成最终的答案或代码编辑。检索层本身不依赖这些 LLM 决策来服务请求。
Web UI
在 Web UI 中,相同的工具也存在,但它们连接到一个完整的查询管道,为每个用户问题端到端运行。UI 智能体拥有规划器和执行器步骤。
- 规划器:轻量级 LLM 过程检查查询和活动项目,然后选择调用哪些检索工具,比如 search、search_slack 和 subsystem_index。
- 执行器:系统并行扇出这些工具调用,收集结果,并规范化为共享的证据模式,包含分数、时效性和来源提示。
- 合成:最终的 LLM 过程接收类型化的证据包和原始问题,然后生成 UI 中显示的答案,包括引用、注意事项和跨来源的综合。
从用户的角度来看,Web UI 就是"问一个问题,得到一个答案"。在底层,它运行着与 MCP 客户端可以显式重建相同的规划器 → 执行器 → 合成器模式。
组织
随着语料库的增长,"搜索所有地方的一切"很快就不再有用。编译器团队的工程师不希望基础设施操作手册出现在他们的结果中,反之亦然。项目是我们让搜索默认相关的方式。
项目与范围搜索
我们引入了项目作为组织查询运行工作区的主要方式。一个项目是一个命名的数据源捆绑包:与某个团队或计划相关的特定 Slack 频道、代码仓库、内部数据库和文档空间。
项目有意保持轻量级。同一个数据源——比如共享的事件频道或中央平台仓库——可以被多个项目引用,而无需重复。
入职引导与默认设置
在入职引导过程中,用户被提示选择或创建一个与他们的工作方式匹配的默认项目,比如 ML 训练基础设施、编译器或数据中心运营。
该默认项目存储在用户资料中,并自动限定查询范围。新工程师无需先了解哪些 Slack 频道、仓库或文档空间重要,就能获得高信号答案。
最后的话
归根结底,这个知识库之所以有效,是因为它在信息已经存在的地方与人相遇,而不是强迫所有东西进入一个僵化的系统。通过结合多种搜索技术,我们可以快速呈现证据。结果是一种搜索体验,它对真实公司数据保持足够的灵活性,同时又足够结构化,能在 Cerebras 持续增长的情况下保持有用。
如果你读到了这里并且对此感兴趣,AI/增长团队正在招聘。请联系 @learnwdaniel。
参考文献
- Malkov and Yashunin, Efficient and Robust Approximate Nearest Neighbor Search Using Hierarchical Navigable Small World Graphs, arXiv:1603.09320 / IEEE TPAMI 2018。
- Anthropic, Introducing Contextual Retrieval, 2024。
- Cormack, Clarke, and Büttcher, Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods, SIGIR 2009。
- Li et al., Search-o1: Agentic Search-Enhanced Large Reasoning Models, arXiv:2501.05366, 2025。
- Anthropic, Code Execution with MCP, 2025。