offer-catcher-agent / src /embedding_client.py
hungryb's picture
Deploy Offer Catcher Agent demo
3161850 verified
Raw
History Blame Contribute Delete
4.87 kB
"""
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: # python-dotenv is optional; env vars still work.
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 # "api" | "local" | None
# 尝试加载 API 模式
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}")
# API 失败则尝试本地模型
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()