Files

289 lines
12 KiB
Python
Raw Permalink Normal View History

"""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