跳到主要内容

数据截至 (上游 commit 3dcf4cad0124)

记性:线程持久化、分支、上下文压缩与 RAG

30 秒导读: 上一章讲模型怎么跑(04),这一章讲它记得住什么。 对话原文要落盘、要能长出分支、要在有限窗口里活下来;文档知识则根本不进窗口, 而是躺在本地向量库里、等模型开口要才取。


1. 这一章讲什么(零基础也能懂)

一句话定义: 这一章讲 Jan 的存储与上下文治理——聊天记录存在哪、怎么改一条回答而不丢原来那条、 消息多到装不下时丢谁、以及大文档怎么在不撑爆窗口的前提下被用上。

为什么这是个问题。 模型本身没有记性。它每一轮看到的,只是你这次塞给它的那一叠消息。 所以"记性"从来不是模型的能力,而是外面这层应用的工程活:

要解决的事白话Jan 的答案
关掉 app 明天还在对话得落到磁盘每线程一个目录 + JSONL 追加日志(移动端换 SQLite)
点"重新生成"别把旧答案冲掉同一个问题要能存多份回答消息 metadata 里挂父指针,兄弟节点即多版本
聊久了塞不进窗口得丢掉或压缩老消息先估 token,裁最旧;开关打开才调模型压成摘要
一本 300 页的 PDF全塞进去必炸切块 → 向量化 → 存本地库 → 模型自己调工具捞

一句话直觉: 把模型的上下文窗口当内存,把线程目录和向量库当磁盘。 这一章讲的就是这套"虚拟内存"——什么时候换入、什么时候换出、换出的东西去哪了。

用起来什么样。 用户视角只有三个动作:正常聊天(自动落盘)、点回答下面的 < 2/3 > 切换版本(分支)、往输入框拖一个 PDF(RAG 入库)。底下这三条链路互不相干,正是这一章要拆的三条主线。


2. 顶层全景(四层记性,各管一段)

Jan 的"记性"不是一个模块,是四层各自独立的机制。它们唯一的交汇点是 CustomChatTransport 组装请求的那一刻(见 02)。

怎么读这张图:从上到下是数据离窗口越来越远——越往下越"冷",越需要显式动作才能捞回窗口。

┌──────────────────────────────────────────┐
│ ① 窗口内:这轮真正发给模型的消息数组 │
│ 由 context-manager 裁剪/压缩后产出 │
└───────────────┬──────────────────────────┘
│ 读取「活跃路径」
┌───────────────┴──────────────────────────┐
│ ② 内存态:useMessages(zustand store) │
│ 整棵消息树都在,含所有历史版本 │
└───────────────┬──────────────────────────┘
│ 异步持久化
┌───────────────┴──────────────────────────┐
│ ③ 磁盘:线程目录 JSONL / 移动端 SQLite │
│ thread.json + messages.jsonl │
└──────────────────────────────────────────┘

┌──────────────────────────────────────────┐
│ ④ 旁路:向量库(每线程一个 .db 文件) │
│ 只在模型调 retrieve 工具时才回到 ① │
└──────────────────────────────────────────┘

部件一句话职责:

部件干什么在哪个文件
Rust threads 命令线程/消息的增删改查,桌面走文件、移动走 SQLitesrc-tauri/src/core/threads/commands.rs
SQLite 后端移动端两张表 + 连接池src-tauri/src/core/threads/db.rs
文件后端JSONL 读写 + 每线程异步锁src-tauri/src/core/threads/helpers.rs
分支代数parentId/activeChildId 的树运算web-app/src/lib/message-branching.ts
消息 store乐观更新 + 异步落盘web-app/src/hooks/useMessages.ts
上下文管家估算 token、裁剪、压缩web-app/src/lib/context-manager.ts
RAG 扩展三个内置工具 + 入库切块extensions/rag-extension/src/index.ts
向量库插件sqlite-vec 虚表 / 暴力兜底src-tauri/plugins/tauri-plugin-vector-db/src/db.rs

主线走一遍(不进代码): 用户发一句话 → 前端立刻把消息塞进 store 并异步写盘 → transport 从 store 取活跃路径、按预算裁剪 → 发给模型 → 模型若想查资料就调 retrieve → 向量库返回片段 → 模型引用它作答 → 回答写回 store,若是"重新生成"则挂成兄弟节点。


3. 落盘:一条消息怎么变成磁盘上的一行

3.1 两套后端,一个编译期开关

Jan 没有统一存储层,而是按平台分叉:桌面走文件系统,移动端走 SQLite。分叉点只有一个函数:

pub fn should_use_sqlite() -> bool {
cfg!(any(target_os = "android", target_os = "ios"))
}

src-tauri/src/core/threads/helpers.rs:17 should_use_sqlite —— cfg!编译期常量, 所以桌面构建里 SQLite 分支整个被优化掉;db.rs 本身也带 #[cfg(any(target_os = "android", target_os = "ios"))] 条件编译(src-tauri/src/core/threads/mod.rs:15-16)。

每个 #[tauri::command] 的头几行都是同一个模式,以 list_threads 为例 (src-tauri/src/core/threads/commands.rs:24-31):先问 should_use_sqlite(),是就转 db::db_list_threads, 否则往下走文件路径。commands.rs 里 14 个命令全部照抄这个骨架。

