fix(embedding): 传递本地索引限制并冻结推理配置

This commit is contained in:
2026-09-04 19:33:57 +08:00
parent 1d0f19508a
commit 468eb56daa
11 changed files with 235 additions and 13 deletions
+2
View File
@@ -31,6 +31,7 @@ class ParsedNote:
created_at: datetime
updated_at: datetime
blocks: list[NoteBlock] = field(default_factory=list)
embedding_local_only: bool = False
def note_id_for_path(rel_path: str) -> str:
@@ -69,6 +70,7 @@ def parse_note(
created_at=created_at,
updated_at=updated_at,
blocks=blocks,
embedding_local_only=str(frontmatter.get("embedding_local_only", "")).lower() == "true",
)
+14 -3
View File
@@ -165,21 +165,32 @@ runtime = Runtime()
class LocalEmbedding:
dim = 384
def __init__(self, config=None):
self._config = config
def snapshot(self):
return LocalEmbedding((self._config or configuration()).model_copy(deep=True))
@property
def model_id(self):
spec = CATALOG[configuration().embedding_model]
spec = CATALOG[(self._config or configuration()).embedding_model]
return f"{spec.repository}@{spec.revision}"
@property
def version(self):
return CATALOG[configuration().embedding_model].revision
return CATALOG[(self._config or configuration()).embedding_model].revision
@property
def available(self):
return read_state(configuration().embedding_model)["status"] == "installed" and interpreter().is_file()
async def embed_documents(self, texts):
return await runtime.infer(configuration().embedding_model, "embedding", {"texts": texts}, priority=0)
config = (self._config or configuration()).model_copy(deep=True)
token = runtime_context.set(config)
try:
return await runtime.infer(config.embedding_model, "embedding", {"texts": texts}, priority=0)
finally:
runtime_context.reset(token)
async def embed_query(self, query):
return (await self.embed_documents([query]))[0]
+7 -5
View File
@@ -206,9 +206,9 @@ class ModelRoutingService:
raise invalid_response()
return data, url
async def embed(self, texts: list[str]) -> EmbeddingResult:
async def embed(self, texts: list[str], *, local_only=False) -> EmbeddingResult:
config = self.configuration()
binding = config.embedding
binding = None if local_only else config.embedding
record_embedding(route_version=config.version,
requested_route=binding.model_dump() if binding else None)
reason = None
@@ -255,12 +255,14 @@ class ModelRoutingService:
model_id="api-" + hashlib.sha256(identity.encode()).hexdigest())
except ProviderError as exc:
reason = exc.code
from app.local_models.runtime import LocalEmbedding
local_embedding = self.local_embedding.snapshot() if isinstance(self.local_embedding, LocalEmbedding) else self.local_embedding
try:
vectors = await self.local_embedding.embed_documents(texts)
vectors = await local_embedding.embed_documents(texts)
except ProviderError as exc:
raise ApiError(503, exc.code, exc.message, {"fallback_reason": reason}) from exc
return EmbeddingResult(vectors=vectors, source="local", model_id=self.local_embedding.model_id,
dimensions=self.local_embedding.dim, fallback_reason=reason)
return EmbeddingResult(vectors=vectors, source="local", model_id=local_embedding.model_id,
dimensions=local_embedding.dim, fallback_reason=reason)
@staticmethod
def _media_file(path: Path):
+3 -3
View File
@@ -35,7 +35,7 @@ class EmbeddingResult(Protocol):
class EmbeddingRuntime(Protocol):
async def embed(self, texts: list[str]) -> EmbeddingResult: ...
async def embed(self, texts: list[str], *, local_only=False) -> EmbeddingResult: ...
@dataclass(frozen=True)
@@ -69,7 +69,7 @@ def _unit_vector(vector: list[float], dimensions: int) -> list[float]:
return [value / norm for value in scaled]
async def embed_remote(texts: list[str], *, accept_local=False, strict=False) -> RemoteEmbeddings | None:
async def embed_remote(texts: list[str], *, accept_local=False, strict=False, local_only=False) -> RemoteEmbeddings | None:
"""Return validated API vectors, or None to use the caller's local baseline.
Do not use the runtime's local result: the caller may have injected its own
@@ -83,7 +83,7 @@ async def embed_remote(texts: list[str], *, accept_local=False, strict=False) ->
if strict:
raise ApiError(503, "EMBEDDING_UNAVAILABLE", "Embedding 服务未就绪,请检查模型路由和本地运行环境。")
return None
result = await runtime.embed(texts)
result = await runtime.embed(texts, local_only=True) if local_only else await runtime.embed(texts)
if result.source != "api" and not accept_local:
record_embedding(fallback_reason=result.fallback_reason)
return None
+3
View File
@@ -41,6 +41,9 @@ async def create_transcript_note(job_id, options):
lines.append("")
else:
lines.append(job.text or "")
if job.local_only:
# Persist the indexing policy in the Vault, including later rebuilds.
lines = ["---", "embedding_local_only: true", "---", "", *lines]
try:
note = await note_service.create_note(title=title, markdown="\n".join(lines), folder=options.folder, tags=["转写"])
except ApiError as exc:
+2 -2
View File
@@ -82,10 +82,10 @@ async def prepare_note_index(parsed: ParsedNote, *, strict=False) -> PreparedInd
texts = [block.content for block in parsed.blocks]
if isinstance(embedding, LocalEmbedding):
# One routed invocation: API first, validated local fallback. No hash vectors.
remote = await routed_vectors.embed_remote(texts, accept_local=True, strict=strict)
remote = await routed_vectors.embed_remote(texts, accept_local=True, strict=strict, local_only=parsed.embedding_local_only)
return [], remote
vectors = await embedding.embed_documents(texts)
remote = await routed_vectors.embed_remote(texts)
remote = await routed_vectors.embed_remote(texts, local_only=parsed.embedding_local_only)
return vectors, remote
+26
View File
@@ -102,3 +102,29 @@ def test_terminology_export_and_privacy_cleanup():
assert cleaned['text'] is None and cleaned['original_text'] is None and cleaned['corrections'] == []
assert client.post(f'/api/media/transcriptions/{job_id}/retry').status_code == 409
assert client.get('/api/media/attachments/lecture.txt').status_code == 404
def test_local_only_export_and_rebuild_keep_local_embedding_policy(monkeypatch):
from types import SimpleNamespace
from app.contracts import TranscriptNoteRequest, IndexRebuildRequest
from app.local_models.runtime import LocalEmbedding
from app.retrieval import routed_vectors
from app.services import note_service, index_service
from app.services.media_notes import create_transcript_note
calls = []
class Routing:
async def embed(self, texts, *, local_only=False):
calls.append(local_only)
assert local_only
return SimpleNamespace(source='local', model_id='local-test', dimensions=2,
vectors=[[1.0, 0.0] for _ in texts], fallback_reason=None)
monkeypatch.setattr(routed_vectors, 'get_model_routing', lambda: Routing())
monkeypatch.setattr(note_service, 'embedding', LocalEmbedding())
text_attachment()
async def scenario():
job = await jobs.create_transcription('lecture.txt', local_only=True)
note = await create_transcript_note(job.job_id, TranscriptNoteRequest(title='Private'))
assert note.markdown.startswith('---\nembedding_local_only: true\n---')
await index_service.rebuild(IndexRebuildRequest())
assert len(calls) >= 2 and all(calls)
asyncio.run(scenario())
+40
View File
@@ -678,3 +678,43 @@ def test_remote_segments_are_validated_and_local_only_skips_api(rig, audio):
count = len(rig.requests)
result = run(rig.service.transcribe(audio[0], "zh", local_only=True))
assert result.source == "local" and len(rig.requests) == count
def test_embedding_local_only_does_not_change_normal_api_fallback(rig):
bind(rig)
result = run(rig.service.embed(['private'], local_only=True))
assert result.source == 'local' and result.fallback_reason is None
assert rig.requests == [] and rig.credentials.calls == []
rig.http.handler = lambda request: response({'data': [{'index': 0, 'embedding': [1, 0, 0]}]})
assert run(rig.service.embed(['normal'])).source == 'api'
rig.http.handler = lambda request: response({}, status=503)
result = run(rig.service.embed(['fallback']))
assert result.source == 'local' and result.fallback_reason
@pytest.mark.parametrize('api_failure', [False, True])
def test_local_embedding_identity_and_device_are_frozen_during_inference(rig, monkeypatch, api_failure):
import app.local_models.runtime as module
config = module.RuntimeConfig(embedding_model='bekko')
monkeypatch.setattr(module, 'configuration', lambda: module.runtime_context.get() or config)
calls = []
async def infer(key, *args, **kwargs):
calls.append(key)
config.embedding_model = 'granite'
config.device = 'cuda'
await asyncio.sleep(0)
assert module.configuration().embedding_model == key
assert module.configuration().device == ('cpu' if len(calls) == 1 else 'cuda')
return [[1.0] + [0.0] * 383]
monkeypatch.setattr(module.runtime, 'infer', infer)
rig.service.local_embedding = module.LocalEmbedding()
if api_failure:
bind(rig)
rig.http.handler = lambda request: response({}, status=503)
first = run(rig.service.embed(['first']))
assert 'bekko' in first.model_id
assert module.runtime_context.get() is None
second = run(rig.service.embed(['second']))
assert 'granite' in second.model_id
assert calls == ['bekko', 'granite']
assert bool(first.fallback_reason) == api_failure
+1
View File
@@ -55,6 +55,7 @@
- [Knowledge 与 Retrieval Core 问题与修复复盘](retrospectives/Knowledge与Retrieval-Core问题与修复复盘.md)
- [Plugin Command 与 Settings 问题与修复复盘](retrospectives/Plugin-Command与Settings问题与修复复盘.md)
- [前端合并审阅问题与修复复盘](retrospectives/前端合并审阅问题与修复复盘.md)
- [阶段 F:Embedding 与知识库问题与解决方案](retrospectives/阶段F-Embedding与知识库问题与解决方案.md)
## 推荐阅读顺序
@@ -93,6 +93,14 @@ POST /api/providers/request-preview 不联网,隐藏正文/文件且不包含
## 验证记录
### 2026-09-04 联调修复补充
Windows 热重载不支持异步子进程时使用线程管道兼容路径。本地 Embedding 进入推理前冻结模型与设备配置,向量空间标识来自同一快照;普通 API 成功及失败回退策略保持不变。
仅本地转写新生成的笔记增加 `embedding_local_only: true` frontmatter,索引与后续重建跳过远程 Embedding。它只约束索引,不是通用的笔记联网权限;旧导出笔记需人工补标记。本地和 API 空间不混用,无法完整覆盖时保留全文检索能力。
搜索记录保存在应用 SQLite,使用 `/api/search/history` GET/DELETE 读取和清空。聊天已接入真实知识库上下文与 Citation。详细原因和验证见[阶段 F 问题与解决方案](../retrospectives/阶段F-Embedding与知识库问题与解决方案.md)。
2026-09-04Windows / Python 3.12 / torch 2.9.1+cpu:后端 472 项、前端 93 项测试通过,类型检查和生产构建通过,仍有既有大 bundle 警告。Edge 真实 API 页面、播放器时长/定位、模型与用量卡片无页面异常。
真实模型完成音频 → 转写 → 片段声纹 → 笔记 → 语义检索闭环。示例来自固定 ModelScope revision;权重和音频不提交仓库。
@@ -0,0 +1,129 @@
# 阶段 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 和设备配置 |
## 3. F-01 / F-03:模型可用不等于索引可用
### 原因与后果
`embed_remote` 捕获异常后返回 `None`,上层将调用失败与索引缺失统一显示为“请配置 Embedding”。默认库曾有 29 个 Block 和模型元信息,但没有向量侧表。普通保存允许降级保留正文,这一策略又被用于严格重建,导致没有向量也可能报告完成。异常回溯保留查询游标时,还会影响后续写事务。
### 解决思路与实际方案
纯向量查询使用严格错误处理:模型失败保留安全错误码;模型可用但索引缺失时返回 `SEMANTIC_INDEX_UNAVAILABLE`,明确提示重建。混合检索仍可退到全文检索。重建先准备向量,再进入事务,检查空间一致和完整覆盖,失败保留旧索引。查询游标在 `finally` 中关闭。
前序真实本地验证:重建后 29 个 Block 对应 29 条向量,查询返回 20 条结果。这是当时样本库的历史记录,不代表全部环境与规模。
## 4. F-02Windows 热重载下本地模型无法启动
### 原因与后果
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 仍可调用,不泄漏临时限制 |
| 推理期间修改设置 | 当前向量与身份一致,下一请求采用新设置 |
| 空间混用或索引覆盖不完整 | 严格重建拒绝提交,不混合不同向量空间 |
本地限定笔记与远程索引并存时,现有完整覆盖检查可能使纯向量查询提示不完整、重建拒绝混合空间;混合检索仍可退到全文检索。不能为获得完整远程索引而绕过本地标记。
提交审阅时已用隔离数据库复现:一篇普通 API 笔记与一篇本地限定笔记共存,配置未变化,全量重建仍返回 `EMBEDDING_SPACE_CHANGED`。此项列为合并阻碍,尚未修复;后续需区分预期的处理策略差异和实际配置漂移,并明确各向量空间的索引覆盖范围。
本轮在 `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. 工程经验
区分配置、权重安装、推理运行、索引覆盖四种状态;按用户实际启动方式验证;跨异步边界冻结身份;持久化处理限制;增加限制时也验证普通 API 回退没有被破坏。