"""RAG 管道:Embedding(本地模型,ModelScope 下载,离线可用)""" import sys import threading import math import logging import time import os from functools import lru_cache from pathlib import Path from ..config import MODELS_DIR, EMBEDDING_MODEL _lock = threading.Lock() _encoder = None _encoder_name = None _encoder_load_ms = 0.0 _metric_lock = threading.Lock() _last_embedding_metric: dict = {} _embedding_dimension = "" logger = logging.getLogger("yawen.rag.embed") # This is deliberately a separate value from the model name. Operators can # bump it when replacing files in the model directory without changing the # configured model identifier (which also invalidates query-vector caches). EMBEDDING_MODEL_VERSION = os.environ.get("EMBEDDING_MODEL_VERSION", "1") def _set_metric(row: dict) -> None: """Keep the most recent bounded embedding sample for the RAG envelope. The RAG worker is single threaded today, but protecting this assignment makes the helper safe when a worker is configured with concurrent requests. No source text is retained in telemetry. """ global _last_embedding_metric clean = { "process_start_ms": 0.0, "ipc_ms": 0.0, "model_load_ms": 0.0, "tokenizer_ms": 0.0, "encode_ms": 0.0, "postprocess_ms": 0.0, "embedding_source": "sentence-transformers", "embedding_count": 0, "model": str(EMBEDDING_MODEL), "model_version": str(EMBEDDING_MODEL_VERSION), "pid": os.getpid(), "cache_hit": False, } clean.update({key: value for key, value in row.items() if value is not None}) for key in ("process_start_ms", "ipc_ms", "model_load_ms", "tokenizer_ms", "encode_ms", "postprocess_ms"): try: clean[key] = round(float(clean[key]), 2) except (TypeError, ValueError): clean[key] = 0.0 clean["embedding_count"] = int(clean.get("embedding_count") or 0) clean["pid"] = int(clean.get("pid") or os.getpid()) with _metric_lock: _last_embedding_metric = clean def last_embedding_metric() -> dict: """Return phase timings for the latest encode without exposing input text.""" with _metric_lock: return dict(_last_embedding_metric) def query_cache_snapshot() -> dict: """Expose the independent query-vector LRU counters for diagnostics.""" info = _cached_query.cache_info() total = info.hits + info.misses return { "size": info.currsize, "max_size": info.maxsize, "hits": info.hits, "misses": info.misses, "hit_rate": round(info.hits / total, 4) if total else 0.0, "model": str(EMBEDDING_MODEL), "model_version": str(EMBEDDING_MODEL_VERSION), "dimension": int(_embedding_dimension or 0), "pid": os.getpid(), } def clear_query_cache() -> None: """Clear only query vectors; retrieval candidate pools remain untouched.""" _cached_query.cache_clear() def set_transport_timings(*, process_start_ms: float = 0.0, ipc_ms: float = 0.0) -> None: """Attach process/IPC timings measured by the parent worker transport. ``embed.py`` cannot observe subprocess creation or pipe transfer itself; the worker pool can call this after a request and the next envelope then contains the complete set of phases. Keeping this optional preserves the existing embedding API for in-process callers and tests. """ metric = last_embedding_metric() metric.update(process_start_ms=process_start_ms, ipc_ms=ipc_ms) _set_metric(metric) def ensure_model_downloaded(model_name: str) -> Path: """模型目录存在即用;否则从 ModelScope 下载(HF 被墙)""" safe = model_name.replace("/", "--") local = MODELS_DIR / safe if (local / "config.json").exists(): return local try: from modelscope import snapshot_download print(f"[embed] 从 ModelScope 下载 {model_name} ...", file=sys.stderr) snapshot_download(model_name, local_dir=str(local)) return local except Exception as e: raise RuntimeError(f"模型下载失败(ModelScope): {e}") def get_encoder(): """单例加载 SentenceTransformer(本地路径)""" global _encoder, _encoder_name, _encoder_load_ms if _encoder is not None and _encoder_name == EMBEDDING_MODEL: return _encoder with _lock: if _encoder is not None and _encoder_name == EMBEDDING_MODEL: return _encoder started = time.perf_counter() from sentence_transformers import SentenceTransformer local = ensure_model_downloaded(EMBEDDING_MODEL) print(f"[embed] 加载 {local} ...", file=sys.stderr) _encoder = SentenceTransformer(str(local)) _encoder_name = EMBEDDING_MODEL _encoder_load_ms = (time.perf_counter() - started) * 1000 return _encoder def prewarm() -> bool: """Load the embedding model and run one tiny encode as a best-effort warmup. Model loading is intentionally kept out of the API startup path. The caller should run this function from a daemon thread so a slow model download/load never delays health checks or request handling. Returning a boolean keeps this useful for startup diagnostics without making warmup failures fatal to the service. """ started = time.perf_counter() try: encoder = get_encoder() # A real encode initializes tokenizer/runtime kernels as well as the # SentenceTransformer object. The result is discarded; query vectors # continue to use the normal cached path. encoder.encode(["系统启动预热"], normalize_embeddings=True, show_progress_bar=False, batch_size=1) logger.info("Embedding 模型预热完成:%s,耗时 %.3f 秒", EMBEDDING_MODEL, time.perf_counter() - started) return True except Exception: logger.exception("Embedding 模型预热失败:%s,服务将按需加载", EMBEDDING_MODEL) return False EMBEDDING_POLICY = "full-token-windows-v1" def text_windows(text: str, encoder=None) -> list[str]: """Cover every source character without sending over-limit windows to BGE.""" enc = encoder or get_encoder() tokenizer = enc.tokenizer maximum = int(enc.max_seq_length) def fits(value): return len(tokenizer.encode(value, add_special_tokens=True, truncation=False)) <= maximum if fits(text): return [text] # Character boundaries preserve exact original text; a binary search uses the # actual model tokenizer rather than assuming that one character is one token. windows, offset = [], 0 while offset < len(text): remaining = text[offset:] if fits(remaining): windows.append(remaining) break low, high = 1, min(len(remaining), maximum * 4) while low < high: middle = (low + high + 1) // 2 if fits(remaining[:middle]): low = middle else: high = middle - 1 if not fits(remaining[:low]): raise ValueError("单个字符超过向量模型输入上限") windows.append(remaining[:low]) offset += low return windows def embed_texts(texts: list[str]) -> list[list[float]]: if not texts: return [] # ``get_encoder`` includes model download and construction only on the # first call in each resident worker. Subsequent calls report zero load # time, which makes accidental worker restarts visible in telemetry. enc = get_encoder() global _encoder_load_ms model_load_ms = _encoder_load_ms # Consume the one-shot load measurement. A resident encoder must report # zero on later requests; a non-zero value then reliably indicates a worker # restart or an explicit model replacement. _encoder_load_ms = 0.0 # At most 64 originals and 64 encoded windows in one model batch. Long # paragraphs are pooled in the existing vector space, keeping chunk IDs and # consumption records stable while representing their tails as well. output = [] tokenizer_ms = encode_ms = postprocess_ms = 0.0 flat_count = 0 for start in range(0, len(texts), 64): tokenizer_started = time.perf_counter() groups = [text_windows(str(text), enc) for text in texts[start:start + 64]] flat = [window for group in groups for window in group] tokenizer_ms += (time.perf_counter() - tokenizer_started) * 1000 flat_count += len(flat) encode_started = time.perf_counter() vectors = enc.encode(flat, normalize_embeddings=True, show_progress_bar=False, batch_size=64) encode_ms += (time.perf_counter() - encode_started) * 1000 postprocess_started = time.perf_counter() cursor = 0 for group in groups: selected = vectors[cursor:cursor + len(group)] cursor += len(group) if len(group) == 1: output.append([float(v) for v in selected[0]]) continue weights = [max(1, len(enc.tokenizer.encode(window, add_special_tokens=False, truncation=False))) for window in group] combined = [sum(float(vector[i]) * weight for vector, weight in zip(selected, weights)) for i in range(len(selected[0]))] norm = math.sqrt(sum(value * value for value in combined)) or 1.0 output.append([value / norm for value in combined]) postprocess_ms += (time.perf_counter() - postprocess_started) * 1000 _set_metric({ "model_load_ms": model_load_ms, "tokenizer_ms": tokenizer_ms, "encode_ms": encode_ms, "postprocess_ms": postprocess_ms, "embedding_count": len(texts), # ``flat_count`` is useful when long documents are split into windows; # retain it as a diagnostic without changing the public return value. "window_count": flat_count, "embedding_dimension": len(output[0]) if output else 0, "cache_hit": False, }) return output @lru_cache(maxsize=256) def _cached_query(model: str, policy: str, version: str, dimension: str, text: str) -> tuple[float, ...]: """Cache only query vectors, keyed by model identity and policy version.""" return tuple(embed_texts([text])[0]) def embed_query(text: str) -> list[float]: # lru_cache does not expose whether this invocation hit without comparing # its counters. The comparison is process local and avoids retaining the # query itself in telemetry. before = _cached_query.cache_info() global _embedding_dimension # Keep the cache identity stable across the first call: the actual vector # dimension is discovered only after encoding. Model/version already # identify the encoder contract; ``auto`` prevents the first "unknown" # dimension from creating a second key for the same query. result = list(_cached_query(EMBEDDING_MODEL, EMBEDDING_POLICY, EMBEDDING_MODEL_VERSION, "auto", str(text))) if not _embedding_dimension and result: _embedding_dimension = str(len(result)) after = _cached_query.cache_info() cache_hit = after.hits > before.hits metric = last_embedding_metric() metric.update( embedding_count=1, cache_hit=cache_hit, embedding_source="query-vector-lru", model=EMBEDDING_MODEL, model_version=EMBEDDING_MODEL_VERSION, pid=os.getpid(), embedding_dimension=len(result) if result else 0, ) if cache_hit: # A cache hit performs no tokenizer, model inference, or serialization; # do not leak timings from the previous (possibly indexing) call. metric.update(model_load_ms=0.0, tokenizer_ms=0.0, encode_ms=0.0, postprocess_ms=0.0, window_count=0) _set_metric(metric) return result