诚实说明: 克隆里没有"桌面 JSONL → SQLite"的迁移代码。两套后端是平台并列关系, 不是新旧关系——同一台设备不会从一种切到另一种。下文 3.4 讲的"旧格式"指的是这套 桌面 JSON/JSONL 布局本身。

3.2 桌面:目录 + 追加日志

桌面的磁盘布局由三个常量定死(src-tauri/src/core/threads/constants.rs:2-4):

常量含义
THREADS_DIRthreads数据根下的线程总目录
THREADS_FILEthread.json单个线程的元数据(标题、模型、assistants)
MESSAGES_FILEmessages.jsonl该线程的消息,一行一条

拼出来是这样(路径函数在 src-tauri/src/core/threads/utils.rs:10-20,get_thread_dir / get_messages_path):

<jan 数据目录>/
└── threads/
├── 4f3a…-uuid/
│ ├── thread.json ← 整个覆盖写
│ └── messages.jsonl ← 新消息 append 一行
└── 9b21…-uuid/
└── …

为什么用 JSONL 而不是一个大 JSON 数组? 因为新增消息是最频繁的操作, JSONL 让它退化成一次 append——不必读全量、不必反序列化整棵历史。 create_message 正是这么干的(src-tauri/src/core/threads/commands.rs:204-214): OpenOptions::new().create(true).append(true),写一行 writeln!,然后显式 flush()

代价在改和删:modify_messagedelete_message 都得读全量、改内存、整文件重写 (write_messages_to_file,src-tauri/src/core/threads/helpers.rs:34-44,用 File::create 截断重写)。 这是典型的"append 便宜、update 昂贵"取舍。

并发怎么保证。 追加日志最怕两个写者交叉。Jan 的解法是一张全局的"线程 id → 锁"表:

pub static MESSAGE_LOCKS: OnceLock<Mutex<HashMap<String, Arc<Mutex<()>>>>> = OnceLock::new();

src-tauri/src/core/threads/helpers.rs:14 MESSAGE_LOCKSget_lock_for_thread (同文件 :22-31)取出该线程的锁后drop(locks) 释放外层 map 锁再返回内层锁—— 避免"拿着全局表锁去等某个线程的文件锁"这种放大阻塞。粒度是每线程,不同线程互不干扰。

3.3 移动端:两张表,data 列塞整个 JSON

init_database(src-tauri/src/core/threads/db.rs:27)做四件事:建目录、 SqliteConnectOptions::create_if_missing(true)、开 5 连接的池、跑内联 DDL。

两张表的 schema 值得看,因为它几乎没有 schema:

说明
threadsid PK / data TEXT / created_at / updated_atdata 是整个 thread 对象的 JSON 字符串
messagesid PK / thread_id FK / data TEXT / created_at同样把消息整体塞进 data

DDL 见 db.rs:57-84;外键带 ON DELETE CASCADE,所以 db_delete_thread(:187)只删线程行, 消息由数据库连带清掉(:193 注释明说)。另有两个索引 idx_messages_thread_id / idx_messages_created_at(:87-95)。

这是有意的"JSON 列"设计: 前端的 Thread / ThreadMessage 类型演进很快, 把它整体当 blob 存,schema 就不用跟着改;代价是除了 id/thread_id/时间戳,什么都查不了db_list_threads(:119)就是 SELECT data FROM threads ORDER BY updated_at DESC 再逐行 serde_json::from_str。连"取线程的 assistant"都得把整个 thread 反序列化出来再取数组第一项—— db_get_thread_assistant(:311-335)就是这么做的。

3.4 create 与 modify 的竞态:两边同时上保险

这是本节最不显然的一处设计。前端的 useMessages乐观更新的:先改内存,再异步落盘 (web-app/src/hooks/useMessages.ts:34-58)。而 addMessagecreateMessageupdateMessagemodifyMessage(:77),两个异步调用没有顺序保证—— 流式回答的 update 完全可能跑在 create 前面落地。

Jan 的处理是两个方向都做成幂等:

方向桌面实现移动实现
create 晚到先读全量,发现同 id 已存在就直接返回(commands.rs:193-202)INSERT OR IGNORE(db.rs:248)
modify 早到找不到 id 就 push 进去当新消息(commands.rs:259-262)ON CONFLICT(id) DO UPDATE SET data = excluded.data(db.rs:279-282)

源码注释把意图写得很直白:db.rs:247 "Skip if a modify_message upsert already landed for this id."、 commands.rs:257-258 "Upsert when create_message lost the lock race"。 要带走的模式: 当上层做乐观更新、下层是异步命令时,别去排序,直接把两条写路径都做成 upsert。


4. 分支与版本:一棵藏在 metadata 里的父指针树

4.1 它要解决的小问题

你对一个回答不满意,点"重新生成"。天真做法是覆盖——但那样旧回答就没了,用户想比较也比较不了。 Jan 的做法是:新回答不是替换,是同一个提问下的第二个孩子。UI 上就是回答底下那个 < 2/3 > 翻页控件。

4.2 数据模型:不加表,只加两个 metadata 键

整套分支能力没有动存储 schema,全靠消息 metadata 里的两个键 (web-app/src/lib/message-branching.ts:10-21 的文件头注释是最好的规格说明):

