Files
border-red-post-nalati/backend/evaluation/local_embeddings.py
T

222 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()