| """ |
| embedding_client.py — 语义向量召回客户端 |
| 支持两种模式: |
| 1. API 模式(DeepSeek / OpenAI / Qwen 兼容嵌入接口) |
| 2. 本地 sentence-transformers 模式(可选,需安装) |
| |
| 优先级:API → sentence-transformers → token cosine fallback |
| """ |
| from __future__ import annotations |
|
|
| import json |
| import os |
| import re |
| from pathlib import Path |
| from typing import Optional |
|
|
| try: |
| from dotenv import load_dotenv |
| except ModuleNotFoundError: |
| def load_dotenv(*args, **kwargs): |
| return False |
|
|
|
|
| load_dotenv() |
|
|
|
|
| class EmbeddingClient: |
| """统一嵌入接口。未配置时自动回退。""" |
|
|
| def __init__(self) -> None: |
| self.api_key = os.getenv("EMBEDDING_API_KEY") or os.getenv("LLM_API_KEY") |
| self.base_url = os.getenv("EMBEDDING_BASE_URL") or os.getenv("LLM_BASE_URL") |
| self.model = os.getenv("EMBEDDING_MODEL", "text-embedding-v1") |
| self.provider = os.getenv("EMBEDDING_PROVIDER", "").lower() |
| self._client = None |
| self._local_model = None |
| self.mode: Optional[str] = None |
|
|
| |
| if self.api_key and self.base_url: |
| try: |
| from openai import OpenAI |
| self._client = OpenAI(api_key=self.api_key, base_url=self.base_url) |
| self.mode = "api" |
| print(f"[OK] EmbeddingClient: API 模式 ({self.base_url})") |
| except Exception as exc: |
| print(f"[WARN] EmbeddingClient API 初始化失败:{exc}") |
|
|
| |
| if self.mode is None: |
| self._try_load_local() |
|
|
| def _try_load_local(self) -> None: |
| try: |
| from sentence_transformers import SentenceTransformer |
| model_name = os.getenv("LOCAL_EMBEDDING_MODEL", "BAAI/bge-small-zh-v1.5") |
| self._local_model = SentenceTransformer(model_name) |
| self.mode = "local" |
| print(f"[OK] EmbeddingClient: 本地模式 ({model_name})") |
| except Exception: |
| self.mode = None |
|
|
| def is_available(self) -> bool: |
| return self.mode is not None |
|
|
| def embed(self, texts: list[str]) -> Optional[list[list[float]]]: |
| """批量获取嵌入向量。返回 List[List[float]] 或 None。""" |
| if self.mode == "api": |
| return self._embed_api(texts) |
| if self.mode == "local": |
| return self._embed_local(texts) |
| return None |
|
|
| def _embed_api(self, texts: list[str]) -> Optional[list[list[float]]]: |
| try: |
| resp = self._client.embeddings.create(model=self.model, input=texts) |
| return [d.embedding for d in resp.data] |
| except Exception as exc: |
| print(f"[WARN] Embedding API 调用失败:{exc}") |
| return None |
|
|
| def _embed_local(self, texts: list[str]) -> Optional[list[list[float]]]: |
| try: |
| embeddings = self._local_model.encode(texts, normalize_embeddings=True) |
| return embeddings.tolist() |
| except Exception as exc: |
| print(f"[WARN] 本地嵌入失败:{exc}") |
| return None |
|
|
| def cosine_similarity(self, vec1: list[float], vec2: list[float]) -> float: |
| """余弦相似度。""" |
| if not vec1 or not vec2: |
| return 0.0 |
| dot = sum(a * b for a, b in zip(vec1, vec2)) |
| norm1 = sum(a * a for a in vec1) ** 0.5 |
| norm2 = sum(b * b for b in vec2) ** 0.5 |
| if norm1 == 0 or norm2 == 0: |
| return 0.0 |
| return dot / (norm1 * norm2) |
|
|
| def semantic_similarity(self, resume_text: str, job_text: str) -> Optional[float]: |
| """计算简历与岗位的语义相似度(0~1)。""" |
| embeddings = self.embed([resume_text, job_text]) |
| if embeddings is None: |
| return None |
| return self.cosine_similarity(embeddings[0], embeddings[1]) |
|
|
| def rank_by_semantic( |
| self, resume_text: str, jobs: list[dict], top_k: int = 8 |
| ) -> list[tuple[int, float]]: |
| """用语义向量对岗位重新排序。返回 [(index, score), ...] 按分数降序。""" |
| if not self.is_available(): |
| return [] |
| texts = [f"{j.get('title', '')} {j.get('jd', '')}" for j in jobs] |
| all_texts = [resume_text] + texts |
| embeddings = self.embed(all_texts) |
| if embeddings is None: |
| return [] |
| resume_emb = embeddings[0] |
| scores: list[tuple[int, float]] = [] |
| for i, job_emb in enumerate(embeddings[1:], start=0): |
| sim = self.cosine_similarity(resume_emb, job_emb) |
| scores.append((i, sim)) |
| scores.sort(key=lambda x: x[1], reverse=True) |
| return scores[:top_k] |
|
|
|
|
| def get_embedding_client() -> EmbeddingClient: |
| """工厂函数。""" |
| return EmbeddingClient() |
|
|