Feat/multimodal pipeline #19

Merged
Kronecker merged 9 commits from feat/multimodal-pipeline into main 2026-09-04 20:17:03 +08:00
11 changed files with 235 additions and 13 deletions
Showing only changes of commit 468eb56daa - Show all commits
+2
View File
@@ -31,6 +31,7 @@ class ParsedNote:
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
blocks: list[NoteBlock] = field(default_factory=list) blocks: list[NoteBlock] = field(default_factory=list)
embedding_local_only: bool = False
def note_id_for_path(rel_path: str) -> str: def note_id_for_path(rel_path: str) -> str:
@@ -69,6 +70,7 @@ def parse_note(
created_at=created_at, created_at=created_at,
updated_at=updated_at, updated_at=updated_at,
blocks=blocks, 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: class LocalEmbedding:
dim = 384 dim = 384
def __init__(self, config=None):
self._config = config
def snapshot(self):
return LocalEmbedding((self._config or configuration()).model_copy(deep=True))
@property @property
def model_id(self): def model_id(self):
spec = CATALOG[configuration().embedding_model] spec = CATALOG[(self._config or configuration()).embedding_model]
return f"{spec.repository}@{spec.revision}" return f"{spec.repository}@{spec.revision}"
@property @property
def version(self): def version(self):
return CATALOG[configuration().embedding_model].revision return CATALOG[(self._config or configuration()).embedding_model].revision
@property @property
def available(self): def available(self):
return read_state(configuration().embedding_model)["status"] == "installed" and interpreter().is_file() return read_state(configuration().embedding_model)["status"] == "installed" and interpreter().is_file()
async def embed_documents(self, texts): 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): async def embed_query(self, query):
return (await self.embed_documents([query]))[0] return (await self.embed_documents([query]))[0]
+7 -5
View File
@@ -206,9 +206,9 @@ class ModelRoutingService:
raise invalid_response() raise invalid_response()
return data, url 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() config = self.configuration()
binding = config.embedding binding = None if local_only else config.embedding
record_embedding(route_version=config.version, record_embedding(route_version=config.version,
requested_route=binding.model_dump() if binding else None) requested_route=binding.model_dump() if binding else None)
reason = None reason = None
@@ -255,12 +255,14 @@ class ModelRoutingService:
model_id="api-" + hashlib.sha256(identity.encode()).hexdigest()) model_id="api-" + hashlib.sha256(identity.encode()).hexdigest())
except ProviderError as exc: except ProviderError as exc:
reason = exc.code 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: try:
vectors = await self.local_embedding.embed_documents(texts) vectors = await local_embedding.embed_documents(texts)
except ProviderError as exc: except ProviderError as exc:
raise ApiError(503, exc.code, exc.message, {"fallback_reason": reason}) from 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, return EmbeddingResult(vectors=vectors, source="local", model_id=local_embedding.model_id,
dimensions=self.local_embedding.dim, fallback_reason=reason) dimensions=local_embedding.dim, fallback_reason=reason)
@staticmethod @staticmethod
def _media_file(path: Path): def _media_file(path: Path):
+3 -3
View File
@@ -35,7 +35,7 @@ class EmbeddingResult(Protocol):
class EmbeddingRuntime(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) @dataclass(frozen=True)
@@ -69,7 +69,7 @@ def _unit_vector(vector: list[float], dimensions: int) -> list[float]:
return [value / norm for value in scaled] 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. """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 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: if strict:
raise ApiError(503, "EMBEDDING_UNAVAILABLE", "Embedding 服务未就绪,请检查模型路由和本地运行环境。") raise ApiError(503, "EMBEDDING_UNAVAILABLE", "Embedding 服务未就绪,请检查模型路由和本地运行环境。")
return None 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: if result.source != "api" and not accept_local:
record_embedding(fallback_reason=result.fallback_reason) record_embedding(fallback_reason=result.fallback_reason)
return None return None
+3
View File
@@ -41,6 +41,9 @@ async def create_transcript_note(job_id, options):
lines.append("") lines.append("")
else: else:
lines.append(job.text or "") 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: try:
note = await note_service.create_note(title=title, markdown="\n".join(lines), folder=options.folder, tags=["转写"]) note = await note_service.create_note(title=title, markdown="\n".join(lines), folder=options.folder, tags=["转写"])
except ApiError as exc: 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] texts = [block.content for block in parsed.blocks]
if isinstance(embedding, LocalEmbedding): if isinstance(embedding, LocalEmbedding):
# One routed invocation: API first, validated local fallback. No hash vectors. # 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 return [], remote
vectors = await embedding.embed_documents(texts) 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 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 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.post(f'/api/media/transcriptions/{job_id}/retry').status_code == 409
assert client.get('/api/media/attachments/lecture.txt').status_code == 404 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) count = len(rig.requests)
result = run(rig.service.transcribe(audio[0], "zh", local_only=True)) result = run(rig.service.transcribe(audio[0], "zh", local_only=True))
assert result.source == "local" and len(rig.requests) == count 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) - [Knowledge 与 Retrieval Core 问题与修复复盘](retrospectives/Knowledge与Retrieval-Core问题与修复复盘.md)
- [Plugin Command 与 Settings 问题与修复复盘](retrospectives/Plugin-Command与Settings问题与修复复盘.md) - [Plugin Command 与 Settings 问题与修复复盘](retrospectives/Plugin-Command与Settings问题与修复复盘.md)
- [前端合并审阅问题与修复复盘](retrospectives/前端合并审阅问题与修复复盘.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 页面、播放器时长/定位、模型与用量卡片无页面异常。 2026-09-04Windows / Python 3.12 / torch 2.9.1+cpu:后端 472 项、前端 93 项测试通过,类型检查和生产构建通过,仍有既有大 bundle 警告。Edge 真实 API 页面、播放器时长/定位、模型与用量卡片无页面异常。
真实模型完成音频 → 转写 → 片段声纹 → 笔记 → 语义检索闭环。示例来自固定 ModelScope revision;权重和音频不提交仓库。 真实模型完成音频 → 转写 → 片段声纹 → 笔记 → 语义检索闭环。示例来自固定 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 回退没有被破坏。