跳到主要内容

企业面: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-schemaEE 的契约层:GraphQL 类型 + 所有 service trait 定义ee/tabby-schema/src/schema/
AnswerServiceAnswer 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_jobtokio 调度器:定时把仓库/文档/集成索引进库ee/tabby-webserver/src/service/background_job/mod.rs:217
AccessPolicy多租户读权限:线程归属、按 source 的组授权ee/tabby-schema/src/policy.rs:7
tabby-dbSQLite + sqlx 迁移,所有 EE 状态ee/tabby-db/src/lib.rs:76
Webserver::attach把上面这些装配并挂进主 axum serveree/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_codecollect_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 / pagedoc_query 且 source 非空
公网serper: DocSearchSerper(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:AuthenticationServiceThreadServiceRepositoryService 等,以及 聚合它们的 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 的私有对话、也检索不到 无权访问的代码库?

两层权限:

  1. 对象归属(线程/页面):AccessPolicy(ee/tabby-schema/src/policy.rs:7)持有 user_id + is_admincheck_read_thread(policy.rs:35)规则:admin 全放行;非 admin 只能读自己的已分享的(is_ephemeral=false 表示已分享)线程。删除/改持久化等各有 check_* 方法。

  2. 来源级授权(source):check_read_source(policy.rs:119)——admin 全放行;否则查 allow_read_source(user_id, source_id)默认公开:没配任何策略的 source 谁都能读; 一旦给某 source 绑定了 user_group,就变私有,只有组内成员能读(逻辑见 access_policy.rsgrant/revoke_source_id_read_access,access_policy.rs:32/:45,测试 policy.rs:220 完整演示了这个"绑组即变私有"的语义)。

这层过滤贯穿检索:ContextService::read 在列出所有来源后,用 policy.check_read_source(...) 逐个筛掉无权来源(context.rs:53-62),Answer Engine 拿到的 context_info_helper 因此天然只含可访问来源。

认证怎么做的: 登录后签发 JWT。generate_jwt(ee/tabby-webserver/src/jwt.rs:21)默认 30 分钟过期(jwt.rs:18),密钥取自 TABBY_WEBSERVER_JWT_TOKEN_SECRET(未设则进程内生成 一次性密钥,并要求是 uuid 格式,jwt.rs:41)。认证来源支持密码、OAuth (oauth/{github,google,gitlab,oidc}.rs)、LDAP(ldap.rs:11 new_ldap_client)。


4. 深入实现:后台作业调度器

后台作业是"把静态数据变成可检索知识"的引擎。它跑在一个独立 tokio 任务里,与请求处理并行。

4.1 调度器主循环

background_job::start(ee/tabby-webserver/src/service/background_job/mod.rs:217)spawn 一个 循环,用 tokio::select! 同时盯四个事件源:

tokio::select! {
job = db.get_next_job_to_execute() ─▶ 从 DB 取一个待执行作业,反序列化 command 并跑
hourly.next() ─▶ 触发一个 Hourly 作业入队
daily.next() ─▶ 触发一个 Daily 作业入队
ten_seconds.next() ─▶ 每 10s 检查是否要调度 ingestion 索引
}

作业即数据。 作业不是内存里的闭包,而是存进 SQLite 的一行:BackgroundJobEvent 枚举 (mod.rs:86)serde 成 JSON 字符串当 command 落库(to_command,mod.rs:115)。调度器 取出后 serde_json::from_str 还原再 match 分发(mod.rs:264-377)。好处:重启不丢作业、 天然串行、有 job_runs 表记录日志与状态(失败会 notify_job_error 通知 admin,mod.rs:184)。

cron 是"入队器",不是"执行器"。 @hourly/@daily 到点时只调 job_service.trigger(...) 把一个作业写进库(mod.rs:386-397),真正执行仍走上面那条"从库取 → 跑"的统一路径。 HourlyJob::run(hourly.rs:27)再 fan-out 出:DB 维护、Git 调度、集成同步、GitHub/GitLab 索引、 索引垃圾回收。

4.2 各类索引作业

作业NAME干什么源码
SchedulerGitJobscheduler_git刷新代码索引 + commit 索引git.rs:28
SchedulerGithubGitlabJobscheduler_github_gitlab索引第三方仓库的 issue/PRthird_party_integration.rs:80
SyncIntegrationJobthird_party_repository_sync从 GitHub/GitLab 拉仓库列表third_party_integration.rs:35
WebCrawlerJobweb_crawler爬文档站,索引成 docweb_crawler.rs:20
SyncPageIndexJob索引 page(知识页)index_pages.rs
SyncIngestionIndexJob索引外部 ingest 的文档index_ingestion.rs
IndexGarbageCollection清理孤儿索引index_garbage_collection.rs

Git 索引(SchedulerGitJob::run,git.rs:33):CodeIndexer::refresh 重建代码索引(复用 第 3 章的索引器),再 index_commits::refresh 索引 commit。cron(git.rs:55)把 config 文件 里的仓库和 DB 里的仓库合并后逐个入队,仓库多时还有分片逻辑(calculate_current_shard, mod.rs:56;超过 20 个仓库且设了 TABBY_INDEX_REPO_IN_SHARD 才启用,每小时轮一片)。

Web 爬取(WebCrawlerJob::run_impl,web_crawler.rs:39):先试 crawler_llmsllms-full.txt(很多文档站提供的 LLM 友好全文);抓不到再走 crawl_pipeline 常规爬取 (依赖 crates/tabby-crawler)。每篇转成 StructuredDocpresync(判断是否需更新)→ sync 索引 → commit。整个作业有 2 小时超时(CRAWLER_TIMEOUT_SECS,web_crawler.rs:17)。

4.3 持久化:SQLite + sqlx

所有 EE 状态落在一个 SQLite 文件(ee/tabby-db/src/lib.rs:76 DbConn)。要点:

  • WAL 模式减少"database is locked"(lib.rs:173),连接池 64 连接。
  • 迁移编译进二进制:sqlx::migrate!("./migrations") 在启动时 init_dbrun (lib.rs:226-227),ee/tabby-db/migrations/ 下约 50 组编号 .up/.down.sql,启动即自动升级。
  • 升级前自动备份:检测到需要迁移时先备份旧库(backup_db,lib.rs:192)。
  • 每张表一个模块(threads.rs/integrations.rs/job_runs.rs…),DAO 结构 + 查询封装; tabby-db-macros 提供派生宏减少样板。

5. 巧妙之处(可借鉴的技术)

  • 让 LLM 决定检索策略,而非硬编码。 pipeline_decide_need_codebase_context (prompt_tools.rs:35)用 few-shot 让模型选 SNIPPET/FILE_LIST——把"要不要查、查什么" 这个难判断的路由问题外包给模型,省去手写规则。

  • 流式产出即时落库。 ThreadService::create_run(thread.rs:156)在转发 Answer 流的同时, 对每种事件分别写库:ContentDelta 追加正文、attachments 更新附件、questions 落库 (thread.rs:222-264)。边流边存,断线也不丢已生成内容。

  • 作业持久化 + cron 只入队。 作业存 DB(BackgroundJobEvent,mod.rs:86),cron 只负责 trigger 入队,执行统一走"取一个跑一个"——重启不丢、天然串行、可观测。

  • 契约与实现分离到不同 crate。 tabby-schema 只有类型和 trait,tabby-webserver 才有实现; 前端只依赖 schema。改实现不惊动前端。

  • 共享底座。 Answer Engine 的检索(CodeSearch/DocSearch)和推理(ChatCompletionStream) 与补全是同一套抽象;连"公网搜索"都被塞成一个 DocSearch 实现(retrieval.rs:35)。

  • 默认公开、绑组变私有。 source 权限模型简单而实用:无策略即公开,加 user_group 即收窄 (policy.rs:119 + access_policy.rs:32)。


6. 边界与局限

  • 单机 SQLite,非分布式。 全部状态在一个 SQLite 文件(lib.rs:76),靠 WAL + 单进程作业 循环扛并发;没有水平扩展多写节点的设计。仓库很多时靠时间分片错峰(mod.rs:56),不是真正的 分布式调度。

  • 后台作业串行、粗粒度重试。 调度器一次取一个作业跑(mod.rs:242);失败只发通知给 admin (mod.rs:184),没有精细退避重试队列。

  • 检索质量依赖索引新鲜度。 回答只看已索引的内容;刚 push 的代码要等下一轮 SchedulerGitJob 才进得来。

  • 公网搜索需自带 key。 没配 SERPER_API_KEY 就没有 serper 这一路(webserver.rs:70)。

  • Embedding 是硬前提。 多数索引作业在 embeddingNone 时直接跳过并记 exit_code=-1 (如 mod.rs:277)——没有 embedding 后端,知识库能力基本瘫痪。

  • JWT 默认 30 分钟 + 一次性密钥的坑。 不设 TABBY_WEBSERVER_JWT_TOKEN_SECRET,每次重启 换密钥、所有 token 失效(jwt.rs:41),生产必须显式配置。


7. 横向对比

  • 与本项目补全侧的关系: 补全(第 2 章)是"无状态、单请求、拼 FIM prompt";Answer Engine 是"有状态对话、多阶段检索编排、流式"。二者共享检索(第 3 章)与推理(第 4 章)底座,EE 只是 在上面加编排 + 账号 + 数据管道。

  • 与其它 coding-agent 子库: 很多 agent 把"检索决策"写死成规则;Tabby 把它做成一次 LLM 调用 (§3.1),这与"用模型做路由/规划"的思路一脉相承。它的 RAG 又强调来源可访问性过滤 (§3.4),这是面向企业多租户场景的取舍——把"权限"编进检索管道,而不是事后过滤。


8. 代码地图(导航索引)

主题文件路径关键符号
EE 挂载进主 serveree/tabby-webserver/src/webserver.rsWebserver::newWebserver::attach
Answer Engine 编排ee/tabby-webserver/src/service/answer.rsAnswerService::answergenerate_relevant_questionscreate
检索决策 promptee/tabby-webserver/src/service/answer/prompt_tools.rspipeline_decide_need_codebase_contextpipeline_related_questions
检索编排ee/tabby-webserver/src/service/retrieval.rsRetrievalServicecollect_relevant_docscollect_relevant_codemerge_code_snippetsfind_repository
上下文来源聚合 + 权限过滤ee/tabby-webserver/src/service/context.rsContextServiceImpl::read
线程/消息 + 流式落库ee/tabby-webserver/src/service/thread.rsThreadServiceImpl::create_run
页面(知识页)ee/tabby-webserver/src/service/page.rsPageService
访问策略(对象/来源)ee/tabby-schema/src/policy.rsAccessPolicy::check_read_threadcheck_read_source
来源授权读写ee/tabby-webserver/src/service/access_policy.rsgrant_source_id_read_accessrevoke_source_id_read_access
认证 / JWT / LDAPee/tabby-webserver/src/jwt.rs.../service/auth.rs.../ldap.rs.../oauth/generate_jwtvalidate_jwtnew_ldap_client
GraphQL 根 + ServiceLocatoree/tabby-schema/src/schema/mod.rscreate_schemaQueryMutationSubscriptionServiceLocator
GraphQL 路由挂载ee/tabby-webserver/src/routes/mod.rscreate(/graphql/subscriptions)
后台作业调度器ee/tabby-webserver/src/service/background_job/mod.rsstartBackgroundJobEventcalculate_current_shard
Git 索引作业.../background_job/git.rsindex_commits.rsSchedulerGitJob::runSchedulerGitJob::cron
第三方集成作业.../background_job/third_party_integration.rsSyncIntegrationJobSchedulerGithubGitlabJob
Web 爬取作业.../background_job/web_crawler.rsWebCrawlerJob::run_impl
页面/ingestion 索引作业.../background_job/index_pages.rsindex_ingestion.rsSyncPageIndexJobSyncIngestionIndexJob
周期作业.../background_job/hourly.rsdaily.rsHourlyJob::runDailyJob
持久化(SQLite+sqlx)ee/tabby-db/src/lib.rsee/tabby-db/migrations/DbConn::newDbConn::init_db
派生宏ee/tabby-db-macros/(DAO 样板宏)
许可证 / 座位限制ee/tabby-webserver/src/service/license.rsee/tabby-schema/src/schema/license.rsLicenseType(Community/Team/Enterprise)