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

This commit is contained in:
2026-09-04 19:33:57 +08:00
parent 5234f20522
commit d1cffb2f40
8 changed files with 97 additions and 13 deletions
+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