类型含义
parentIdstring指向前一条消息
parentIdnull显式的根节点
parentId缺失(undefined)旧线程,尚未分支化
activeChildIdstring该节点当前展示哪个孩子

三态的 parentId 是关键rawParent(:22-26)刻意区分 null(我是根) 和 undefined(我是遗留数据);对外的 getParentId(:28)把两者都收敛成 null, 而内部运算一律用 rawParent。这样 getSiblings(:48-56)才能对遗留消息直接返回 [m]—— 没有分支元数据的消息不参与版本运算,老线程于是天然表现为一条直线。

4.3 图:一次"重新生成"发生了什么

怎么读:竖线是父子,横排是兄弟;粗箭头 ==>activeChildId 指向的活跃分支。

U1 "帮我写个正则" (parentId: null)

├── A1 第一次回答 (parentId: U1)

╘══> A2 重新生成的回答 (parentId: U1) ← U1.activeChildId = A2

╘══> U2 "再简化点" (parentId: A2)

渲染出来的对话 = U1 → A2 → U2
A1 没被删,切到 < 1/2 > 就回来了

computeActivePath(:90-112)就是在走这条粗线:从活跃根出发,每步用 pickActiveChild 选下一跳, 用 Set 防环。pickActiveChild(:75-84)的兜底很重要——activeChildId 失效或缺失时, 取按 created_at 排序的最后一个孩子,也就是"最新的那次生成"。这让新回答不必显式写 activeChildId 也能默认可见。

4.4 三段真实链路

(a) 遗留线程的补链(backfill)。 老线程一条元数据都没有,第一次要分叉时才补:

export const backfillParentIds = (path: ThreadMessage[]): ThreadMessage[] => {
if (path.some((m) => meta(m).parentId !== undefined)) return []
return path.map((m, i) => withParentId(m, i === 0 ? null : path[i - 1].id))
}

web-app/src/lib/message-branching.ts:142-145 backfillParentIds —— 已经分支化就返回空数组(幂等), 否则把线性路径串成 null → m0 → m1 → …。调用方是 ensureBranched (web-app/src/routes/threads/$threadId.tsx:1061-1067),它在每次 regenerate / edit 之前跑一遍。 这是懒迁移:不分叉的用户永远不会被写入这些字段。

(b) 重新生成 → 兄弟节点。 handleRegenerate($threadId.tsx:1150-1179)本身不建消息, 它只做两件事:ensureBranched(),然后把新回答应该挂在谁下面记进一个 ref:

handleRegenerate(messageId)
├─ ensureBranched() 补链
├─ pendingAssistantParentId.current =
│ resolveAssistantParent(messageId) 回溯到最近的 user 消息
└─ regenerate() 交给 AI SDK 发请求
↓ 流式结束
onFinish 里读走这个 ref → 写进新消息的 metadata.parentId

resolveAssistantParent(:1124-1146)从活跃路径上往回找最近的一条 user 消息—— 因为"回答的父节点"在语义上永远是提问。落地在 onFinish::316-317 取出并清空 ref, :329-332 把它写进 metadata.parentId,:358-363 再回头把父节点的 activeChildId 指向新回答。 :340-353 还有一条防御分支:如果这个 id 的消息已存在(onFinish 重入),保留原有的 parentId,别把链改坏。

(c) 编辑 → fork。 handleEditMessage(:1183-1217)用 makeSibling (message-branching.ts:236-256)造一个同父、新 id、新时间戳的副本;注意它显式 delete sourceMeta.activeChildIddelete sourceMeta.error——新版本不该继承旧版本的孩子指针和错误标记。 然后按角色分流:编辑 user 消息会顺带触发 regenerate,编辑 assistant 消息不会(:1202-1208)。

4.5 活跃根存在线程上,不在消息上

树根没有父节点,activeChildId 挂不上去。所以"当前展示哪个根"被存进了线程的 metadata: setActiveBranch($threadId.tsx:1070-1090)发现 getParentId(node) 为空时, 改的是 useThreads 里的 metadata.activeRootId。加载线程时再读回来喂给 computeActivePath(:705-712)。


5. 上下文预算:估、裁、压

5.1 它要解决的小问题

窗口是硬上限。消息累积到超限,请求会直接失败或被后端截断。所以发请求之前必须先算一笔账: 系统提示占多少、要留多少给输出、剩下多少能装历史。

5.2 第一步:把 token 数猜出来

Jan 不跑真 tokenizer,用一个常数除法:

const CHARS_PER_TOKEN = 3.5
export function estimateTokens(text: string): number {
if (!text) return 0
return Math.ceil(text.length / CHARS_PER_TOKEN)
}

web-app/src/lib/context-manager.ts:11-16。上面那段注释说"平均 1 token ≈ 4 字符", 但代码用的是 3.5——故意偏小的除数会算出偏多的 token,也就是故意高估,留安全边际。

estimateMessageTokens(:42-46)在此之上再加 + 4,算角色标记和格式开销。 它统计的原文由 messageToText(:18-40)拼出,三类内容都算进去:

  • 纯文本 part;
  • 工具调用 part(dynamic-tooltool- 前缀)——整个 JSON.stringify(part) 计入,因为工具参数和结果是真占窗口的;
  • metadata.inline_file_contents 里内联进来的附件正文(见 §9)。

