企业面:Answer Engine、GraphQL、后台索引任务与集成
30 秒导读: 社区版 Tabby 是一个单机的"代码补全服务器"(见 第 1 章)。 而
ee/目录下的这一整套代码,是把它升级成团队级知识引擎:多用户登录、一个能对着你整个 代码库和文档"聊天"的 Answer Engine、一层 GraphQL API 给前端用、以及一堆在后台默默把 仓库 / 文档 / GitHub 集成持续索引进库的定时任务。本章讲的就是这层"企业能力"怎么搭起来、 又怎么和补全共享同一套检索与推理底座。
1. 这是什么(零基础也能懂)
一句话定义: ee/(enterprise edition,企业版)是 Tabby 在核心补全之上加的一层应用服务,
把"给单个开发者的补全 API"变成"给整个团队的、带账号和知识库的 AI 助手平台"。
社区版 vs 企业版,先分清边界。 同一个二进制,按有没有 ee feature / 有没有 license 决定挂不挂这层:
| 维度 | 社区版(core) | 企业版(ee) |
|---|---|---|
| 主打能力 | 代码补全(FIM) | 补全 + Answer Engine 对话 + 页面/知识库 |
| 用户模型 | 无账号,单机 | 多用户、多租户、用户组、访问策略 |
| 对外接口 | REST(/v1/completions 等) | REST + GraphQL(/graphql、/subscriptions) |
| 数据 | 无状态 / 索引文件 | SQLite 库(用户、线程、集成、作业…) |
| 知识来源 | 当前请求带的上下文 | 后台持续索引的仓库、文档站、GitHub/GitLab issue/PR |
给谁用 / 解决什么问题: 假设你们团队有几十个私有仓库和一堆内部文档,你想要一个"懂你们代码" 的 ChatGPT——问它"我们的鉴权是怎么做的",它能去检索你们真实的代码和文档、给出带引用的回答, 还能顺手补全代码。这就是 EE 层要做的事。
它能做什么(功能):
- Answer Engine:带检索的对话(RAG),自动决定要不要翻代码库、生成追问。
- 知识库:爬取文档站、索引 GitHub/GitLab 的 issue/pull/commit,变成可检索来源。
- 多租户与权限:登录(密码 / OAuth / LDAP)、用户组、按来源(source)的读权限策略。
- 后台作业:定时把仓库、文档、集成同步并重建索引。
- GraphQL API:给前端 Web UI 用的全部读写接口与订阅(streaming 回答)。
用起来什么样: 前端发一个 GraphQL subscription(因为回答是流式的),服务端一边生成一边推:
# 示意:发起一次带检索的对话,服务端流式返回 assistant 消息
subscription {
createThreadAndRun(input: {
thread: { userMessage: { content: "我们的 JWT 鉴权在哪实现的?" } }
options: {
docQuery: { content: "JWT 鉴权", sourceIds: ["git:xxx"], searchPublic: false }
codeQuery: { content: "jwt auth", sourceId: "git:xxx" }
generateRelevantQuestions: true
}
}) {
__typename # 依次收到:ThreadCreated / ...ReadingCode / ...AttachmentsCode
# / ...AttachmentsDoc / RelevantQuestions / ...ContentDelta(逐字)...
}
}
一句话直觉: 把补全那套"检索增强"的机器,接到一个对话循 环上,再包一层账号、权限和 "后台不停灌数据"的管道——补全是"帮你写下一行",Answer Engine 是"帮你查明白整个库"。
本章只讲上层编排与企业能力。底层检索实现(tree-sitter 切片、Tantivy、RRF 混合检索)见
第 3 章;推理后端(ChatCompletionStream 抽象)见
第 4 章。
2. 顶层全景(它大概怎么转)
EE 层由三个 crate + 一个挂载点组成。先看结构图(从上到下是"接口 → 编排 → 数据/引擎"):
前端 Web UI (tabby-ui)
│ GraphQL (query / mutation / subscription)
┌─────────────────────────┼─────────────────────────────────┐
│ ee/tabby-schema (EE 契约层) │
│ Query / Mutation / Subscription + 所有 *Service trait │
└─────────────────────────┬─────────────────────────────────┘
│ 实现
┌─────────────────────────┴─────────────────────────────────┐
│ ee/tabby-webserver/src/service (业务实现) │
│ │
│ AnswerService ──uses──> RetrievalService ──> code/doc/serper
│ ThreadService ──uses──> AnswerService │
│ AuthService / AccessPolicy / Integration / Page ... │
│ background_job::start (调度器,tokio 循环) │
└──────┬─ ─────────────────────────────────┬─────────────────┘
│ │
ee/tabby-db (SQLite+sqlx) 共享底座(core,见第3/4章)
用户/线程/集成/作业/页面 … CodeSearch / DocSearch / ChatCompletionStream
怎么读这张图: 上面是"契约"(schema),中间是"实现"(service),下面是"存储 + 复用的底座"。
Answer Engine 和补全共享最下面一层:同一个 CodeSearch/DocSearch 检索、同一个
ChatCompletionStream 推理。EE 只是在上面加了对话编排、账号和数据管道。
部件一句话职责:
| 部件 | 干什么 | 在哪 |
|---|---|---|
tabby-schema | EE 的契约层:GraphQL 类型 + 所有 service trait 定义 | ee/tabby-schema/src/schema/ |
AnswerService | Answer Engine 的编排:检索 → 追问 → 调 LLM 流式回答 | ee/tabby-webserver/src/service/answer.rs:39 |
RetrievalService | 聚合 code/doc/serper 三路检索 + 权限过滤 | ee/tabby-webserver/src/service/retrieval.rs:32 |
ThreadService | 线程/消息的持久化,把 Answer 的流式结果落库 | ee/tabby-webserver/src/service/thread.rs:118 |
background_job | tokio 调度器:定时把仓库/文档/集成索引进库 | ee/tabby-webserver/src/service/background_job/mod.rs:217 |
AccessPolicy | 多租户读权限:线程归属、按 source 的组授权 | ee/tabby-schema/src/policy.rs:7 |
tabby-db | SQLite + sqlx 迁移,所有 EE 状态 | ee/tabby-db/src/lib.rs:76 |
Webserver::attach | 把上面这些装配并挂进主 axum server | ee/tabby-webserver/src/webserver.rs:59 |
主线走一遍(高层): 前端发 createThreadRun subscription → Subscription
(schema/mod.rs:1883)鉴权后调 ThreadService::create_run → 后者调
AnswerService::answer 做检索+生成 → 每产出一个片段就顺手落库并 yield 给前端流。
与此同时,后台 background_job 循环在另一个 tokio 任务里,不停把新数据索引好,供检索使用。
3. 核心原理
3.1 Answer Engine:一次 RAG 回答的编排流程
它要解决的小问题: 用户问一句话,怎么在回答前决定去查什么、查回来怎么拼进 prompt, 并且全程流式返回?
思路: AnswerService::answer 是一个 async_stream,按固定的 4 个阶段推进,每个阶段
都可能往前端 yield 一个事件(前端据此渲染"正在读代码""找到 N 篇文档"等)。
阶段图(从上到下顺序执行,虚线是"按需"):
用户最后一条消息 + ThreadRunOptionsInput
│
├─(1) 有 code_query?─▶ find_repository ─▶ pipeline_decide_need_codebase_context (问 LLM)
│ └─ 需要 snippet? ─▶ collect_relevant_code ┐
│ └─ 需要 file_list?─▶ collect_file_list(≤300) ┤─▶ 填入 attachment.code / code_file_list
│
├─(2) 有 doc_query? ─▶ collect_relevant_docs(过滤可访问 source)─▶ attachment.doc
│
├─(3) generate_relevant_questions? ─▶ pipeline_related_questions(问 LLM)─▶ yield 追问
│
└─(4) 组装 chat_messages(system + 历史 + 带 attachment 的用户消息)
└─▶ chat.chat_stream ─▶ 逐 chunk yield ContentDelta ─▶ 完成事件
关键设计:让 LLM 自己决定要不要检索代码。 这一步是 Answer Engine 的精华。它不盲目检索, 而是先用一个小 prompt 问模型"回答这个问题需要哪种上下文":
// ee/tabby-webserver/src/service/answer/prompt_tools.rs:35 pipeline_decide_need_codebase_context
// prompt 里给了几个 few-shot 例子,让模型只回 SNIPPET / FILE_LIST(可组合)
"How to implement an embedding api?" -> SNIPPET
"How many python files is in the codebase?" -> FILE_LIST
"What does this repository do?" -> FILE_LIST
返回的字符串被 detect_content 解析成两个 bool(snippet / file_list),据此决定走哪条检索。
真实编排见 answer.rs:93-133:先 find_repository,再按决策分别调
collect_relevant_code 或 collect_file_list(最多列 300 个文件,answer.rs:99)。
追问生成(related questions): 若 generate_relevant_questions 为真,且检索到了东西,
就把 code/doc 拼成上下文,再问一次 LLM 生成"三个后续问题"
(pipeline_related_questions,prompt_tools.rs:11;调用点 answer.rs:286 generate_relevant_questions)。
最后组 prompt 调 LLM。 系统提示 + 历史消息 + 把 attachment 塞进用户消息,构造
CreateChatCompletionRequestArgs,然后 chat.chat_stream(answer.rs:194-222)——这里的
chat 就是第 4 章那个可插拔的 ChatCompletionStream,和补全共享推理后端。
3.2 检索编排:三路来源 + 访问策略过滤
它要解决的小问题: "相关上下文"可能来自私有代码库、内部文档、也可能来自公网搜索—— 怎么把它们聚合起来,又不让用户看到无权访问的来源?
三路来源(RetrievalService,retrieval.rs:32):
| 来源 | 字段 | 来自 | 触发条件 |
|---|---|---|---|
| 代码 | code: CodeSearch | 第 3 章的 Tantivy 混合检索 | 有 code_query |
| 文档 | doc: DocSearch | 索引进库的文档站 / issue / PR / page | 有 doc_query 且 source 非空 |
| 公网 | serper: DocSearch | Serper(Google 搜索 API) | search_public=true 且配了 SERPER_API_KEY |
collect_relevant_docs(retrieval.rs:164)先做权限过滤再检索:
source_ids.retain(|x| helper.can_access_source_id(x))(retrieval.rs:172),然后 tantivy
检索(retrieval.rs:180-204),再按需追加 serper 结果(retrieval.rs:207-216)。
巧妙细节:小文件整篇塞进去。 merge_code_snippets(retrieval.rs:262)有一条规则:同一文件命中
多个片段、且文件 < 300 行时,直接把整份文件作为一个 hit(retrieval.rs:279),并把多片段的
分数取平均、start_line 置 空。直觉:小文件与其给零碎片段,不如给全貌,LLM 理解更完整。
Serper 是什么? "Serper(一个把 Google 搜索结果封装成 API 的服务)"。它被当成又一个
DocSearch 实现注入,所以"公网搜索"在检索层和"内部文档"走同一个接口——这就是共享底座的好处。
3.3 GraphQL 契约层:tabby-schema 为什么单独一个 crate
它要解决的小问题: 前端要一份稳定、类型安全的 API;后端有一堆 service 实现。怎么解耦?
思路: tabby-schema 只放类型定义与 trait,不放实现。它是 EE 的"契约层":
- GraphQL 根:
Query/Mutation/Subscription三棵树,create_schema()组装 (ee/tabby-schema/src/schema/mod.rs:1971)。 - service trait:
AuthenticationService、ThreadService、RepositoryService等,以及 聚合它们的ServiceLocator(schema/mod.rs:102)——Context通过它拿到任意 service。 - 子模块按领域切:
thread/answer/repository/integration/auth/analytic… 每个文件一组 juniper#[graphql_object]类型 + 对应 trait。
为什么对话用 Subscription 而不是 Query? 因为回答是流式的。create_thread_and_run
(schema/mod.rs:1860)返回 ThreadRunStream,底层就是 §3.1 那个 async_stream。
鉴权在这层做:check_user_allow_auth_token(ctx)(schema/mod.rs:1864)。
tabby-webserver 依赖 tabby-schema 并实现这些 trait;前端只认 schema。上游改实现不影响契约,
改契约才需要前后端一起动——这就是把它拆成独立 crate 的意义。
3.4 多租户与访问策略
它要解决的小问题: 多个用户共用一台服务器,怎么保证 A 看不到 B 的私有对话、也检索不到 无权访问的代码库?
两层权限:
-
对象归属(线程/页面):
AccessPolicy(ee/tabby-schema/src/policy.rs:7)持有user_id+is_admin。check_read_thread(policy.rs:35)规则:admin 全放行;非 admin 只能读自己的或已分享的(is_ephemeral=false表示已分享)线程。删除/改持久化等各有check_*方法。 -
来源级授权(source):
check_read_source(policy.rs:119)——admin 全放行;否则查allow_read_source(user_id, source_id)。默认公开:没配任何策略的 source 谁都能读; 一旦给某 source 绑定了 user_group,就变私有,只有组内成员能读(逻辑见access_policy.rs的grant/revoke_source_id_read_access,access_policy.rs:32/:45,测试policy.rs:220完整演示了这个"