From 9792bb50b8b7e345e26264f04f69c0b81b54522b Mon Sep 17 00:00:00 2001 From: Backend Contributors Date: Fri, 16 Oct 2026 09:11:33 +0800 Subject: [PATCH 1/2] feat: evaluate pinned local embedding models on CPU --- README.md | 7 +- backend/app/services/embedding.py | 161 ++++++++++++++++++ backend/evaluation/__init__.py | 1 + backend/evaluation/local_embeddings.py | 221 +++++++++++++++++++++++++ backend/evaluation/synthetic_zh.json | 30 ++++ backend/requirements-embedding-cpu.txt | 8 + backend/tests/test_embedding.py | 120 ++++++++++++++ docs/ARCHITECTURE.md | 5 + docs/DECISIONS.md | 7 + docs/LOCAL_EMBEDDING_EVALUATION.md | 77 +++++++++ docs/TASKS.md | 11 ++ 11 files changed, 647 insertions(+), 1 deletion(-) create mode 100644 backend/app/services/embedding.py create mode 100644 backend/evaluation/__init__.py create mode 100644 backend/evaluation/local_embeddings.py create mode 100644 backend/evaluation/synthetic_zh.json create mode 100644 backend/requirements-embedding-cpu.txt create mode 100644 backend/tests/test_embedding.py create mode 100644 docs/LOCAL_EMBEDDING_EVALUATION.md diff --git a/README.md b/README.md index c2e1227..5ae7df3 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ Vite 通常使用 http://localhost:5173。 前端包括问答、知识库管理、来源与更新、实验评估四个页面。实验评估页面没有虚构数据;所有页面明确标记当前功能状态。 -注意:当前没有实现 Embedding、RAG、网络搜索、后台更新任务或用户认证。审核人是本机开发环境输入字段,不是认证身份。服务保持仅本机访问;正式部署能力留到后续阶段。`.env` 不要提交到 Git。 +注意:当前 API 尚未接入 Embedding、RAG、网络搜索、后台更新任务或用户认证;本地 Embedding 评估工具单独见下方。审核人是本机开发环境输入字段,不是认证身份。服务保持仅本机访问;正式部署能力留到后续阶段。`.env` 不要提交到 Git。 ### 后端测试和迁移回滚 @@ -108,3 +108,8 @@ pytest -q - [技术决策](docs/DECISIONS.md) 目标 Gitea 仓库:`https://gitea.kronecker.cc/saint/border-red-post-nalati`。 + + +## 本地 Embedding 评估(可选) + +已提供独立 CPU 评估工具、固定版本 BGE Provider 和合成测试集。安装、实际资源/编码结果、指标定义及局限见 [本地模型评估](docs/LOCAL_EMBEDDING_EVALUATION.md)。模型包与缓存不属于默认 FastAPI 运行依赖;当前评估不改变正式 API 或写入业务数据库。 diff --git a/backend/app/services/embedding.py b/backend/app/services/embedding.py new file mode 100644 index 0000000..5ecd5d4 --- /dev/null +++ b/backend/app/services/embedding.py @@ -0,0 +1,161 @@ +"""Optional local embeddings; importing this module never loads model weights or PyTorch.""" + +import hashlib +import json +import math +import re +from dataclasses import asdict, dataclass +from typing import Protocol + +QUERY_INSTRUCTION = "为这个句子生成表示以用于检索相关文章:" + + +class EmbeddingError(ValueError): + pass + + +@dataclass(frozen=True) +class EmbeddingSpec: + model: str + revision: str + dimension: int + token_limit: int = 512 + query_instruction: str = QUERY_INSTRUCTION + implementation: str = "hf-cls-l2-cpu-fp32-v1" + checkpoint_format: str = "safetensors" + + def __post_init__(self): + if not isinstance(self.revision, str) or not re.fullmatch(r"[0-9a-f]{40}", self.revision): + raise EmbeddingError("模型必须固定到完整 revision SHA") + if ( + type(self.dimension) is not int + or type(self.token_limit) is not int + or self.dimension < 1 + or self.token_limit < 2 + ): + raise EmbeddingError("向量维度和 token 上限无效") + if not self.model or not isinstance(self.query_instruction, str): + raise EmbeddingError("模型名称或查询指令无效") + if self.checkpoint_format not in {"safetensors", "pytorch-weights-only"}: + raise EmbeddingError("不支持的权重格式") + + @property + def space_id(self) -> str: + return hashlib.sha256( + json.dumps(asdict(self), sort_keys=True, ensure_ascii=False).encode() + ).hexdigest() + + +SMALL_ZH = EmbeddingSpec("BAAI/bge-small-zh-v1.5", "7999e1d3359715c523056ef9478215996d62a620", 512) +BASE_ZH = EmbeddingSpec( + "BAAI/bge-base-zh-v1.5", + "f03589ceff5aac7111bd60cfc7d497ca17ecac65", + 768, + checkpoint_format="pytorch-weights-only", +) +LOCAL_MODELS = {"small": SMALL_ZH, "base": BASE_ZH} + + +class EmbeddingProvider(Protocol): + spec: EmbeddingSpec + + def encode(self, texts: list[str], *, query: bool = False) -> list[list[float]]: ... + + +def validate_vectors(vectors: list[list[float]], count: int, spec: EmbeddingSpec): + if len(vectors) != count: + raise EmbeddingError("模型返回数量与输入不一致") + for vector in vectors: + try: + finite = all(type(x) in {float, int} and math.isfinite(x) for x in vector) + except (TypeError, OverflowError): + finite = False + if len(vector) != spec.dimension or not finite: + raise EmbeddingError("模型返回维度错误或非有限数值") + if not math.isclose(sum(x * x for x in vector), 1, abs_tol=1e-4): + raise EmbeddingError("模型输出没有正确 L2 归一化") + + +class LocalEmbeddingProvider: + def __init__( + self, + spec: EmbeddingSpec, + *, + batch_size: int = 8, + threads: int = 2, + cache_dir=None, + offline: bool = True, + ): + if ( + type(batch_size) is not int + or type(threads) is not int + or not 1 <= batch_size <= 32 + or not 1 <= threads <= 4 + ): + raise EmbeddingError("CPU batch_size 必须为 1–32,threads 必须为 1–4") + import torch + from huggingface_hub import snapshot_download + from transformers import AutoModel, AutoTokenizer + + torch.set_num_threads(threads) + self.spec, self.batch_size, self.torch = spec, batch_size, torch + path = snapshot_download( + repo_id=spec.model, + revision=spec.revision, + cache_dir=cache_dir, + local_files_only=offline, + token=False, + allow_patterns=[ + "config.json", + "model.safetensors" + if spec.checkpoint_format == "safetensors" + else "pytorch_model.bin", + "tokenizer.json", + "tokenizer_config.json", + "special_tokens_map.json", + "vocab.txt", + "added_tokens.json", + ], + ) + self.tokenizer = AutoTokenizer.from_pretrained( + path, local_files_only=True, trust_remote_code=False + ) + self.model = ( + AutoModel.from_pretrained( + path, + local_files_only=True, + trust_remote_code=False, + use_safetensors=spec.checkpoint_format == "safetensors", + weights_only=True, + dtype=torch.float32, + ) + .to("cpu") + .eval() + ) + if self.model.config.hidden_size != spec.dimension: + raise EmbeddingError("模型实际维度与配置不一致") + if self.tokenizer.model_max_length != spec.token_limit: + raise EmbeddingError("模型 tokenizer 上限与配置不一致") + + def encode(self, texts: list[str], *, query: bool = False) -> list[list[float]]: + if any( + not isinstance(text, str) or not text.strip() or len(text) > 10_000 for text in texts + ): + raise EmbeddingError("每条输入须为非空文本且最多 10000 字符") + torch = self.torch + vectors = [] + for offset in range(0, len(texts), self.batch_size): + batch = texts[offset : offset + self.batch_size] + if query: + batch = [self.spec.query_instruction + text for text in batch] + encoded = self.tokenizer( + batch, padding=True, truncation=False, return_tensors="pt", verbose=False + ) + if encoded["input_ids"].shape[1] > self.spec.token_limit: + raise EmbeddingError("输入超出模型 token 上限;请重新切分,禁止静默截断") + with torch.inference_mode(): + features = self.model(**encoded).last_hidden_state[:, 0] + normalized = torch.nn.functional.normalize(features, p=2, dim=1) + vectors.extend(normalized.tolist()) + validate_vectors(vectors, len(texts), self.spec) + return vectors diff --git a/backend/evaluation/__init__.py b/backend/evaluation/__init__.py new file mode 100644 index 0000000..704246a --- /dev/null +++ b/backend/evaluation/__init__.py @@ -0,0 +1 @@ +"""Offline, synthetic local-model evaluation; never writes the application database.""" diff --git a/backend/evaluation/local_embeddings.py b/backend/evaluation/local_embeddings.py new file mode 100644 index 0000000..e278b1d --- /dev/null +++ b/backend/evaluation/local_embeddings.py @@ -0,0 +1,221 @@ +"""Reproducible CPU smoke evaluation; no application database or remote inference service.""" + +import argparse +import hashlib +import importlib.metadata +import json +import math +import platform +import statistics +import sys +from dataclasses import asdict +from pathlib import Path +from time import perf_counter + +from app.services.embedding import LOCAL_MODELS, EmbeddingError, LocalEmbeddingProvider + + +def load_dataset(path: Path) -> tuple[dict, str]: + with path.open("rb") as source: + raw = source.read(2_000_001) + if len(raw) > 2_000_000: + raise ValueError("评估输入不得超过 2MB") + data = json.loads(raw) + if not isinstance(data, dict): + raise ValueError("评估输入须为 JSON 对象") + documents, queries = data["documents"], data["queries"] + if not isinstance(documents, list) or not isinstance(queries, list): + raise ValueError("documents 与 queries 须为列表") + if not 1 <= len(documents) <= 1000 or not 1 <= len(queries) <= 1000: + raise ValueError("文档和问题各须为 1–1000 条") + for rows in [documents, queries]: + if any(not isinstance(row, dict) for row in rows): + raise ValueError("每条记录须为对象") + ids = [row["id"] for row in rows] + if any(not isinstance(x, str) or not x or len(x) > 100 for x in ids) or len( + set(ids) + ) != len(ids): + raise ValueError("每组 ID 必须非空、唯一且最多 100 字符") + if any( + not isinstance(row["text"], str) or not row["text"].strip() or len(row["text"]) > 10000 + for row in rows + ): + raise ValueError("每条文本须为非空且最多 10000 字符") + ids = {row["id"] for row in documents} + for query in queries: + relevant = query["relevant_ids"] + if ( + not isinstance(relevant, list) + or not relevant + or any(not isinstance(x, str) for x in relevant) + or len(set(relevant)) != len(relevant) + or not set(relevant) <= ids + ): + raise ValueError("相关文档标注必须非空、唯一并引用现有文档") + if not isinstance(data["dataset_id"], str) or not isinstance(data["purpose"], str): + raise ValueError("必须填写数据集 ID 与用途") + return data, hashlib.sha256(raw).hexdigest() + + +def rank_and_measure(documents, queries, document_vectors, query_vectors, *, top_k=3): + if not documents or not queries or top_k < 1: + raise ValueError("评估须有文档、问题且 top_k 为正数") + if len(document_vectors) != len(documents) or len(query_vectors) != len(queries): + raise ValueError("评估向量数量与输入不一致") + rows = [] + for query, vector in zip(queries, query_vectors, strict=True): + ranked = sorted( + [ + (doc["id"], math.fsum(x * y for x, y in zip(vector, embedding, strict=True))) + for doc, embedding in zip(documents, document_vectors, strict=True) + ], + key=lambda pair: (-pair[1], pair[0]), + ) + relevant = set(query["relevant_ids"]) + first = next( + (rank for rank, (id_, _) in enumerate(ranked[:top_k], 1) if id_ in relevant), None + ) + rows.append( + { + "query_id": query["id"], + "top_results": [ + {"id": id_, "score": round(score, 8)} for id_, score in ranked[:top_k] + ], + "hit_at_1": int(ranked[0][0] in relevant), + "recall_at_k": len({id_ for id_, _ in ranked[:top_k]} & relevant) / len(relevant), + "reciprocal_rank_at_k": 1 / first if first else 0, + } + ) + return { + "top_k": top_k, + "hit_at_1": statistics.mean(row["hit_at_1"] for row in rows), + "recall_at_k": statistics.mean(row["recall_at_k"] for row in rows), + "mrr_at_k": statistics.mean(row["reciprocal_rank_at_k"] for row in rows), + "per_query": rows, + } + + +def measure(call, repeats): + call() # Warm this exact input shape before taking the recorded samples. + timings = [] + result = None + for _ in range(repeats): + start = perf_counter() + result = call() + timings.append((perf_counter() - start) * 1000) + return result, { + "samples_ms": [round(value, 3) for value in timings], + "median_ms": round(statistics.median(timings), 3), + } + + +def main(): + parser = argparse.ArgumentParser( + description="本地 CPU Embedding 实测;合成结果不能用于论文结论" + ) + parser.add_argument("--model", choices=LOCAL_MODELS, required=True) + parser.add_argument( + "--dataset", type=Path, default=Path(__file__).with_name("synthetic_zh.json") + ) + parser.add_argument("--batch-size", type=int, default=8) + parser.add_argument("--threads", type=int, default=2) + parser.add_argument("--repeats", type=int, default=5) + parser.add_argument("--cache-dir", type=Path) + parser.add_argument( + "--download", action="store_true", help="允许下载固定 revision 的公开模型文件" + ) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + if sys.platform != "linux": + parser.error("当前 RSS 测量仅支持 Linux;Provider 可在其他支持的平台单独使用") + import resource + + if not 1 <= args.repeats <= 20: + parser.error("repeats 必须为 1–20") + dataset, digest = load_dataset(args.dataset) + print("初始化固定版本的 CPU 模型。", file=sys.stderr, flush=True) + started = perf_counter() + provider = LocalEmbeddingProvider( + LOCAL_MODELS[args.model], + batch_size=args.batch_size, + threads=args.threads, + cache_dir=args.cache_dir, + offline=not args.download, + ) + initialization_seconds = perf_counter() - started + print("模型就绪,执行预热和编码测量。", file=sys.stderr, flush=True) + provider.encode(["预热测试"], query=True) + corpus = [row["text"] for row in dataset["documents"]] + queries = [row["text"] for row in dataset["queries"]] + documents, corpus_time = measure(lambda: provider.encode(corpus), args.repeats) + vectors, query_time = measure(lambda: provider.encode(queries, query=True), args.repeats) + _, single_time = measure(lambda: provider.encode([queries[0]], query=True), args.repeats) + long_text = "合成测试资料仅用于测量本地编码资源,不表示任何真实景区信息。" * 12 + _, long_time = measure(lambda: provider.encode([long_text] * args.batch_size), args.repeats) + try: + provider.encode(["超长测试" * 500]) + except EmbeddingError: + length_guard = True + else: + raise AssertionError("超长输入未被拒绝") + import torch + + report = { + "schema_version": 1, + "evaluation_kind": "synthetic_smoke" + if dataset["purpose"] == "synthetic_smoke_not_domain_benchmark" + else "provided_dataset_not_independently_verified", + "dataset": { + "id": dataset["dataset_id"], + "purpose": dataset["purpose"], + "sha256": digest, + "documents": len(corpus), + "queries": len(queries), + }, + "embedding_spec": asdict(provider.spec), + "space_id": provider.spec.space_id, + "runtime": { + "python": platform.python_version(), + "torch": torch.__version__, + "transformers": importlib.metadata.version("transformers"), + "huggingface_hub": importlib.metadata.version("huggingface_hub"), + "numpy": importlib.metadata.version("numpy"), + "tokenizers": importlib.metadata.version("tokenizers"), + "safetensors": importlib.metadata.version("safetensors"), + "device": "cpu", + "threads": torch.get_num_threads(), + "interop_threads": torch.get_num_interop_threads(), + "batch_size": args.batch_size, + "repeats": args.repeats, + "warmup_per_shape": 1, + }, + "initialization_seconds": round(initialization_seconds, 3), + "timings": { + "corpus_encode": corpus_time, + "query_batch_encode": query_time, + "single_query_encode": single_time, + "long_text_batch_encode": long_time, + }, + "long_text_characters": len(long_text), + "long_text_tokens": len(provider.tokenizer(long_text)["input_ids"]), + "peak_process_rss_mib": round(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024, 1), + "token_overflow_rejected": length_guard, + "retrieval": rank_and_measure(dataset["documents"], dataset["queries"], documents, vectors), + "limitations": [ + "合成小样本仅检查流程,不能代表那拉提领域质量或正式实验分数", + "计时为本机预热后的编码,包含分词/补齐/归一化/结果转换,不含 API、数据库与网络请求", + "峰值 RSS 是整个独立进程的高水位,包含解释器和模型;初始化包含缓存检查/下载与首次模型加载", + ], + } + encoded = json.dumps(report, ensure_ascii=False, indent=2) + "\n" + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(encoded, encoding="utf-8") + args.output.chmod(0o600) + print("评估完成,报告已写入指定本机位置。", file=sys.stderr) + else: + print(encoded, end="") + + +if __name__ == "__main__": + main() diff --git a/backend/evaluation/synthetic_zh.json b/backend/evaluation/synthetic_zh.json new file mode 100644 index 0000000..1d0ba09 --- /dev/null +++ b/backend/evaluation/synthetic_zh.json @@ -0,0 +1,30 @@ +{ + "dataset_id": "synthetic-zh-travel-smoke-v1", + "purpose": "synthetic_smoke_not_domain_benchmark", + "documents": [ + {"id":"d01","text":"【合成测试】示例园区的停车场设有接驳车,步行游客可换乘到游客中心。"}, + {"id":"d02","text":"【合成测试】示例停车区域按车辆停留时段管理,离场时到出口办理停车手续。"}, + {"id":"d03","text":"【合成测试】示例展馆提供轮椅通道和电梯,行动不便的访客可从无障碍入口进入。"}, + {"id":"d04","text":"【合成测试】示例演出因降雨取消,已购票观众可沿原购票渠道申请退款。"}, + {"id":"d05","text":"【合成测试】示例红色文化展厅设有预约讲解,访客可提前登记并选择导览时段。"}, + {"id":"d06","text":"【合成测试】示例资料管理员核查发布机构、来源链接和更新时间,只有审核通过的资料才能成为问答依据。"}, + {"id":"d07","text":"【合成测试】示例民宿在前台办理入住,旅客需要出示有效身份证件并确认预订信息。"}, + {"id":"d08","text":"【合成测试】示例住宿提供早餐,有素食需求的客人可以提前向接待人员说明。"}, + {"id":"d09","text":"【合成测试】示例山间步道遇到恶劣天气暂停通行,游客应查看关闭提示并选择室内活动。"}, + {"id":"d10","text":"【合成测试】示例游客服务台统一接收捡到的物品,并登记失物线索协助寻找主人。"}, + {"id":"d11","text":"【合成测试】示例停车区设有电动车充电点,司机可按现场设备说明补充电量。"}, + {"id":"d12","text":"【合成测试】示例场馆的开放安排以入口公告为准,参观者应提前确认当天是否接待。"} + ], + "queries": [ + {"id":"q01","text":"没有开车的人怎样从停车区去游客中心?","relevant_ids":["d01"]}, + {"id":"q02","text":"同行者坐轮椅,可以从哪里进入展馆?","relevant_ids":["d03"]}, + {"id":"q03","text":"演出被雨取消,已经付的钱能退吗?","relevant_ids":["d04"]}, + {"id":"q04","text":"想听展厅里的故事讲解,如何安排导览?","relevant_ids":["d05"]}, + {"id":"q05","text":"怎么确保问答使用的旅游介绍有可靠出处?","relevant_ids":["d06"]}, + {"id":"q06","text":"到了住宿地点办理入住需要准备什么证件?","relevant_ids":["d07"]}, + {"id":"q07","text":"早饭不吃肉,应该提前找谁说明?","relevant_ids":["d08"]}, + {"id":"q08","text":"天气恶劣时还能在山路上游玩吗?","relevant_ids":["d09"]}, + {"id":"q09","text":"在路上捡到游客遗落的东西应交到哪里?","relevant_ids":["d10"]}, + {"id":"q10","text":"电动汽车快没电了,到哪儿补充电量?","relevant_ids":["d11"]} + ] +} diff --git a/backend/requirements-embedding-cpu.txt b/backend/requirements-embedding-cpu.txt new file mode 100644 index 0000000..a18f4d0 --- /dev/null +++ b/backend/requirements-embedding-cpu.txt @@ -0,0 +1,8 @@ +# Optional Linux CPU evaluation environment; not required by FastAPI startup. +# Install the CPU torch wheel from its official index first; see the evaluation guide. +torch==2.14.1+cpu +transformers==5.20.0 +huggingface-hub==2.2.0 +numpy==2.4.6 +tokenizers==0.23.3 +safetensors==0.8.0 diff --git a/backend/tests/test_embedding.py b/backend/tests/test_embedding.py new file mode 100644 index 0000000..418dcbb --- /dev/null +++ b/backend/tests/test_embedding.py @@ -0,0 +1,120 @@ +"""Lightweight contract/metric tests do not require model packages or network access.""" + +import json +from dataclasses import FrozenInstanceError, replace +from pathlib import Path + +import pytest + +from app.services.embedding import ( + SMALL_ZH, + EmbeddingError, + EmbeddingSpec, + LocalEmbeddingProvider, + validate_vectors, +) +from evaluation.local_embeddings import load_dataset, rank_and_measure + + +def test_model_space_identity_is_immutable_and_includes_semantic_configuration(): + assert SMALL_ZH.space_id == replace(SMALL_ZH).space_id + for change in [ + {"revision": "a" * 40}, + {"dimension": 768}, + {"query_instruction": ""}, + {"token_limit": 256}, + {"implementation": "another-pooling"}, + ]: + assert replace(SMALL_ZH, **change).space_id != SMALL_ZH.space_id + with pytest.raises(FrozenInstanceError): + SMALL_ZH.dimension = 768 + + +@pytest.mark.parametrize("revision", ["main", "v1.5", "a" * 39, None]) +def test_model_requires_full_revision(revision): + with pytest.raises(EmbeddingError): + EmbeddingSpec("example", revision, 2) + + +@pytest.mark.parametrize( + "vectors,count", + [ + ([[1.0, 0.0]], 2), + ([[1.0]], 1), + ([[0.0, 0.0]], 1), + ([[float("nan"), 0.0]], 1), + ([[float("inf"), 0.0]], 1), + ([["invalid", 0.0]], 1), + ([[True, 0.0]], 1), + ], +) +def test_invalid_vectors_cannot_pass_contract(vectors, count): + spec = EmbeddingSpec("example", "a" * 40, 2) + with pytest.raises(EmbeddingError): + validate_vectors(vectors, count, spec) + validate_vectors([[1.0, 0.0]], 1, spec) + + +def test_token_overflow_is_rejected_before_any_model_execution(): + provider = object.__new__(LocalEmbeddingProvider) + provider.spec = SMALL_ZH + provider.batch_size = 8 + provider.torch = None + + class Shape: + shape = (1, 513) + + provider.tokenizer = lambda *args, **kwargs: {"input_ids": Shape()} + # There is intentionally no model: exceeding the budget must never execute one. + with pytest.raises(EmbeddingError, match="token"): + provider.encode(["占位文本"]) + + +def test_ranking_metrics_match_hand_calculation_and_ties_are_stable(): + docs = [{"id": id_} for id_ in ["a", "b", "c"]] + queries = [{"id": "q1", "relevant_ids": ["b", "c"]}, {"id": "q2", "relevant_ids": ["c"]}] + result = rank_and_measure(docs, queries, [[1, 0], [0, 1], [-1, 0]], [[1, 1], [-1, 0]], top_k=2) + assert result["hit_at_1"] == 0.5 + assert result["recall_at_k"] == 0.75 + assert result["mrr_at_k"] == 0.75 + assert [x["id"] for x in result["per_query"][0]["top_results"]] == ["a", "b"] + + +def test_dataset_validation_rejects_unknown_labels_and_duplicate_ids(tmp_path): + data = { + "dataset_id": "placeholder", + "purpose": "synthetic_smoke_not_domain_benchmark", + "documents": [{"id": "d", "text": "占位"}], + "queries": [{"id": "q", "text": "测试", "relevant_ids": ["missing"]}], + } + path = tmp_path / "dataset.json" + path.write_text(json.dumps(data)) + with pytest.raises(ValueError): + load_dataset(path) + data["queries"][0]["relevant_ids"] = ["d"] + data["documents"].append({"id": "d", "text": "重复"}) + path.write_text(json.dumps(data)) + with pytest.raises(ValueError): + load_dataset(path) + + +def test_bundled_dataset_is_explicitly_synthetic_and_reproducible(): + path = Path(__file__).resolve().parents[1] / "evaluation" / "synthetic_zh.json" + dataset, digest = load_dataset(path) + assert dataset["purpose"] == "synthetic_smoke_not_domain_benchmark" + assert all(row["text"].startswith("【合成测试】") for row in dataset["documents"]) + assert load_dataset(path)[1] == digest + + +@pytest.mark.parametrize( + "options", [{"batch_size": 0}, {"batch_size": 33}, {"threads": 5}, {"batch_size": 1.5}] +) +def test_invalid_resource_options_fail_without_loading_optional_dependencies(options): + with pytest.raises(EmbeddingError): + LocalEmbeddingProvider(SMALL_ZH, **options) + + +@pytest.mark.parametrize("checkpoint", ["pickle", "automatic-fallback"]) +def test_unsupported_checkpoint_format_is_rejected(checkpoint): + with pytest.raises(EmbeddingError): + replace(SMALL_ZH, checkpoint_format=checkpoint) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d698576..41ecec0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -28,3 +28,8 @@ FastAPI + Pydantic ## 安全边界 当前无认证与权限控制,仅供本机开发;正式部署前须补充登录鉴权、接口限流、数据库迁移、密钥管理、日志脱敏、CORS 收敛和 HTTPS。 + + +## 本地 Embedding 评估(T07 准备) + +`app/services/embedding.py` 定义固定模型身份、可替换编码接口及可选 CPU Provider;模型库按需导入,普通 API 启动不加载权重。`evaluation/local_embeddings.py` 在独立进程中验证真实模型向量和 token 预算,并测量已标注合成样本的资源/排序结果;不读取或写入业务数据库。当前尚未把向量接入检索 API,详见 [本地模型评估](LOCAL_EMBEDDING_EVALUATION.md)。 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 5e955fd..d91b17c 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -22,3 +22,10 @@ ## ADR-007 显式迁移与旧数据保留 状态:分支实现,待合并请求审阅。以 Alembic 取代应用启动时的 `create_all`。基础迁移接管旧知识表,新增字段和表通过增量迁移完成;不推测旧数据采集时间。旧审核结论缺少完整记录时保留原快照并转待审核。迁移回滚需先备份,新增历史与审核元数据无法通过降级完整保留。 + + +## ADR-011 本地 Embedding 独立评估与空间身份 + +状态:T07 本地评估分支,待审阅。用户选择先评估本地开源模型与资源;采用固定 revision 的 BGE small/base 中文模型做 CPU 实测,保持可替换 Provider、CLS/L2 与查询指令配置。模型、维度、修订及编码配置形成独立空间身份,禁止后续混用。 + +评估环境与默认后端分开,不在 API 启动时下载或加载模型。当前合成小样本只验证流程与资源,实际数据集 SHA、计时和模型身份可复核;默认模型仍须经过真实已审核问题集评测。详见 [本地模型评估](LOCAL_EMBEDDING_EVALUATION.md)。 diff --git a/docs/LOCAL_EMBEDDING_EVALUATION.md b/docs/LOCAL_EMBEDDING_EVALUATION.md new file mode 100644 index 0000000..27d8eb8 --- /dev/null +++ b/docs/LOCAL_EMBEDDING_EVALUATION.md @@ -0,0 +1,77 @@ +# T07:本地 Embedding 与 CPU 资源评估 + +本阶段从 `dev` 独立开发,交付可替换 Provider 和可复现的本地 CPU 评估工具。它不依赖待审 PR #6/#7/#8 的接口或数据库迁移;后续向量检索集成再组合这些基础。普通后端启动及普通自动化测试均不导入 PyTorch、不加载模型,也不要求模型下载。 + +## 固定配置与边界 + +| CLI 名称 | 官方模型 | 固定 revision | 维度 | Token 上限 | 权重格式 | +|---|---|---|---:|---:|---| +| small | BAAI/bge-small-zh-v1.5 | `7999e1d3359715c523056ef9478215996d62a620` | 512 | 512 | Safetensors | +| base | BAAI/bge-base-zh-v1.5 | `f03589ceff5aac7111bd60cfc7d497ca17ecac65` | 768 | 512 | PyTorch,仅权重读取 | + +两者使用 CLS 输出、L2 归一化、CPU FP32;查询添加官方检索指令,正文不添加该指令。`EmbeddingSpec` 冻结模型/revision/维度/token 上限/查询指令/实现版本/权重格式,由规范 JSON 生成 `space_id`。不同空间必须分开保存和查询;目前只提供身份契约,数据库隔离约束尚待向量阶段实现。 + +模型仓库均标注 MIT。基础版该修订只提供 `pytorch_model.bin`,显式使用 Transformers 的 `weights_only=True` 读取;不会自动回退未知格式或开启远程模型代码。公开模型下载使用 `token=False`,不要求或读取用户保存的 Hugging Face 凭据。默认只用本机缓存,CLI `--download` 才允许下载固定修订。 + +`LocalEmbeddingProvider` 按批处理,默认 batch=8、CPU 线程=2;显式限制 batch 1–32、线程 1–4。输入先按真实 tokenizer 计数,包含查询指令和特殊 token;超过模型上限明确报错,不静默截断。输出检查数量、维度、有限数值和 L2 范数。字符切分长度不等于 token 数,后续接入 chunks 时须据此重新检查。 + +## 输入与指标 + +项目当前资料台账只有未核验的占位行,没有可用于正式选型的领域数据。本阶段自带 `backend/evaluation/synthetic_zh.json`,含 12 条明确标注“合成测试”的示例文本及 10 个问题,ID/相关文档标注在运行前固定。这些句子不描述那拉提真实事实,不写入应用数据库。 + +评估保存输入文件 SHA-256、模型完整配置与空间 ID、运行库版本、实际命中 ID/分数、命中率、Recall@3 和 MRR@3。排序采用归一化向量内积,同分按文档 ID;数值不是正确率或置信度。命中率检查首位相关文档;Recall@k 为前 k 命中相关文档数/标注相关文档总数;MRR@k 为首个相关命中的倒数,未命中为 0。 + +每项计时先按同一输入形状预热一次,然后记录 5 次及中位数:12 条语料编码、10 个查询批量编码、单个查询、8 条长文本批量编码。长文本均为相同的合成句子重复形成,360 字符/362 token,仅测试资源。包含分词、补齐、前向、归一化和结果转换;不包含网络、数据库、HTTP 或整个 RAG 延迟。初始化时间单独记录,可能包含缓存检查/下载与首次加载,不用于模型推理速度比较。Linux 峰值 RSS 是整个独立进程高水位,包含解释器、运行库和模型,不能当作仅权重大小。 + +## 本机实测 + +运行环境:Linux、Python 3.11.17、4 个逻辑 CPU、内存总计约 9.7 GiB,检查时可用约 5.0 GiB;GPU 驱动不可用,本轮仅用 CPU。PyTorch `2.14.1+cpu`、Transformers `5.20.0`、huggingface-hub `2.2.0`、NumPy `2.4.6`;每个模型用独立进程测量,线程 2、batch 8、每项 5 次。未记录用户名、主机名或本机个人路径。 + +输入文件 SHA-256:`9f4762a10dc1502b871c84ccedf9fbbdbd93067b8318476a77c78aa14789b243`。 + +| 模型 | 12 条正文批编码中位数 | 10 个查询批编码中位数 | 单查询中位数 | 8 条长文批编码中位数 | 峰值进程 RSS | 合成 Hit@1 / Recall@3 / MRR@3 | +|---|---:|---:|---:|---:|---:|---| +| small | 98.708 ms | 72.877 ms | 8.927 ms | 457.944 ms | 531.2 MiB | 0.90 / 0.90 / 0.90 | +| base | 582.315 ms | 416.287 ms | 52.912 ms | 3016.157 ms | 865.5 MiB | 1.00 / 1.00 / 1.00 | + +两个模型在该负载下都能使用本机 CPU。small 在合成问题 q05(出处可靠性)把住宿条目 d07 排在首位,目标资料审核条目 d06 未进入前三;base 将 d06 排首位。这里保留失败结果,不修改数据以提高分数。两个模型的相似度分布不同,不能共用固定分数阈值。 + +建议:资源优先的后端开发验证可先使用 small,保留 base 作对照。正式默认模型暂不冻结,先在已核验语料与独立问题集上比较相关性、无依据情形和实际端到端延迟。当前未测 GPU、并发服务、最大 token 负载或完整索引构建。 + +这些是合成小样本的功能与资源结果,不是项目正式实验分数。不能据此断言真实文旅问答质量或生产尾延迟。应先用资料负责人核验后的语料及独立问题集复测,再固定默认模型;不能通过修改测试题迎合已观察的排名。 + +## 复现(Linux,仓库根目录) + +可使用 `uv` 创建独立 Python 3.11 环境;或以等价 venv/pip 命令安装。依赖独立于 `requirements.txt`,不把 GPU 依赖或模型加入默认 API 启动流程。 + +```bash +task_eval_root=$(git rev-parse --absolute-git-dir) +test -x "$task_eval_root/embedding-eval-venv/bin/python" || \ + uv venv --python 3.11 "$task_eval_root/embedding-eval-venv" +uv pip install --python "$task_eval_root/embedding-eval-venv/bin/python" \ + --index-url https://download.pytorch.org/whl/cpu torch==2.14.1+cpu +uv pip install --python "$task_eval_root/embedding-eval-venv/bin/python" \ + -r backend/requirements-embedding-cpu.txt +export HF_HUB_DISABLE_TELEMETRY=1 HF_HUB_DISABLE_IMPLICIT_TOKEN=1 +export HF_HUB_DISABLE_PROGRESS_BARS=1 TOKENIZERS_PARALLELISM=false +export HF_HUB_DISABLE_XET=1 +( + cd backend + "$task_eval_root/embedding-eval-venv/bin/python" -m evaluation.local_embeddings \ + --model small --download --cache-dir "$task_eval_root/embedding-model-cache" \ + --output "$task_eval_root/embedding-evaluation/small.json" + "$task_eval_root/embedding-eval-venv/bin/python" -m evaluation.local_embeddings \ + --model base --download --cache-dir "$task_eval_root/embedding-model-cache" \ + --output "$task_eval_root/embedding-evaluation/base.json" +) +``` + +后续离线复跑去掉 `--download`。可用 `--dataset` 指定同结构数据,文档/问题各最多 1000 条、文件最多 2MB;拒绝重复 ID、未知或重复的相关文档标注。报告不含输入文本,但实际问题/标签可能仍敏感,原始报告、运行输出、环境及模型缓存都应只留在本机 Git 元数据目录,不提交。参数错误或模型失败不会生成伪造的成功结果。 + +普通后端验证仍是在 `backend/` 执行 `ruff check .`、`ruff format --check .`、`pytest -q`。轻量测试验证模型身份、非法输出、资源参数、token 拒绝、输入标注及手算排序指标,不需要模型包或联网;真实模型执行由上述独立 CLI 验证。 + +## 下一步 + +将 Provider 接入已发布的文本版本,完成 pgvector 存储与严格空间隔离、向量检索及 RRF,再使用真实审核语料评测。模型编码耗时应在发布锁外运行,发布前重新核对目录/语料版本;过期或失败时保留旧版本。当前没有新增向量数据库字段、检索 API 或论文结论。 + +依据:[BGE small 官方模型说明](https://huggingface.co/BAAI/bge-small-zh-v1.5)、[BGE base 官方文件](https://huggingface.co/BAAI/bge-base-zh-v1.5/tree/f03589ceff5aac7111bd60cfc7d497ca17ecac65)、[Transformers 模型加载参数](https://huggingface.co/docs/transformers/main_classes/model#transformers.PreTrainedModel.from_pretrained)、[PyTorch CPU 安装](https://pytorch.org/get-started/locally/)、[Hugging Face 固定修订下载](https://huggingface.co/docs/huggingface_hub/guides/download)。 diff --git a/docs/TASKS.md b/docs/TASKS.md index 263f347..af10c54 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -44,3 +44,14 @@ - 覆盖:新库迁移、已有基础表接管、重复升级、阶段 2 降级后基础正文保留、ORM 与迁移结构一致、审核前置条件、正文/来源/文档变更后重新审核、历史快照、软删除引用保护、筛选与分页、并发写入冲突、跨域预检、真实 HTTP 与服务重启持久化。 所有测试正文、来源和审核人均为占位数据,临时数据库不入库。当前问答仍是占位接口,未开展论文对照实验。Issue、合并请求和最终批准结论须补入 Gitea 后按团队规则归档。 + +## 2026-10-16 T07:本地 Embedding 与资源评估 + +- 用户选择先评估本地开源模型与本机资源;从最新 `dev` 独立创建 `feat/backend-local-embedding-evaluation`,不依赖仍待审的 #6/#7/#8。 +- 实现不在普通启动流程加载模型的 Provider、固定 revision 与空间身份、维度/有限数/L2 校验、真实 token 上限拒绝及独立 CPU 评估 CLI。可选依赖和模型缓存与默认后端环境分开。 +- 固定 BGE small/base 中文模型,CPU 线程 2、batch 8、各输入形状预热一次后重复 5 次。单查询中位数分别 8.927/52.912 ms,独立进程峰值 RSS 531.2/865.5 MiB;两者实际运行、向量校验与超限拒绝通过。 +- 项目资料台账目前只有占位行,本轮采用事先固定的 12 文本/10 问题合成样本,仅验证流程。small 的合成 Hit@1/Recall@3/MRR@3 为 0.90,base 为 1.00;不是实际文旅质量或论文结论,保留 small 的错误案例,不据此冻结正式默认模型。 +- 固定配置、完整指标定义、输入哈希、复现命令和限制见 [本地模型评估](LOCAL_EMBEDDING_EVALUATION.md)。原始运行报告、权重、虚拟环境只保存在本机 Git 元数据目录,不入库。 +- 向量数据库/空间隔离、索引构建、向量/RRF 检索、真实已核验问题集和浏览器/部署验收仍待后续推进;本轮没有把这些标记为完成。 + +- 本独立分支实际验证:`ruff check .`、`ruff format --check .` 通过;`pytest -q` **44 passed**,包含当前 dev 的 PostgreSQL/HTTP 基线与新增轻量契约/指标用例。small/base 另由独立模型环境真实执行;可选依赖在新建空环境中完成 CPU wheel 安装与后续依赖解析验证。先前 #7/#8 的 70/91 项结果属于各自分支,不混同为本独立分支的测试计数。 From 1de90a72fe0f1217cc1be165d18f70c67f1f1380 Mon Sep 17 00:00:00 2001 From: Backend Contributors Date: Fri, 16 Oct 2026 09:14:31 +0800 Subject: [PATCH 2/2] fix: reject invalid embedding input containers --- backend/app/services/embedding.py | 8 ++++++-- backend/tests/test_embedding.py | 7 +++++++ docs/TASKS.md | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/backend/app/services/embedding.py b/backend/app/services/embedding.py index 5ecd5d4..e6cead2 100644 --- a/backend/app/services/embedding.py +++ b/backend/app/services/embedding.py @@ -34,7 +34,11 @@ class EmbeddingSpec: or self.token_limit < 2 ): raise EmbeddingError("向量维度和 token 上限无效") - if not self.model or not isinstance(self.query_instruction, str): + if ( + not isinstance(self.model, str) + or not self.model + or not isinstance(self.query_instruction, str) + ): raise EmbeddingError("模型名称或查询指令无效") if self.checkpoint_format not in {"safetensors", "pytorch-weights-only"}: raise EmbeddingError("不支持的权重格式") @@ -138,7 +142,7 @@ class LocalEmbeddingProvider: raise EmbeddingError("模型 tokenizer 上限与配置不一致") def encode(self, texts: list[str], *, query: bool = False) -> list[list[float]]: - if any( + if not isinstance(texts, list) or any( not isinstance(text, str) or not text.strip() or len(text) > 10_000 for text in texts ): raise EmbeddingError("每条输入须为非空文本且最多 10000 字符") diff --git a/backend/tests/test_embedding.py b/backend/tests/test_embedding.py index 418dcbb..69a9dce 100644 --- a/backend/tests/test_embedding.py +++ b/backend/tests/test_embedding.py @@ -118,3 +118,10 @@ def test_invalid_resource_options_fail_without_loading_optional_dependencies(opt def test_unsupported_checkpoint_format_is_rejected(checkpoint): with pytest.raises(EmbeddingError): replace(SMALL_ZH, checkpoint_format=checkpoint) + + +@pytest.mark.parametrize("texts", [None, "不能逐字编码的字符串", ("占位",), [None], [" "]]) +def test_input_shape_and_text_are_validated_before_encoding(texts): + provider = object.__new__(LocalEmbeddingProvider) + with pytest.raises(EmbeddingError): + provider.encode(texts) diff --git a/docs/TASKS.md b/docs/TASKS.md index af10c54..33ee24d 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -54,4 +54,4 @@ - 固定配置、完整指标定义、输入哈希、复现命令和限制见 [本地模型评估](LOCAL_EMBEDDING_EVALUATION.md)。原始运行报告、权重、虚拟环境只保存在本机 Git 元数据目录,不入库。 - 向量数据库/空间隔离、索引构建、向量/RRF 检索、真实已核验问题集和浏览器/部署验收仍待后续推进;本轮没有把这些标记为完成。 -- 本独立分支实际验证:`ruff check .`、`ruff format --check .` 通过;`pytest -q` **44 passed**,包含当前 dev 的 PostgreSQL/HTTP 基线与新增轻量契约/指标用例。small/base 另由独立模型环境真实执行;可选依赖在新建空环境中完成 CPU wheel 安装与后续依赖解析验证。先前 #7/#8 的 70/91 项结果属于各自分支,不混同为本独立分支的测试计数。 +- 本独立分支实际验证:`ruff check .`、`ruff format --check .` 通过;`pytest -q` **49 passed**,包含当前 dev 的 PostgreSQL/HTTP 基线与新增轻量契约/指标用例。small/base 另由独立模型环境真实执行;可选依赖在新建空环境中完成 CPU wheel 安装与后续依赖解析验证。先前 #7/#8 的 70/91 项结果属于各自分支,不混同为本独立分支的测试计数。