# 阶段 F:Embedding 与知识库问题与解决方案 > 记录日期:2026-09-04。涉及分支:`feat/multimodal-pipeline`,前序功能提交:`6eb97bf`。 > 本文按既有复盘格式记录原因、后果、解决思路、实际方案和验证结果。修复随当前分支提交;合并状态以 Git 与 PR 记录为准。 ## 1. 背景 阶段 F 将占位向量替换为真实本地 Embedding,并加入 API 路由、CPU/CUDA 模型子进程、转写笔记和聊天知识库上下文。联调问题跨越运行环境、索引、持久化与本地处理约束,不能仅根据“模型已下载”判断整条链路正常。 ```text 前端 / 后续 Tauri WebView → 后端 API → 模型路由 → 带模型空间标识的向量索引 → 知识检索 / 聊天来源 ``` ## 2. 问题总览 | 编号 | 问题 | 后果 | 实际方案 | | --- | --- | --- | --- | | F-01 | 配置、推理与索引错误共用提示 | 已有模型却被提示未配置 | 区分推理错误与索引错误 | | F-02 | Windows 热重载事件循环不支持异步子进程 | 命令行成功,HTTP 失败 | 线程管道子进程兼容路径 | | F-03 | 重建忽略向量失败,异常游标未关闭 | 虚报成功或阻塞重建 | 严格校验、回滚和显式关闭 | | F-04 | 搜索记录仅保存在内存或浏览器 | 刷新丢失,桌面无法统一管理 | SQLite 历史与 API | | F-05 | 聊天忽略 `use_rag` | 没有知识库内容 | 真实 Block 上下文与来源事件 | | F-06 | 本地转写导出未传递限制 | 正文可能发送给远程 Embedding | 持久化本地索引标记 | | F-07 | 推理结束才读取当前模型标识 | 向量与空间错配 | 冻结模型、revision 和设备配置 | | F-08 | 将不同处理策略误判为配置漂移 | 普通与仅本地笔记共存时不能重建 | 按策略校验覆盖,独立检索并融合排名 | | F-09 | 简单字符串比较忽略 YAML 语法 | 注释等合法写法可能关闭本地限制 | 解析 YAML 节点,非法策略明确拒绝 | | F-10 | 迁移 DDL 与版本号分开提交 | 中断后重启报重复列 | 原子迁移、并发重检及旧半迁移恢复 | | F-11 | BOM 与未闭合头部被当作无策略 | 本地限定正文可能进入普通 API 路由 | 统一 frontmatter 边界,异常头部拒绝保存 | | F-12 | 普通 Markdown 分割线误判为头部 | 正常笔记保存失败、重建中止 | 按元数据声明识别头部,保留普通正文 | ## 3. F-01 / F-03:模型可用不等于索引可用 ### 原因与后果 `embed_remote` 捕获异常后返回 `None`,上层将调用失败与索引缺失统一显示为“请配置 Embedding”。默认库曾有 29 个 Block 和模型元信息,但没有向量侧表。普通保存允许降级保留正文,这一策略又被用于严格重建,导致没有向量也可能报告完成。异常回溯保留查询游标时,还会影响后续写事务。 ### 解决思路与实际方案 纯向量查询使用严格错误处理:模型失败保留安全错误码;模型可用但索引缺失时返回 `SEMANTIC_INDEX_UNAVAILABLE`,明确提示重建。混合检索仍可退到全文检索。重建先准备向量,再进入事务,检查空间一致和完整覆盖,失败保留旧索引。查询游标在 `finally` 中关闭。 前序真实本地验证:重建后 29 个 Block 对应 29 条向量,查询返回 20 条结果。这是当时样本库的历史记录,不代表全部环境与规模。 ## 4. F-02:Windows 热重载下本地模型无法启动 ### 原因与后果 Windows 下 `uvicorn --reload` 使用的事件循环可能不支持 `asyncio.create_subprocess_exec`,抛出 `NotImplementedError`。模型与依赖均已安装,普通 `asyncio.run` 冒烟成功,但实际 HTTP 请求失败。第一轮只修提示和索引,未覆盖此启动方式。 ### 实际方案与验证 优先保留异步子进程,仅在不支持时使用 `ThreadedProcess`。同步创建进程以避免取消时失去进程归属;管道读写与等待在线程中执行,保留输出上限、隐藏窗口、超时、取消和回收逻辑。 修复后通过前端实际连接的 `/api/search` 验证成功;测试覆盖真实小子进程的结果读取与取消回收。重新安装权重不能解决此类事件循环兼容问题。 ## 5. F-04 / F-05:搜索记录与聊天知识库 ### 原因与后果 历史最初包含硬编码示例并只在内存更新;第一轮改成 `localStorage` 虽解决刷新丢失,却不符合后续 Tauri 统一管理应用数据的要求。聊天只有 `use_rag` 字段,没有执行检索。 ### 实际方案 SQLite v5 增加 `search_history`,保留最近 10 条去重查询,重复项置顶。记录属于配置的 `APP_DB_PATH`,Tauri 可复用后端;这不等于已实现 Rust 原生存储。此前浏览器记录没有自动迁入 SQLite。 | 方法 | 路径 | 行为 | | --- | --- | --- | | POST | `/api/search` | 记录提交的非空查询,再执行检索 | | GET | `/api/search/history` | 返回 `{"queries": [...]}`,最近项在前 | | DELETE | `/api/search/history` | 清空历史,返回空数组 | 前端通过 API 加载与清空,失败显示错误。聊天开启知识库时最多取 6 个来源,每段正文最多 3000 字符、合计最多 12000 字符;资料明确标为非指令,SSE 返回 `Citation` 供定位。关闭时不附加笔记,无命中时不生成来源。请求与事件测试不等于外部模型回答质量验收。 ## 6. F-06:仅本地转写的笔记索引 ### 原因与后果 `create_transcript_note` 调用通用 `create_note`,后者默认执行 API Embedding。转写本身遵守 `local_only`,导出索引却可能上传正文。只增加临时调用标记也无法覆盖后续重建。 ### 实际方案 仅本地任务新生成的 Markdown 写入 frontmatter: ```yaml --- embedding_local_only: true --- ``` 解析器读取标记,索引向路由传入 `local_only=True`,跳过远程绑定与凭据解析。普通笔记保持 API 优先和本地回退。本地向量失败时,普通保存仍可保留正文与全文索引;严格重建报错并保留旧索引。 标记随 Vault 持久化,编辑保留标记和重建时继续生效。此标记约束 Embedding,不是笔记的通用联网权限;显式开启远程聊天知识库仍可能提供相关片段。旧导出笔记不会自动补标记,必要时应人工补入;删除标记恢复普通索引路由。 ## 7. F-07:异步推理中的模型空间一致性 ### 原因与后果 请求开始使用 Bekko,推理期间切到 Granite,结束时重新读取 `model_id` 就可能把旧向量标为新模型。两者都是 384 维,维度校验无法发现错误。 ### 实际方案 进入本地路径时调用 `LocalEmbedding.snapshot()` 复制模型和设备配置。通过请求级 `ContextVar` 将相同配置传给 Runtime,结束或异常时恢复上下文;返回模型标识取自同一快照,下一请求使用新设置。 快照仅在实际进入本地路径时创建,避免正常 API 请求额外依赖本地配置。API 的 URL、模型、维度和请求扩展冻结规则保持不变。 ## 8. 回退行为与验证 | 场景 | 预期 | | --- | --- | | 普通请求,API 有效 | 使用 API,不执行本地推理 | | 普通请求,无 API | 使用本地模型 | | 普通请求,API 失败或响应无效 | 回退本地,保留 `fallback_reason` | | 本地限定索引,存在 API | 不请求 API、不解析远程凭据 | | 本地限定后再发普通请求 | API 仍可调用,不泄漏临时限制 | | 推理期间修改设置 | 当前向量与身份一致,下一请求采用新设置 | | 普通与仅本地笔记共存 | 分区重建,各自空间内检索,再融合排名 | | 同一策略内空间漂移或覆盖不完整 | 严格重建拒绝提交,整体回滚 | ### F-08:混合处理策略的重建与检索 提交审阅时用隔离数据库复现:一篇普通 API 笔记与一篇本地限定笔记共存,配置未变化,全量重建仍返回 `EMBEDDING_SPACE_CHANGED`。原因是重建将全部笔记约束到一个空间,查询也要求单个空间覆盖全部 Block,未区分处理策略。 本轮追加 SQLite v6,为 Block 保存 `embedding_local_only` 策略。重建分别检查普通和仅本地策略的空间一致性及完整覆盖,仍在同一事务提交;同一策略内模型改变、缺失向量或存储失败仍整体回滚。正常 API 回退不受跨策略差异影响。 查询按策略生成对应查询向量,在同一个数据库快照中检查两个分区。各分区独立计算相似度,再用 RRF 融合排名,不直接比较不同模型的向量或余弦分数。仅含本地限定笔记时,查询也不请求远程 Embedding;分区失效时纯向量明确报错,混合查询仍可退到全文。 已有数据库升级后应重建一次索引,将 Vault 中的策略标记同步至 Block。新增和更新笔记自动同步。回归覆盖混合策略、全部本地回退、仅本地查询、单分区缺失和跨分区写入失败回滚;此项合并阻碍已修复。 本轮在 `backend/` 执行: ```powershell .venv/Scripts/python.exe -m pytest tests/test_model_routing.py tests/test_media_jobs.py tests/test_routed_retrieval.py tests/test_local_models.py -q -p no:cacheprovider ``` 结果:132 项通过,覆盖 API 回退、本地限定导出和重建、模型切换及 Windows 子进程路径,不调用真实外部 API;有既有 Starlette/httpx 弃用提示。 前序持久化修复记录:后端搜索历史、聊天与媒体相关 9 项,前端搜索与聊天 5 项及类型检查通过。各轮结果是针对性验证,不相加当作全仓测试数。 ## 9. 工程经验 ### F-12:普通分割线与元数据头部消歧 F-11 修复后,`---` 和 `---\n\n# Title\n\n正文` 等合法 Markdown 被误判为未闭合 frontmatter,原先能够保存的笔记被拒绝;库中已有此类文件时全量重建也会失败。 实际方案:开头分隔线仅作为候选,继续判断内容是否声明元数据。YAML 映射、以键值形式开始的头部或显式 `embedding_local_only` 声明按元数据处理,缺少结束行仍报错;普通段落、标题和代码块按正文保留,包括之后再次出现分割线的情况。已有闭合空头部继续兼容。 显式本地策略即使与其他损坏的 YAML 行共存,也不能退成普通正文。无结束分隔符的键值头部仍视为错误;普通文章中有歧义的开头键值形式应避免紧随文件首行 `---`。回归覆盖分割线正文解析、真实保存和重建、BOM 与本地策略原有拒绝规则。 围栏代码块中的策略示例不算真实声明,保留为 Markdown 正文。验证记录:首批修复后全量后端 542 项通过;补充围栏示例识别后,解析、迁移与检索相关 130 项通过。本轮未修改前端,未调用真实外部模型。 ### F-11:frontmatter 边界与 BOM 审阅通过隔离保存链路复现:普通 `---` 头部返回 `local_only=True`,加 UTF-8 BOM 或移除结束分隔线后却返回 `False`。原因是策略、元数据和正文分别使用 `startswith` 与子串查找判断头部;未识别成功时静默按无策略处理。 实际方案:三处改用 `_frontmatter` 统一识别。允许一个文件起始 BOM,开头分隔符须为独立的 `---` 行,结束分隔符支持独立的 `---` 或 `...` 行及尾部空白;支持 LF、CRLF、CR。`---metadata`、`----` 等前缀不会被误当成结束分隔符。已识别开头但没有结束行时返回 `INVALID_EMBEDDING_POLICY`,不继续索引。 原 Markdown 不做去 BOM 或换行转换,正文偏移仍由原文计算 UTF-16 code unit,保证来源定位。保存和重建使用同一解析路径;更新失败恢复原文件。测试覆盖 BOM 的本地限定保存与重建,未闭合更新不触发模型调用且文件、数据库正文保持原值。 F-11 修复后完整后端回归:533 项通过,新增 17 个参数化用例。普通 API 与本地回退、分区检索及迁移测试均通过,未调用真实外部模型。 ### F-09:本地限制标记的 YAML 解析 再次审阅复现:`embedding_local_only: true # keep local` 被旧字符串比较解析为 `False`。加注释没有改变用户意图,却可能使保存或重建发送正文到远程 Embedding。 实际方案:使用 PyYAML SafeLoader 解析 frontmatter 节点,不构造任意对象;读取布尔节点,支持注释、带引号的键、缩进、多行布尔值和布尔锚点。普通的 `true/false` 与 YAML 布尔别名 `yes/no/on/off` 均按布尔值处理。字符串 `"true"`、数字、空值、非法值及重复声明返回 `INVALID_EMBEDDING_POLICY`,不静默转为普通索引。 无效 YAML、非映射 frontmatter 和 YAML 合并键也明确拒绝;合并键应展开为显式声明,以避免遗漏继承的限制。标题与标签的既有提取方式保持不变。回归包含添加行尾注释后保存笔记、重建仍只走本地索引。 ### F-10:数据库迁移中断恢复 旧 `executescript` 先提交 DDL,随后才写入 `schema_migrations`。若 v6 新增列后中断,重启会再次执行 `ALTER TABLE`,产生 `duplicate column name: embedding_local_only`。 实际方案:用 SQLite 的完整语句检测拆分静态迁移脚本,逐句执行,避免 `executescript` 隐式提交。每个版本在 `BEGIN IMMEDIATE` 事务内执行 DDL 和版本写入;异常包括中断均回滚。获取写锁后重新检查版本,防止多个连接重复迁移。连接初始化失败时主动关闭连接。 兼容恢复仅针对旧版已发生的 v6 半迁移:确认现有列为预期的 `INTEGER NOT NULL DEFAULT 0` 后补记版本,不再重复新增列;形状不符合预期则报错,不擅自更改数据。测试注入写版本失败和中断,验证列与版本同时回滚、重新连接升级成功,并覆盖旧半迁移与并发连接升级。 F-09/F-10 修复后完整后端回归:516 项通过,新增 21 个参数化用例;仍仅有既有 Starlette/httpx 弃用提示。测试均使用隔离数据,不调用外部模型 API。 F-08 修复后完整后端回归:495 项通过;新增 5 个用例覆盖混合策略重建与查询、全部本地回退、仅本地查询不访问 API、跨分区回滚和不完整分区。现有同策略空间漂移拒绝用例仍通过。 区分配置、权重安装、推理运行、索引覆盖四种状态;按用户实际启动方式验证;跨异步边界冻结身份;持久化处理限制;增加限制时也验证普通 API 回退没有被破坏。