From 468eb56daa8bfd969e3faef188259f50ab5d23e5 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Fri, 4 Sep 2026 19:33:57 +0800 Subject: [PATCH] =?UTF-8?q?fix(embedding):=20=E4=BC=A0=E9=80=92=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E7=B4=A2=E5=BC=95=E9=99=90=E5=88=B6=E5=B9=B6=E5=86=BB?= =?UTF-8?q?=E7=BB=93=E6=8E=A8=E7=90=86=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/knowledge/parser.py | 2 + backend/app/local_models/runtime.py | 17 ++- backend/app/providers/routing.py | 12 +- backend/app/retrieval/routed_vectors.py | 6 +- backend/app/services/media_notes.py | 3 + backend/app/services/note_service.py | 4 +- backend/tests/test_media_jobs.py | 26 ++++ backend/tests/test_model_routing.py | 40 ++++++ docs/README.md | 1 + .../多模态管线与模型运行开发说明.md | 8 ++ .../阶段F-Embedding与知识库问题与解决方案.md | 129 ++++++++++++++++++ 11 files changed, 235 insertions(+), 13 deletions(-) create mode 100644 docs/retrospectives/阶段F-Embedding与知识库问题与解决方案.md diff --git a/backend/app/knowledge/parser.py b/backend/app/knowledge/parser.py index 268525a..d3217f5 100644 --- a/backend/app/knowledge/parser.py +++ b/backend/app/knowledge/parser.py @@ -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", ) diff --git a/backend/app/local_models/runtime.py b/backend/app/local_models/runtime.py index 01f93fc..f31e6ed 100644 --- a/backend/app/local_models/runtime.py +++ b/backend/app/local_models/runtime.py @@ -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] diff --git a/backend/app/providers/routing.py b/backend/app/providers/routing.py index 580da01..dcd009e 100644 --- a/backend/app/providers/routing.py +++ b/backend/app/providers/routing.py @@ -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): diff --git a/backend/app/retrieval/routed_vectors.py b/backend/app/retrieval/routed_vectors.py index 12286af..666526c 100644 --- a/backend/app/retrieval/routed_vectors.py +++ b/backend/app/retrieval/routed_vectors.py @@ -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 diff --git a/backend/app/services/media_notes.py b/backend/app/services/media_notes.py index cf0f938..e2ec13e 100644 --- a/backend/app/services/media_notes.py +++ b/backend/app/services/media_notes.py @@ -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: diff --git a/backend/app/services/note_service.py b/backend/app/services/note_service.py index 58f9e40..3c7da5c 100644 --- a/backend/app/services/note_service.py +++ b/backend/app/services/note_service.py @@ -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 diff --git a/backend/tests/test_media_jobs.py b/backend/tests/test_media_jobs.py index cb085c2..20ecfa4 100644 --- a/backend/tests/test_media_jobs.py +++ b/backend/tests/test_media_jobs.py @@ -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()) diff --git a/backend/tests/test_model_routing.py b/backend/tests/test_model_routing.py index 1205e5b..985017b 100644 --- a/backend/tests/test_model_routing.py +++ b/backend/tests/test_model_routing.py @@ -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 diff --git a/docs/README.md b/docs/README.md index 82070c0..7c4f180 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) ## 推荐阅读顺序 diff --git a/docs/development/多模态管线与模型运行开发说明.md b/docs/development/多模态管线与模型运行开发说明.md index 510c053..5d2a832 100644 --- a/docs/development/多模态管线与模型运行开发说明.md +++ b/docs/development/多模态管线与模型运行开发说明.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-04,Windows / Python 3.12 / torch 2.9.1+cpu:后端 472 项、前端 93 项测试通过,类型检查和生产构建通过,仍有既有大 bundle 警告。Edge 真实 API 页面、播放器时长/定位、模型与用量卡片无页面异常。 真实模型完成音频 → 转写 → 片段声纹 → 笔记 → 语义检索闭环。示例来自固定 ModelScope revision;权重和音频不提交仓库。 diff --git a/docs/retrospectives/阶段F-Embedding与知识库问题与解决方案.md b/docs/retrospectives/阶段F-Embedding与知识库问题与解决方案.md new file mode 100644 index 0000000..55f0759 --- /dev/null +++ b/docs/retrospectives/阶段F-Embedding与知识库问题与解决方案.md @@ -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-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 仍可调用,不泄漏临时限制 | +| 推理期间修改设置 | 当前向量与身份一致,下一请求采用新设置 | +| 空间混用或索引覆盖不完整 | 严格重建拒绝提交,不混合不同向量空间 | + +本地限定笔记与远程索引并存时,现有完整覆盖检查可能使纯向量查询提示不完整、重建拒绝混合空间;混合检索仍可退到全文检索。不能为获得完整远程索引而绕过本地标记。 + +提交审阅时已用隔离数据库复现:一篇普通 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 回退没有被破坏。