5.3 第二步:裁(trim)——最旧优先淘汰

预算 = maxContextTokens − maxOutputTokens − systemPromptTokens

[最旧] m0 m1 m2 m3 m4 m5 [最新]
←──────────── 从最新往回累加
✗ 到这里超预算,停
丢弃 ──┘ └── 保留

trimMessages(context-manager.ts:69-113)从数组尾部往前走,kept.unshift(message) 保持原顺序。两条护栏:

  1. 预算 ≤ 0 时直接 messages.slice(-1),只留最后一条(:81-83);
  2. 循环的中断条件带 && kept.length > 0(:97),且循环后还有一次兜底 if (kept.length === 0) kept.push(最后一条)(:105-107)——保证最新那条消息哪怕单条超预算也不会被丢

5.4 第三步:压(compact)——让模型自己写摘要

裁剪会直接丢信息。compactMessages(:124-217)多做一步:把将被丢掉的部分喂给模型总结

流程:

compactMessages
├─ 先跑一遍 trimMessages,问出「哪些会被丢」
├─ trimmedCount == 0 ? → 原样返回,不花钱
├─ 把被丢的消息拼成 "role: text" 文本
├─ 文本为空 → 返回纯裁剪结果
├─ 截断摘要输入(见下)
├─ generateText(...) 生成摘要
│ ├─ 成功 → 造一条 role:'system' 的摘要消息,放到最前,再 trim 一次
│ └─ 抛错 → console.warn,回落到纯裁剪结果
└─ 返回

三个细节值得点名:

  • 摘要调用自己也会超窗。 所以摘要输入被截到 Math.floor(summaryBudgetTokens * CHARS_PER_TOKEN) 个字符,且取的是 slice(-maxExcerptChars) ——保留尾部,因为离当前更近的对话更相关(:168-178)。
  • 摘要以 role: 'system' 注入(:190-199),注释解释了原因:当成 user 轮会打乱轮次交替逻辑。
  • 注入完要再 trim 一次。 摘要本身占 token,merged = [summaryMessage, ...trimResult.messages] 之后再跑一遍 trimMessages(:205-206),返回的 trimmedCount 是两次之和。
  • 失败必须能降级。 catch 里只 console.warn 然后返回纯裁剪结果(:213-215)—— 模型没起来、网络断了,都不该让整轮对话失败。

5.5 触发点:两个推理参数

这套东西在 custom-chat-transport.ts 组装消息时被调用,开关是两个模型参数:

参数类型效果
max_context_tokensnumber> 0 才启用整套预算逻辑;为 0/缺失则完全不裁
auto_compactboolean 或字符串 'true'为真且 this.model 存在时走 compact,否则走 trim

读取在 web-app/src/lib/custom-chat-transport.ts:1249-1255(注意 auto_compact 同时接受 布尔和字符串 'true'),分流在 :1027-1065maxOutputTokens 缺省按 2048 算(:1030), systemPromptTokensestimateTokens(effectiveSystem) + 4(:1034-1036)。

裁完还有一道闸:hasGenuineUserQuery(定义在同文件 :465,调用在 :1070-1074)。 如果淘汰或删除之后窗口里已经没有真正的用户提问,直接抛一个人话错误, 而不是让 Qwen 这类模板在后端吐一句看不懂的 Jinja 报错。


6. RAG 工具面:三个内置工具,和它们的 schema 心机

6.1 定位:RAG 在 Jan 里是"工具",不是"预处理"

一种常见做法是自动检索:用户一发问就先检索一遍、把片段拼进 prompt,模型无从选择。Jan 不是。 RAG 扩展实现的是 getTools() / callTool()(extensions/rag-extension/src/index.ts:101-127), 挂在一个虚拟 server 名 RAG_INTERNAL_SERVER = 'rag-internal' 下 (core/src/browser/extensions/rag.ts:18),走的是和真 MCP 工具同一条路由(见 03)。 要不要检索、检索什么,由模型自己决定。

6.2 三个工具

定义在 extensions/rag-extension/src/tools.ts:8-79getRAGTools:

工具必填参数干什么设计意图
list_attachments列出本线程/项目已索引的文件让模型先"看见有哪些资料"再决定查谁
retrievequery语义检索,返回带分数的片段主力入口
get_chunksfile_id, start_order, end_order按序号区间取原文块逃生舱,描述里明写 "Use sparingly"

为什么 retrieve 只收 query,不收原文? 描述里那句话是刻意的: 'Use query only; do not pass raw document content.'(tools.ts:30)。 理由很硬——若允许传原文,模型就会把文档内容复述进工具参数,那段参数本身要进上下文、要计费、 还会被 estimateMessageTokens 当成占用(§5.2)。RAG 的全部意义就是不让原文进窗口, 传原文等于把这个收益退掉。required: ['query'](:45)也只锁这一个。

top_k 的上界是动态的。 getRAGTools(retrievalLimit) 开头有一句 const maxTopK = Math.max(1, Number(retrievalLimit ?? 3))(:9), 然后把它写进 schema 的 maximum(:35)。也就是用户在设置里调的检索上限, 会变成模型 schema 里的硬约束,而不是事后在代码里截断——约束前移到 schema,模型就不会浪费一次错误调用。

