Project NOMAD 源码分析:离线内容、容器编排与本地 AI
AI 参与说明(Agent:Codex):本文由 Codex 根据固定提交的公开源码协助研究、撰写与校验,涵盖架构、调用链和最小缺陷复现。执行入口为 Codex Desktop;完整模型标识与 reasoning effort 未取得任务级运行记录。
Project NOMAD 把离线阅读、地图、教育应用和本地 AI 组织成一台可通过浏览器使用的知识服务器。源码可以分三层理解:管理设备和应用的控制层、保存原始内容的本地存储层,以及从内容生成向量索引的 AI 检索层。项目将这些组件的安装、状态、更新和恢复连接起来。
| 英文术语 | 中文名称 | 简要解释 |
|---|---|---|
| Command Center | 控制中心 | NOMAD 用来管理应用、内容和设备状态的界面与 API |
| Inertia | 保留原名 | 将后端页面路由及数据交给 React 渲染的连接层 |
| BullMQ | 保留原名 | 基于 Redis 分派和执行后台任务的队列库 |
| RAG | 检索增强生成 | 先查找相关资料,再把资料放进模型输入中生成回答 |
| embedding | 嵌入 | 将文本转换成可用于相似度检索的数值向量 |
| ZIM | 离线归档格式 | 将网页和相关资源打包供离线阅读的文件格式 |
| PMTiles | 地图瓦片归档格式 | 把地图瓦片放进单个文件,并按需读取其中的部分数据 |
| sidecar | 边车服务 | 与主应用配合运行、承担独立职责的辅助服务 |
本文面向希望理解架构、排查问题或参与二次开发的读者,重点分析启动过程、容器生命周期、内容下载、RAG 和前端通信。分析基线为 2026 年 10 月 26 日读取的 main 提交 54ee30a44902e5c252b477f8685c26ec3064ab72,根目录版本为 1.35.1,许可证为 Apache 2.0。下文源码链接均固定到该提交;部署与性能判断限定于源码行为,个别实现问题另列最小复现条件。
项目定位与代码边界
NOMAD implements the Command Center, unified APIs, content management, and chat and RAG orchestration. 阅读器、学习平台和推理引擎等能力来自独立应用;NOMAD 负责把它们接到统一的设备管理流程中。
| 能力 | NOMAD 负责什么 | 外部组件负责什么 |
|---|---|---|
| 应用管理 | 目录、预检、安装、端口、状态、更新 | Docker 运行各应用 |
| 离线资料 | 下载 ZIM、维护书库、选择内容、管理版本 | Kiwix 提供阅读体验 |
| AI 助手 | 会话、提示词、检索、上下文预算、引用、流式输出 | Ollama 或 OpenAI 兼容服务推理,Qdrant 保存向量 |
| 离线地图 | 下载、地区提取、本地资源服务 | PMTiles 保存地图,MapLibre 渲染 |
| 教育 | 安装和启动 Kolibri | Kolibri 管理课程、学习用户和进度 |
| 药品资料 | 数据摄取、SQL 搜索、精选条件目录 | openFDA 提供原始标签数据 |
预置应用以数据库 seed 记录表达,包含镜像、Docker 配置、依赖和展示信息。增加普通容器应用通常先改目录;需要专属初始化时,再扩展编排服务。这里没有统一的第三方插件执行协议。参见 预置服务目录 和 Service 模型。
项目所说的离线优先,是相关镜像、资料、模型、地图字体等准备好之后可以本地消费。刷新目录、下载新资源、拉取更新、从远端全球地图提取地区,以及使用外部模型地址,仍涉及网络。清单缓存 会先尝试远端刷新、再读本地缓存;联网状态检查 也说明离线优先不等于没有网络请求。
总体架构与源码地图
后端位于 admin/,采用 TypeScript、AdonisJS 6 和 Lucid ORM;前端通过 Inertia 连接 React 19,使用 Vite、Tailwind CSS 和 TanStack React Query。业务数据库是 MySQL,异步任务由 BullMQ 与 Redis 承担。主镜像使用 Node 22 的多阶段构建。版本与依赖以 admin/package.json、Dockerfile 为准。
flowchart TD
B["浏览器 React 与 Inertia"] --> W["Command Center HTTP 进程"]
B --> A["Kiwix Kolibri 等应用界面"]
W --> DB["MySQL 业务状态"]
W --> R["Redis 队列与事件"]
R <--> J["BullMQ Worker 进程"]
W --> D["宿主 Docker daemon"]
D --> A
D --> O["Ollama"]
D --> Q["Qdrant"]
W --> O
W --> Q
J --> O
J --> Q
J --> DB
J --> F["宿主持久化 storage"]
A --> F
W --> U["共享更新卷"]
U --> UP["Updater sidecar"]
UP --> D
HTTP 与 Worker 是同一个 admin 容器里的两个进程;可选应用则由宿主 Docker 创建,和 admin 并列运行。核心 Compose 只声明 admin、MySQL、Redis、Dozzle、Updater 和磁盘采集容器,应用目录里的其他工具由管理端动态创建。证据见 进程启动、核心 Compose 和 Docker socket 连接。
| 路径 | 阅读重点 |
|---|---|
install/ | 安装、Compose、启动、更新及 sidecar |
admin/bin/server.ts | HTTP 启动与启动后校正 |
admin/adonisrc.ts 和 admin/start/ | Provider、中间件、页面与 API 路由 |
admin/app/controllers/ | 参数校验、调用服务、组织响应 |
admin/app/services/ | 容器、内容、RAG、系统管理的核心逻辑 |
admin/app/jobs/ 和 admin/commands/queue/ | 可恢复长任务及 Worker |
admin/app/models/ 和 admin/database/ | 持久状态、迁移、应用目录 |
admin/inertia/ | 页面、组件、请求、轮询与流式交互 |
admin/types/ 和 admin/constants/ | 前后端共用类型与策略常量 |
collections/ | 精选内容与配置清单 |
admin/tests/ | 单元测试、功能测试及 RAG 质量评估 |
业务逻辑主要集中在 DockerService、RagService 和 BenchmarkService 等大服务中。阅读时沿一次操作的调用链前进,更容易看清这些服务如何协作。
启动流程与请求入口
官方安装脚本检查系统与依赖、按需安装 Docker 并检查 Compose v2、配置 GPU 支持、生成目录和凭据,再启动核心服务。当前安装路径主要面向 Debian 系统及 x86_64;虽然部分镜像构建逻辑包含 ARM64 分支,不能据此推断全部可选应用均已支持 ARM。参见 架构检查 和 安装主流程。
管理容器启动后的顺序如下:
- 创建日志及知识库上传目录,等待 Redis 可连接。
- 执行数据库迁移。
- 执行 ServiceSeeder,补充新应用并同步预置配置。
- 后台启动
node ace queue:work --all。 - 前台运行
node bin/server.js,启动 HTTP 服务。 - HTTP 应用就绪后,从文件系统校正内容安装账本。
入口见 entrypoint.sh 与 server.ts。文件系统校正失败会被捕获,避免内容登记异常直接阻止管理界面启动。
Seeder 每次启动都会运行,但它会跳过自定义应用和用户已修改的预置应用;对于已安装的预置应用,更新目录配置时还会保留合适的宿主端口映射。这是一种目录升级与用户配置并存的策略,见 ServiceSeeder.run。
普通请求沿以下路径流动:
浏览器
→ start/routes.ts
→ Controller 的 Vine 参数校验
→ Service 组织业务操作
→ Lucid / Docker / 文件系统 / 队列
→ Inertia 页面或 JSON 响应
页面路由和 API 路由共存于 routes.ts。例如 HomeController.home 读取已安装服务,通过 inertia.render('home', props) 交给 React。全局版本、运行环境和助手名称则由 sharedData 统一注入。React 的 SSR 明确关闭,因此这里是服务端组织页面数据、客户端渲染 React。
数据模型与跨存储一致性
项目同时维护关系数据库、文件系统、Redis 与向量库。它们各有职责,判断“内容是否可用”时不能只看某一个状态。
| 存储 | 主要内容 | 能说明什么 |
|---|---|---|
MySQL services | 应用配置、installed、安装状态、更新策略 | 目录与管理状态,不等同于当前正在运行 |
MySQL installed_resources | 资源 ID、类型、版本、路径、大小 | 内容已安装的账本 |
MySQL kb_ingest_state | 摄取决策、索引状态、chunk 数量、错误 | 文件在知识库中的处理结果 |
| MySQL 会话与 KV 表 | 消息、来源、设置、更新状态 | 持久业务数据 |
| 文件系统 | ZIM、PMTiles、上传文件、模型和各应用数据 | 原始内容与应用持久数据 |
| Redis | BullMQ 任务、进度、取消标志、Transmit 事件 | 执行与跨进程通信状态 |
| Qdrant | chunk 向量、正文与来源等 payload | AI 语义检索所需的派生索引 |
InstalledResource 的类型包括 zim、map、dataset;KbIngestState 则独立记录文件是否选择索引、索引是否完成。把两者拆开,解决了“下载成功就算进入 AI 知识库”和“存在一些向量就算完整索引”的歧义。
应用也有类似区别:容器停止后仍可能是已安装。容器与数据库同步 依据容器存在性修正 installed,并为远程 Ollama、链接卡片等情况保留例外。
一次内容安装可能依次修改文件、MySQL、Kiwix XML 和 Qdrant,这些修改没有共同事务。项目采用原子文件替换、幂等更新及重扫来补偿:reconcileFromFilesystem 从磁盘修正账本,Kiwix 可重建书库,知识库单独重试。因此,“文件已下载”“可以阅读”“已完整索引”是不同的结果。
容器安装与应用生命周期
点击安装时,调用链为:
SystemController.installService
→ DockerService.createContainerPreflight
→ 标记 installing 并立即返回
→ 异步 _createContainer
→ 安装依赖 → 获取镜像 → 应用初始化与 GPU 配置
→ Docker create → start
→ 更新 Service → 广播完成或错误
Controller 入口 校验请求后,createContainerPreflight 检查安装状态,将服务加入内存集合并写入数据库,再启动异步安装。Application installation runs in the Web process, not in a durable BullMQ job. 安装进度通过 Transmit 推送,下载和索引任务则由独立 Worker 执行。
_createContainer 根据 depends_on 递归安装依赖,已有镜像可直接使用;否则拉取镜像并等待实际完成。随后针对 Kiwix、Calibre 等应用执行初始化,配置 Ollama 的 NVIDIA 或 AMD 设备,再创建容器。AMD 设备不可用时还存在清理后回退 CPU 的路径。
管理端通过宿主 socket 操作 Docker,动态应用挂到管理网络中。存储路径要转换成宿主能理解的绝对路径,因为 Docker daemon 运行在宿主,不能把 admin 容器内部路径直接当作 bind mount 来源。这也是迁移 storage 时必须保持各处配置一致的原因,见 存储与 socket 挂载。
自定义应用复用相同生命周期。custom_app_guard 会检查镜像和挂载,拒绝 Docker socket、关键系统路径等明显危险配置;资源输入由 validator 校验,再由 Controller 转成 Docker 限额。它提供的是受约束的容器表单,并不支持把任意 Compose 文件原样导入。镜像仓库提示也不构成镜像内容签名验证。
这种实现适合单设备管理,安装流程直观且可加入应用专属处理;恢复能力则依赖状态校正。按静态调用链推断,Web 进程在安装中途退出,会中断尚未完成的 JavaScript 操作,而数据库 installing 标记可能已经写入。实例内集合也不能作为跨进程互斥锁。若将来需要更强的崩溃恢复或并发管理,应用安装应有持久任务 ID、明确阶段和可重入的恢复逻辑。
离线内容下载与本地服务
清单和安装账本
collections/ 的 JSON 定义分类、资源、推荐层级和版本。CollectionManifestService 从仓库 main 分支拉取清单,经 Vine 校验后缓存到 MySQL。tier 可通过 includesTier 继承资源,安装状态则按资源 ID 与 InstalledResource 比对。
应用镜像固定了版本,运行时清单仍可能跟随 main 前进。本地缓存改善断网可用性,但旧应用与未来清单语义的兼容性仍要单独考虑。需要严格复现离线镜像时,应同时记录镜像版本、清单版本和内容摘要。
一份 ZIM 如何变成可阅读的书库内容
flowchart TD
C["校验 URL 与资源"] --> Q["下载任务"]
Q --> T["写临时文件并续传"]
T --> F["rename 为最终 ZIM"]
F --> I["更新安装账本"]
I --> K["重建 Kiwix XML"]
K --> R["Kiwix 热加载并提供阅读"]
I --> E["按用户策略决定是否摄取"]
E --> V["Embedding 与 Qdrant"]
代表性入口是 ZimController.downloadRemote → ZimService.downloadRemote → RunDownloadJob.dispatch。HTTP 请求只提交任务,长时间下载交给 Worker。
底层 doResumableDownload 先通过 HEAD 获取文件长度和 Range 能力,流写入 .tmp,支持断点续传。远端不支持 Range、忽略 Range 返回完整文件、或本地 partial 大于远端时,都有重新下载的分支;五分钟没有数据会视作停滞。成功后 rename 到最终文件名,避免阅读器扫描到未完成的归档。任务按 URL 生成稳定 ID,并配置指数退避。
文件完成后,任务写入 InstalledResource,再调用 ZIM 回调。KiwixLibraryService.rebuildFromDisk 使用 libzim 读取归档元数据,生成指向 Kiwix 容器 /data/ 的书库 XML,采用临时文件与 rename 更新。Kiwix 的 library monitor 能热加载书库,手工复制资料后也能通过 rescan 进入同一流程。
下载后的 AI 摄取取决于是否安装 AI、是否跳过 embedding,以及用户选择的自动或手动策略,见 下载后的摄取决策。已有旧版本的更新还会继承用户先前的索引选择。由此,浏览资料与让模型检索资料可以独立使用。
地图和教育应用
地图由本地 PMTiles、样式、字体和 sprite 组成。MapService.generateStylesJSON 把样式资源地址改到本机;前端 MapLibre 通过 PMTiles Protocol 读取归档,静态服务支持 HTTP Range。除了完整下载,地区提取还会由任务调用 pmtiles extract,从远端全球归档取所需区域,见 地区提取进程。这条提取路径需要联网,地图下载完成后的浏览则使用本地资源。
Kolibri 的课程与学习记录属于其自身业务。当前目录使用独立 Gen2 实例及数据卷;旧版保留为 deprecated,避免强行跨越多个数据版本就地迁移。配置见 Kolibri Gen2 目录项。
AI 模型接入与知识摄取
双协议模型适配
AI 主链由本仓库的 TypeScript 服务自行组织。OllamaService 优先使用用户配置的远端地址,否则查找本地 Ollama 容器;通过 /api/tags 等响应识别原生后端,并支持 OpenAI 兼容调用。
兼容协议存在能力差异:对话可以转到 /v1/chat/completions,但 Ollama 的 num_ctx、模型管理和部分结构化输出约束不能简单照搬。连接兼容服务不代表模型下载、窗口控制和 JSON grammar 都获得同等支持。具体转换位于 原生与兼容参数适配。
默认 embedding 模型是 nomic-embed-text:v1.5,Qdrant 的物理 collection 为 nomad_knowledge_base,向量固定为 768 维、Cosine 距离。界面中的 collection 是 payload 中的逻辑分类。见 默认 embedding 模型 与 向量库配置。因此任意替换 embedding 模型可能需要重建 schema 与索引,不能只改聊天模型名称。
文档处理与索引
上传入口 RagController.upload 清理文件名并添加随机后缀,把文件放入 storage/kb_uploads,提交 EmbedFileJob 后返回 202。摄取任务与用户请求分离,避免大文件解析占住 HTTP 请求。
processAndEmbedFile 按文件类型提取文本:DOCX 使用 mammoth,EPUB 按 OPF spine 顺序读取正文,PDF 优先文本解析,图片及低文本量 PDF 使用 OCR,ZIM 则按文章和章节批次提取。OCR 当前显式使用英语模型;PDF 的 OCR 触发条件是全文件文本量不足,因此不能推断中文扫描件或混合 PDF 的所有图中文字都会被识别。
embedAndStoreText 分块后以 search_document: 前缀计算向量,每批处理 8 个 chunks,并将正文、来源、chunk 序号、分类及 ZIM 元信息写入 point payload。常量中的目标块长需要经过字符估算,不能当成精确 tokenizer 的固定 token 数。
大 ZIM 采用 continuation job,按已经消费的文章数推进 offset。即使某批文章没有有效正文,也能继续向后推进;最终把跨批累计结果写入 KbIngestState。相关逻辑见 ZIM 批次推进 与 continuation 和完成状态。这样,临时任务历史被清理后,知识库状态仍有持久记录。
原始文件可以长期保存,解析和 embedding 可以重做。向量库承担检索,安装账本只记录内容安装结果,各层分别处理自己的状态。
一次 AI 对话的检索与生成
从问题到候选片段
聊天入口 OllamaController.chat 校验模型、会话、图片等输入,再调用生产和质量评估共用的 RagPipelineService.buildPrompt。
sequenceDiagram
participant UI as 浏览器
participant API as OllamaController
participant P as RagPipeline
participant Q as Qdrant
participant M as 模型服务
participant DB as MySQL
UI->>API: 消息与 sessionId
API->>P: buildPrompt
opt 追问需要补全
P->>M: 改写为独立检索问题
end
P->>Q: 向量召回
Q-->>P: chunks 与元数据
P->>P: 重排 相关性过滤 上下文预算
P-->>API: messages 与 trace
API->>DB: 保存用户消息
API->>M: 流式生成
M-->>API: 文本与 thinking chunks
API-->>UI: SSE 格式事件与 sources
API->>DB: 流处理正常结束后保存助手消息
提示词管线先分离 system 与对话,按条件加入默认提示词,并把用户管理的 NOMAD.md 放入 system blocks。该文件通过临时文件加 rename 保存,作为持久行为配置,见 NomadMdService。
首轮问题可直接检索,追问则可利用近期非 system 消息补全指代;改写失败回退到原问题。随后 searchSimilarDocuments 查询 Qdrant,默认先召回 3 × topK 候选,再结合词项重叠、问题词、标题命中重排,并对重复来源作多样性惩罚。
Retrieval uses dense vector search followed by lexical and title-based reranking. 源码注释中的 hybrid 指这一组合,没有 BM25 与向量检索两套独立召回结果合并;重排也不是单独训练的神经 reranker。其直接后果是:没有进入向量候选集的段落,不能仅凭关键词加权被补召回。实现见 重排与来源多样性。
此外还可开启 LLM 相关性判断,过滤整体偏题的候选;默认不开启,判断失败时保留结果,见 RelevanceJudgeService。
上下文预算
planPrompt 将可用窗口分配给 system、本轮问题、完整 RAG chunks、历史轮次和回答。它为回答预留空间,优先保护本轮输入,按预算保留完整片段,再从最近历史向前保留完整对话轮次。放不下的 RAG chunk 会跳过,后面较短的片段仍有机会进入。
窗口也不是简单写死一个数字。ContextWindowService 会结合模型元数据、硬件内存及用户上限确定窗口;TokenCalibrationService 再用实际 prompt token 用量校准估算。非原生后端的窗口能力较难探知,仍需要保守估算或用户配置。
在本地设备上,模型参数量、VRAM、内容长度和回答空间相互制约。检索结果越多,会占用更多输入窗口,并不必然改善回答。
流式输出与持久化
服务端发送的是 data: JSON\n\n 格式事件。浏览器通过 fetch 和 ReadableStream 逐块解析,不是 EventSource,也不经过 Transmit。正文、thinking、sources 和 done_reason 分别处理,长度截断时可提供续写入口。两端实现见 服务端生成流 与 浏览器解析流。
有 sessionId 时,后端先保存用户消息,流处理正常结束后保存助手正文与来源;首轮还会异步生成标题。达到生成长度上限的回答也会保存。断连取消会提前返回,因此部分助手回答不会按完整回答落库;图片和 thinking 也没有对应的 ChatMessage 持久字段。参见 消息保存时序 与 ChatMessage。浏览器当前展示与刷新后的历史回放,因而不是完全相同的记录。
前端状态与通信机制
Inertia 负责页面入口和首屏数据,React Query 管理交互期服务端状态,主题、通知和弹窗由 Provider 统一组织。入口根据页面名加载相应 React 页面,见 createInertiaApp。普通 API 请求集中在同源 Axios 客户端,类型来自共用的 admin/types/。
| 场景 | 通信方式 | 主要原因 |
|---|---|---|
| 页面进入和局部页面刷新 | Inertia | 复用后端路由与页面 props |
| 文件下载任务列表 | React Query 轮询 | 读取 Redis 中可恢复的任务状态 |
| 服务安装、模型下载、跑分事件 | Transmit SSE | 跨进程发送阶段与日志事件 |
| 聊天文本生成 | fetch 读取 SSE 格式响应 | 同一次 POST 请求持续消费模型输出 |
普通下载活跃时约每 2 秒查询,空闲时约每 3 秒查询,见 useDownloads。Transmit 使用 Redis transport,使 Worker 发出的事件可到达 HTTP 进程持有的浏览器连接,配置见 Transmit Redis transport。
队列管理也专门处理连接复用:QueueService 是进程内单例,按名称缓存 Queue;共享 ioredis 延迟连接并配置退避,避免频繁创建队列对象造成连接膨胀。
Worker 采用不同并发和 stall 策略。普通下载可较宽松地恢复,embedding 则对 stalled job 更保守,以避免重复 continuation 链。见 各队列并发 与 stalled 恢复策略。可恢复执行需要考虑具体任务的幂等性,不能统一提高重试次数。
In BullMQ v5, maxStalledCount limits stalled-job recovery, while lockDuration controls a renewable processing lock. 后者不是任务的硬超时;前者超过次数后,任务会转入 failed。区分这两个参数,才能判断任务是在正常长时间处理,还是失去锁后反复恢复。参见 BullMQ v5 WorkerOptions。
核心更新与应用更新
应用更新和 NOMAD 自更新采用两条机制,因为它们的执行者与生命周期不同。
DockerService.updateContainer 先拉新镜像,停止并把原容器改名为 _old,再创建替代容器。创建或启动失败时尝试恢复旧容器,成功则移除旧容器。但它只短暂等待并检查 Running,不验证业务 HTTP 就绪;共用的数据卷也没有回滚,所以旧镜像恢复不等于数据恢复。
核心更新由 SystemUpdateService.requestUpdate 写入共享卷中的请求文件,独立 Updater 读取后修改 Compose 镜像标签、拉取并重建核心服务。这样 admin 消失后,更新执行者仍然存在。执行逻辑见 perform_update。
自动更新又分别区分核心、应用和内容,各有开关,并设置预检与失败退避。核心和应用共享窗口与冷却配置,内容更新使用独立配置。核心决策会排除跨 major、草稿和预发布版本,并把联网失败当作跳过,避免离线设备仅因没有网络就累计故障。evaluate 把决策与实际请求分开,dry-run 可以复用同一决策管线。
这里的 success 有明确边界:自动更新记录结果 在请求文件写入成功时就记录 success,sidecar 的部署动作发生在之后。判断新版本是否真正可用,还要结合 sidecar 状态、当前版本及应用就绪情况。核心 sidecar 也没有应用更新那样保留旧容器的回退流程。
其他模块体现的架构取舍
药品资料采用 openFDA 分片下载、独立 ingest 队列与 MySQL 索引。入库按批处理,利用唯一键及 upsert 去重;查询优先 FULLTEXT,短词或全文检索失败时回退 LIKE。它直接使用结构化 SQL,见 药品批次入库 与 药品搜索。
精选 conditions、自然疗法和居家处理信息从 collections/ JSON 生成 TypeScript,构建镜像时重新生成,以减少源数据和提交的生成文件漂移,见 精选数据构建流程。这表明项目会根据数据性质选择静态目录、SQL 或向量检索。
Benchmark 复用容器能力执行 CPU、内存、磁盘和 AI 测试。Benchmark telemetry is broadcast to the local UI; leaderboard submission is a separate operation. BenchmarkTelemetrySampler 在跑分期间采样,榜单上传则由独立的 submit 调用触发。参见 本地采样 与 榜单提交。
可选的 Translated Library 则以独立 Python 代理保留 Kiwix 路径,对 HTML 正文进行本地翻译;模型齐全后可脱网,失败时退回原文。当前实现仍有架构和语言范围限制。它展示了在应用容器外增加专用代理的扩展方式,见 翻译代理设计。
工程质量与需要注意的实现问题
测试资产与自动检查
该提交有 59 个 unit spec 文件、1 个 functional spec,另外提供 standalone 数据检查和 RAG corpus、goldens、baselines。文件数量不等于测试用例数或覆盖率。测试涉及下载与 URL 规则、容器 guard、流取消、上下文预算、引用和自动更新等边界。
运行入口需要区分:npm run test:unit 使用 Node test runner;npm test 进入 Ace/Japa 配置;npm run test:eval 是评估相关单元测试子集。真正的检索和生成质量评估使用单独 Ace 命令,并可比较 oracle、端到端、无检索和 mock 模式。配置见 测试 scripts 与 RAG 评估流程。
生产聊天与评估共用 buildPrompt 是优点,有助于避免评估路径偏离实际逻辑。不过 PR workflow 只显式运行依赖安装和构建,没有独立的 unit、lint、typecheck 门禁;现有测试资产不等于每次 PR 都强制执行。
已复现的来源错配
上下文预算可能跳过大 chunk 并保留后面的小 chunk,但 pipeline 用以下方式重建实际注入集合:
trace.injected = candidateDocs.slice(0, plan.trace.chunksKept)
前者的实际保留集合未必是候选前缀,后者却只按数量取前缀。预算选择循环、trace.injected 和 引用生成 连接起来,便形成来源错配。
最小验证给候选按顺序放入大段 large.pdf 和小段 small.pdf,窗口设为 1024。执行原预算函数后,只保留 small;按 pipeline 的切片逻辑,来源却报告 large:
{
"chunksKept": 1,
"actualPromptIncludesLarge": false,
"actualPromptIncludesSmall": true,
"reportedSources": ["large.pdf"]
}
该验证覆盖预算与来源集合的纯逻辑,不代表整个部署的端到端测试。修正方向是让预算器返回实际保留的 chunks 或稳定索引,直接据此构造引用;仅返回保留数量不足以表达选择结果。
下载后的处理没有共同事务
下载完成回调处理 捕获后处理错误后仍可把进度设为 100%,书库重建也存在日志化错误的分支。因此“下载完成”不能严格解释为书库登记和索引全部成功。通用下载器还主要依赖长度与 Range 语义,没有在该函数执行强摘要校验;同尺寸最终文件会提前返回,见 已有文件快速返回。
项目已有 rescan、reconcile 和重新索引入口。进一步完善时,应分别保存下载、注册和索引的终态,让用户能只重试失败阶段。
安装和更新的维护边界
安装脚本在生成新凭据时,若已存在 MySQL 数据目录,会直接删除该目录。代码位于 已有 MySQL 目录处理。重跑安装脚本不能当作无损升级或修复操作。
应用版本更新创建新容器时只复制部分 HostConfig,未完整带回 Memory、NanoCpus 等字段,见 更新后的容器配置。按静态配置路径判断,原有限额可能在该更新路径后丢失。后续宜复用统一的配置重建方法,并测试更新前后的约束一致性。
安全依赖明确的网络信任边界
项目安全模型 明确把无鉴权作为设计选择,管理端以可信本地网络为边界。中间件配置 没有用户权限隔离,CSP、CSRF 和 HSTS 在 Shield 配置 中关闭,CORS 设置也较宽松。
与此同时,admin 持有宿主 Docker socket。能访问管理 API 的人可以触发设备级容器操作,因此管理界面与普通离线阅读界面的权限意义不同。对外或多人使用时,需要在网络入口设置访问控制;只给 admin 增加登录页,也不会自动保护各应用独立暴露的端口。
项目仍使用 Vine 参数校验、路径 guard、下载地址规则及云 metadata 地址限制;这些措施与支持本地模型、局域网内容源的需求并存。这里的结论是部署信任边界较强,不能仅凭配置就宣称已验证某条跨源攻击或远程执行漏洞。
二次开发的阅读顺序与切入点
以下命令使用 Git 取得本文固定的提交,再查看路由与核心服务。请在尚无 project-nomad-reading 目录的位置执行;git rev-parse 应输出下方的完整 SHA。
git init project-nomad-reading
git -C project-nomad-reading remote add origin https://github.com/Crosstalk-Solutions/project-nomad.git
git -C project-nomad-reading fetch --depth=1 origin 54ee30a44902e5c252b477f8685c26ec3064ab72
git -C project-nomad-reading switch --detach FETCH_HEAD
git -C project-nomad-reading rev-parse HEAD
git -C project-nomad-reading show HEAD:admin/start/routes.ts
git -C project-nomad-reading show HEAD:admin/app/services/rag_pipeline_service.ts
预期提交:54ee30a44902e5c252b477f8685c26ec3064ab72。这组命令只取得并阅读源码,不会运行项目安装脚本或启动应用。
建议先沿三条调用链阅读,再决定扩展位置:
- 设备和应用:
routes.ts → SystemController → DockerService → ServiceSeeder。适合增加目录项、预安装动作、端口与资源规则。 - 内容管理:
ZimController → ZimService → RunDownloadJob → downloads.ts → KiwixLibraryService。适合增加资源来源、完整性验证及后处理恢复。 - AI 检索:
OllamaController → RagPipelineService → RagService → planPrompt → OllamaService。适合改进召回、预算、引用和后端兼容性。
贡献代码时,优先补上与变更相关的单元测试,并对照实际入口运行。涉及 Docker、数据迁移、模型行为时,再使用隔离环境做集成验证;纯构建不能代替这些行为检查。
NOMAD 值得借鉴的部分,是把离线内容、宿主容器和本地 AI 连接成可操作的设备产品。下载续传、存储路径、用户选择、索引状态和更新执行者都进入了同一套管理流程。这些选择适合可信环境中的单设备知识服务器;若要发展成多人、可审计、强恢复的平台,持久安装工作流、权限模型、跨阶段结果与更新就绪验证会成为主要改造点。