为什么 get_chunks 要写 "Use sparingly"? 它按序号取原文、不看相关性, 连着取一段就等于把整章塞进窗口。描述末尾直接给了替代方案: 'Prefer using retrieve instead for relevance-based fetching.'(:52)。 这是用工具描述做行为约束——比在代码里限流更早生效。

6.3 上下文参数是注入的,不是模型给的

注意三个 schema 里都没有 thread_id / project_id / scope,但 callTool 的实现里 到处在读它们(index.ts:131-132:188-195)。这是因为它们由服务层在调用时补进去:

if (args.scope === 'thread' && !a.thread_id) a.thread_id = args.threadId
if (args.scope === 'project' && !a.project_id) {
a.project_id = args.projectId
a.thread_id = args.projectId
}
a.scope = args.scope

web-app/src/services/rag/default.ts:45-52 DefaultRAGService.callTool好处是模型编不出别的线程 id——检索范围由宿主强制,不受模型输出影响。

6.4 入库:切块参数与嵌入来源

ingestAttachments(index.ts:418-483,项目级是 ingestAttachmentsForProject,:360-416)逐个文件处理:

  1. 特性开关 enabled === false → 直接返回空结果(:431-433);
  2. 超过 maxFileSizeMB 抛错(:453-457);
  3. 调向量库扩展的 ingestFile,传切块参数 { chunkSize: chunkSize ?? 512, chunkOverlap: chunkOverlap ?? 64 }(:468-472)。

512 字符块 / 64 字符重叠是默认值(定义在 index.ts:21-22chunkSizeChars / overlapChars)。 注意单位是字符不是 token——configure() 里还留了对老配置键的回落: 先读 chunk_size_chars,取不到再读 chunk_size_tokens(:58-63)。重叠 64 是为了不让一句话被切断在块边界。

嵌入向量从哪来? embedTexts(:531-547)直接去 extensionManager 里按名字取 @janhq/llamacpp-extension 并调它的 embed()——复用本地推理引擎(04), 不额外起一个嵌入服务。取不到就抛 'llamacpp extension not available'。 返回结果按 item.index 回填数组(:543-545),因为批量嵌入的返回顺序不保证。

ANN 可用性探测。 onLoad 里调 checkANNAvailability()(:36,实现 :78-98): 问向量库扩展要 getStatus(),ann_available 为假就打一条 warn 'sqlite-vec not loaded. Collections will use slower linear search.'。 它只记录不阻断——没有 ANN 也能用,只是慢。这正好接上下一节。


7. 向量库:sqlite-vec 虚表,加一条暴力兜底

7.1 思路

理想路径是用 sqlite-vec 扩展提供的 vec0 虚表做近似最近邻(ANN)。但扩展是个动态库, 打包路径、开发/生产、macOS 的 .app 目录结构都可能让它加载失败。 所以 Jan 的策略是:探测 → 多路径重试 → 失败就退回纯 SQL 全表扫 + 手算余弦相似度

7.2 探测与加载回退

if conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS temp.temp_vec USING vec0(embedding float[1])", []).is_ok() {
let _ = conn.execute("DROP TABLE IF EXISTS temp.temp_vec", []);
return true;
}

src-tauri/plugins/tauri-plugin-vector-db/src/db.rs:71-127 try_load_sqlite_vec探测手段是"真建一张一维的临时虚表"——比查扩展列表可靠,建成了就说明 vec0 模块真的能用, 建完立刻 drop。首探失败才 load_extension_enable(),然后遍历候选路径逐个 load_extension(&p, Some("sqlite3_vec_init")) 并且每次都重跑建表验证(:85-87)—— 加载成功不等于模块可用,两个条件用 && 串起来。

候选路径由 possible_sqlite_vec_paths(:97-126)给出,从近到远:

顺序路径场景
1./src-tauri/resources/bin/sqlite-vec仓库根跑 dev
2./resources/bin/sqlite-vec打包目录跑 dev
3<exe 同级>/resources/bin/sqlite-vec通用生产布局
4<exe 上上级>/Resources/bin/sqlite-vec仅 macOS,.app bundle 结构

第 4 条带 #[cfg(target_os = "macos")](:114-123),因为 macOS 的 Foo.app/Contents/MacOS/foo 要往上跳两级才到 Contents/Resources

7.3 双路检索

create_schema(:148-182)建两张常规表 files / chunks 加三个索引, 末尾调 ensure_vec_table(:128-142)尝试建 chunks_vec 虚表,把结果作为 has_ann 返回。 向量本体在 chunks.embedding 列,以小端字节存(to_le_bytes_vec / from_le_bytes_vec, src-tauri/plugins/tauri-plugin-vector-db/src/utils.rs:17-26);有 ANN 时额外 往虚表里写一份 JSON 形式的向量(insert_chunks,db.rs:295-304)。同一份数据存两处, 所以虚表挂了也不丢数据。

分流在 search_collection(:334-365):

mode = "ann" ──> prefer_ann = true
mode = "linear" ──> prefer_ann = false
mode = "auto" ──> prefer_ann = true (默认偏好 ANN)

has_vec && prefer_ann ?
├── 是 ──> search_ann 虚表 MATCH + k,按 distance 排序
└── 否 ──> search_linear 全表扫,逐行 cosine_similarity

两条路的语义不一样,这是个真坑:

search_ann (:367)search_linear (:449)
排序依据v.distance(越小越近)余弦相似度(越大越近)
score 字段装的是距离相似度
threshold 参数完全不用score >= threshold 过滤(:496)
取前 NSQL 里 k = ?2排序后 .take(limit)(:515)

也就是说:换了检索路径,retrieval_threshold 设置就不生效了,score 的方向也反了。 文档只能照实说——克隆里没有对这两者做归一化的代码。

兜底那把"锤子"本身很朴素(utils.rs:3-15 cosine_similarity):维度不等返错, 零向量返 0,其余 dot / (mag_a * mag_b)。没有 SIMD、没有量化,就是老实的 O(n·d) 全扫。

7.4 切块与 TS 面

真正的切块在 Rust 侧(db.rs:606-631 chunk_text):按 char 而非字节切(对中文安全), 步进是 chunk_size - chunk_overlap,并且chunk_overlap >= chunk_size 时强制步进 1(:622-626) 防止死循环。

TS 面(extensions/vector-db-extension/src/index.ts)几乎是一层薄封装,只加了一件事:集合命名

private collectionForThread(threadId: string): string { return `attachments_${threadId}` }
private collectionForProject(projectId: string): string { return `project_${projectId}` }

extensions/vector-db-extension/src/index.ts:25-31。每个线程/项目一个独立集合, 再由 collection_path(db.rs:51-57)落成独立的 <name>.db 文件——天然隔离,删线程即删文件

ingestFile(:165-192)串起完整入库:查重(同 name + path 抛错)→ parseDocumentchunkTextembedTexts → 用第一条向量的长度当维度 createCollectioncreateFileinsertChunks。 项目版 ingestFileForProject(:78-126)多一段处理:先用默认维度 384 建集合, 拿到真实维度后若不一致就删掉集合重建(:112-115)——因为 vec0 虚表的维度写死在 DDL 里,改不了。


8. 助手与会话扩展:谁提供"人格"和"存储接口"

这两个扩展都很薄,但职责分得很清(扩展机制见 01):

扩展职责特点
conversational-extension线程/消息存储的 TS 门面纯转发,每个方法一行 window.core.api.xxx
assistant-extension助手(人格 + 默认参数)的文件存储 + 版本迁移自己维护迁移版本号

会话扩展薄到什么程度: 整个 extensions/conversational-extension/src/index.ts(127 行) 里每个方法都是 return window.core.api.listThreads() 这种一行转发。 它的价值不在逻辑,在接口位——把存储做成可替换的扩展点,未来换云端存储只需换实现。

助手扩展的迁移机制才是这里的重点。它自己实现了一套版本号(extensions/assistant-extension/src/index.ts:11-12): CURRENT_MIGRATION_VERSION = 3,版本号写在 file://assistants/.migration_versionrunMigrations(:71-95)是老实的顺序 if 链:

版本做什么实现
v1旧 instruction 前缀换成 "You are Jan, …":100 migrateAssistantInstructions
v2换成 Menlo Research 版长提示 + 写入默认参数:142 migrateToMenloInstructions
v3把身份前缀从默认助手里删掉:215 migrateStripIdentityPreamble

v3 有个细节值得学:它只在助手的 instructions 和预期的 v2 默认值逐字相等时才改 (:220-224,if (assistant.instructions !== expectedOldInstructions) continue)—— 用户改过的提示词一个字都不动。用"完全相等"当"未被自定义"的判据,简单且不会误伤。

占位符替换:{{current_date}}

默认助手的 instructions 末尾是 Current date: {{current_date}}(:323)。 渲染发生在前端,renderInstructions(web-app/src/lib/instructionTemplate.ts:12-23):

rendered = rendered.replace(/\{\{\s*current_date\s*\}\}/gi, currentDateStr)

web-app/src/lib/instructionTemplate.ts:21。正则允许大括号内有空格、大小写不敏感、g 全局替换。 调用点在线程页组装 systemMessage 时(web-app/src/routes/threads/$threadId.tsx:184-186)—— 每次渲染都重算,所以长期挂着的线程不会带着上周的日期去问模型"今天是几号"。 目前只支持这一个占位符。


9. 附件与引用渲染(简述)

这三个文件负责"附件进来"和"引用出去"的两端。

attachmentProcessing.ts —— 内联还是入库? processAttachmentsForSend (web-app/src/lib/attachmentProcessing.ts:78)对每个文档决定两条路之一:

模式含义落点
inline解析成纯文本,整篇塞进消息 metadatametadata.inline_file_contents(web-app/src/lib/completion.ts:110-113)
embeddings走 §6 的入库流程,只留 file id向量库

auto 模式的判据是估算 token 是否小于上下文阈值(:238-240): 装得下就内联(更准),装不下才入库(更省)。三条硬规则: 项目级文件永远 embeddings(:201-204); 解析失败时即使选了 inline 也回落 embeddings(:281-283 的注释明说这是有意的安全兜底); 内联的正文会被 §5.2 的 messageToText 计入 token 预算——内联不是免费的

citation-parser.ts —— 从工具输出里认出引用。 parseCitationsFromToolOutput (web-app/src/lib/citation-parser.ts:86)把工具返回的文本逐条尝试 JSON.parse, 再用两个类型守卫分流:isRagCitation 要求同时有 id/text/file_id 三个字符串字段(:11-19), isWebCitation 要求 url 且匹配 ^https?://(:21-25)。 Web 侧还会在 results / citations / sources / data 四个键里挨个找数组(:71-77)—— 为了兼容不同搜索类 MCP 工具各自的返回形状

grounding.ts —— 把角标标回句子上。 computeGrounding (web-app/src/lib/grounding.ts:57-95)把回答按句切开(splitSentences,:24, 正则刻意排除 "e.g." 这类句中句号,且丢掉长度 < 12 的碎句), 把句子和引用片段一次性批量嵌入(:73-76,一个请求同时算两边,省往返), 逐句取余弦相似度 ≥ 0.65(默认 threshold,:65)的最相似片段。 injectCitationMarkers(:107-139)再把 ¹²³ 上标锚点插回 markdown—— 插之前先把代码块和行内代码挖出来存进数组、用 §n§ 占位(:114-119), 替换完再填回去,避免把角标插进代码里


10. 巧妙之处(可以直接借鉴的)

  1. "分支"不需要新表,只需要两个 metadata 键。 parentId + activeChildId 就把线性历史升级成树, 存储层完全无感(web-app/src/lib/message-branching.ts:10-21)。
  2. undefined / null / string 三态区分"没迁移"和"我是根"。 少了这个区分, 老线程的每条消息都会互相认成兄弟(message-branching.ts:27-31 rawParent)。
  3. 懒迁移。 backfillParentIds 只在第一次分叉时跑,且自身幂等——不分叉的用户零成本 (message-branching.ts:142-145)。
  4. 两个方向都做 upsert,而不是给异步写排序。 create 用 INSERT OR IGNORE、 modify 用 ON CONFLICT DO UPDATE(src-tauri/src/core/threads/db.rs:248:279-282)。
  5. 故意把 token 估高。 3.5 而不是 4,宁可少塞几条也别撑爆(context-manager.ts:11)。
  6. 摘要注入后要再 trim 一次。 摘要自己也占 token,这一步很容易漏 (context-manager.ts:205-206)。
  7. 压缩失败必须能降级成裁剪。 一个 catch 换来"模型挂了对话还能继续"(context-manager.ts:213-215)。
  8. 把设置项编译进工具 schema。 retrievalLimit 变成 top_kmaximum, 约束前移到模型侧(extensions/rag-extension/src/tools.ts:9,35)。
  9. 用工具描述做行为约束。 "Use sparingly" 和 "do not pass raw document content" 比事后限流更早生效(tools.ts:26,52)。
  10. 上下文参数由宿主注入而非模型提供。 模型编不出别的 thread_id (web-app/src/services/rag/default.ts:45-52)。
  11. 探测扩展可用性靠"真建一张临时虚表"。 比查版本号可靠(plugins/tauri-plugin-vector-db/src/db.rs:71-76)。
  12. 向量存两份(BLOB 列 + 虚表),所以扩展挂了不丢数据(plugins/tauri-plugin-vector-db/src/db.rs:369-390)。
  13. 注引用前先把代码块挖走占位。 一个 §n§ 存根解决"角标插进代码里" (web-app/src/lib/grounding.ts:114-119)。

11. 边界与局限(诚实说)

存储层

  • 桌面端改一条消息要重写整个 messages.jsonl(helpers.rs:34-44 write_messages_to_fileFile::create 截断)。流式回答会高频触发 modify_message,超长线程上这是 O(n) 写放大。
  • 移动端 SQLite 把整个对象塞进 data TEXT 列,除 id 和时间戳外无法做任何服务端查询 (db.rs:59-84)。想按内容搜历史,只能全量拉到前端。
  • 桌面 list_threads 遍历目录、逐个读 thread.json(commands.rs:43-59),线程多了是线性开销; 解析失败的文件只 println! 后跳过(:52-55),坏文件静默消失。
  • 没有跨平台的存储迁移:桌面 JSONL 和移动 SQLite 是编译期并列的两套,克隆里没有互转代码。

分支

  • 树只活在内存 + metadata里,没有索引。computeActivePath / getSiblings 都是对全量消息数组做 filter(message-branching.ts:55,72),超长线程上每次切版本都是全扫。
  • activeRootId 存在线程 metadata 里、activeChildId 存在消息 metadata 里,两处状态需要同时正确

上下文

  • 字符估算不是真 tokenizer。中文、代码、base64 的实际比值和 3.5 差很远,只能算粗调
  • trimMessages纯按新旧淘汰,不看重要性——第一条里的关键约束和一句闲聊等价。
  • compactMessages 每次触发都是一次额外的模型调用,同步阻塞在发请求路径上。
  • 未开 max_context_tokens整套逻辑不生效(custom-chat-transport.ts:1259),默认行为是不裁。

RAG / 向量库

  • ANN 与 linear 两条路的 score 语义相反、threshold 只对 linear 生效(§7.3),结果不可比
  • linear 兜底是全表扫 + 逐行余弦,块数上万时会明显卡。
  • 集合维度写死在虚表 DDL 里,换嵌入模型意味着重建集合(项目版 index.ts:112-115 就是直接删了重建)。
  • 嵌入硬绑 @janhq/llamacpp-extension(rag-extension/src/index.ts:545),没有它就完全不能入库/检索。
  • 查重只比 name + path(vector-db-extension/src/index.ts:262-266),同名不同内容的文件会被误判为重复。

12. 横向对比

拿同 shelf、同 area(chat-agents)的 Chatbox 当对照——它也是桌面聊天客户端、也做附件 RAG 和长对话压缩, 但在"什么时候压、检索几段、库放在哪"这三处的取舍都和 Jan 不同(依据:chatbox/05-context-rag-compaction.md):

关切Jan 的取法Chatbox 的取法
什么时候压超预算才压,而且要显式打开 auto_compact,默认只裁不压用满上下文窗口的 60% 就自动摘要,阈值是默认值不是开关(该文 §5.5)
检索几段一段:向量 topK 直接返回(ANN 或 linear)两段:向量宽召回 20 条,再 rerank 精筛 5 条(该文 §5.4)
向量库在哪进程内 SQLite 文件,每线程/项目一个,删线程即删文件跑在主进程的 libSQL 知识库,跨会话共享(该文 §5.4)
谁发起检索模型自己调 retrieve 工具同样是工具面(query_knowledge_base 等四个)——这一点两边一致

版本/分支这一项没有对照。 同 shelf 的 chatbox、cherry-studio 两组文档里都找不到消息版本树: chatbox 唯一的历史改写机制是压缩点(该文 §5.5)。Jan 把"同一个提问下的多个回答"做成一等公民, 在这一类客户端里是少数派 (inferred:未逐个通读同 shelf 全部客户端项目)。

同库其它章:存储之上的对话流程见 02, 工具怎么被路由和审批见 03, 嵌入所依赖的本地引擎见 04, 反向把这套记性暴露成 API 见 06


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

主题文件路径关键符号
平台分叉开关src-tauri/src/core/threads/helpers.rsshould_use_sqlite
每线程写锁src-tauri/src/core/threads/helpers.rsMESSAGE_LOCKS, get_lock_for_thread
JSONL 读写src-tauri/src/core/threads/helpers.rsread_messages_from_file, write_messages_to_file
磁盘布局常量src-tauri/src/core/threads/constants.rsTHREADS_DIR, THREADS_FILE, MESSAGES_FILE
路径拼装src-tauri/src/core/threads/utils.rsget_thread_dir, get_messages_path
线程/消息命令src-tauri/src/core/threads/commands.rslist_threads, create_message, modify_message
SQLite 初始化与 DDLsrc-tauri/src/core/threads/db.rsinit_database
SQLite 幂等写src-tauri/src/core/threads/db.rsdb_create_message, db_modify_message
取线程助手src-tauri/src/core/threads/db.rsdb_get_thread_assistant
分支代数web-app/src/lib/message-branching.tsgetParentId, pickActiveChild, computeActivePath
分支写入web-app/src/lib/message-branching.tswithParentId, withActiveChild, backfillParentIds, makeSibling
消息 storeweb-app/src/hooks/useMessages.tsuseMessages, addMessage, updateMessage
分支 UI 编排web-app/src/routes/threads/$threadId.tsxensureBranched, handleRegenerate, handleEditMessage, resolveAssistantParent
token 估算web-app/src/lib/context-manager.tsCHARS_PER_TOKEN, estimateTokens, estimateMessageTokens
裁剪与压缩web-app/src/lib/context-manager.tstrimMessages, compactMessages, COMPACT_SYSTEM_PROMPT
预算触发点web-app/src/lib/custom-chat-transport.tshasGenuineUserQuery(:1017-1074 区段)
RAG 工具 schemaextensions/rag-extension/src/tools.tsgetRAGTools, RETRIEVE, GET_CHUNKS
RAG 入库与嵌入extensions/rag-extension/src/index.tsingestAttachments, ingestAttachmentsForProject, embedTexts, checkANNAvailability
RAG 参数注入web-app/src/services/rag/default.tsDefaultRAGService.callTool
sqlite-vec 加载src-tauri/plugins/tauri-plugin-vector-db/src/db.rstry_load_sqlite_vec, possible_sqlite_vec_paths, ensure_vec_table
双路检索src-tauri/plugins/tauri-plugin-vector-db/src/db.rssearch_collection, search_ann, search_linear
切块src-tauri/plugins/tauri-plugin-vector-db/src/db.rschunk_text
暴力兜底src-tauri/plugins/tauri-plugin-vector-db/src/utils.rscosine_similarity
集合命名与入库extensions/vector-db-extension/src/index.tscollectionForThread, ingestFile, ingestFileForProject
会话存储门面extensions/conversational-extension/src/index.tsJanConversationalExtension
助手迁移extensions/assistant-extension/src/index.tsrunMigrations, migrateStripIdentityPreamble, defaultAssistant
占位符渲染web-app/src/lib/instructionTemplate.tsrenderInstructions
附件内联/入库分流web-app/src/lib/attachmentProcessing.tsprocessAttachmentsForSend
引用识别web-app/src/lib/citation-parser.tsparseCitationsFromToolOutput
句级归因web-app/src/lib/grounding.tssplitSentences, computeGrounding, injectCitationMarkers