Files
news/embedding/local.py
T
simon ff911cf6f7 feat: Token Plan 迁移与 .env 热加载,并修复日报 AI 摘要为空
Token Plan 迁移 / 配置热加载:
- configs/llm_models.yaml: 各场景切到 Token Plan(deepseek-v4.1-flash / qwen3.6-flash)
- 新增 configs/runtime_env.py: .env 按 (mtime_ns, size) 热加载并同步 os.environ,
  统一 env_get 取值;llm / embedding / vectorstore / mcp / pipeline 改用 env_get
- configs/loader.py / scripts/run_scheduler.py 等配套调整
- 新增 tests/test_hot_reload.py

日报 AI 摘要为空修复(2026-09-25):
- 根因: 推理模型的 reasoning token 与正文共用 max_tokens, 预算 1500 被"思考"
  占满 -> text_tokens=0 / finish_reason=length, 摘要静默为空且不重试
- daily_report 场景新增 max_tokens(默认 4000, YAML 保存即热生效);
  LLMConfig 支持可选 max_tokens; 分块预算 800 -> 2000
- _llm_call 拆出 _call_once, 正文为空时自动加倍预算重试(上限 16000),
  用尽才降级返回空串; 网络异常重试语义不变
- docs/user-guide.md 新增 FAQ; continuation.md 记录本次排查
- 已重跑 2026-09-25 日报(report_id=357)补回 466 字摘要

测试: 相关用例 56 passed(test_hot_reload 12 passed);
      ruff 无新增问题; 3 个 crawler 既有失败与本改动无关
2026-09-25 11:13:37 +08:00

112 lines
3.6 KiB
Python

"""本地 BGE-M3 嵌入实现(可选)。
需要额外安装本地依赖:
uv sync --extra local-embedding
模型 BAAI/bge-m3 首次加载约 2.3 GB(从 HuggingFace 自动下载)。
默认 1024 维,与 DashScope 兼容。
"""
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING
from loguru import logger
from .base import AsyncEmbeddingProvider, EmbeddingProvider
from .models import EmbeddingError
if TYPE_CHECKING:
from sentence_transformers import SentenceTransformer
from configs.loader import load_scene_config
from configs.runtime_env import env_get
LOCAL_DEFAULT_MODEL = "BAAI/bge-m3"
LOCAL_DEFAULT_DIM = 1024
def _read_env(key: str, default: str | None = None) -> str | None:
"""读取环境变量(先热加载 .env,改文件后无需重启进程)。"""
return env_get(key, default)
def _try_import_st() -> type[SentenceTransformer]:
try:
from sentence_transformers import SentenceTransformer # noqa: F811
except ImportError as e:
raise EmbeddingError(
"本地 BGE-M3 后端需要 sentence-transformers,请执行: "
"uv sync --extra local-embedding"
) from e
return SentenceTransformer
class LocalBGEEmbeddingProvider(EmbeddingProvider):
"""本地 BGE-M3 同步实现。"""
name = "local-bge"
def __init__(
self,
*,
model: str | None = None,
device: str | None = None,
normalize: bool = True,
) -> None:
st_cls = _try_import_st()
# 模型优先级:CLI/参数 > YAML scenes.embedding > LOCAL_EMBEDDING_MODEL > 默认值
# 不读全局 EMBEDDING_MODEL,避免与 DashScope 冲突
scene_model = load_scene_config("embedding").get("model")
self.model = (
model
or scene_model
or _read_env("LOCAL_EMBEDDING_MODEL", LOCAL_DEFAULT_MODEL)
or LOCAL_DEFAULT_MODEL
)
self.dim = LOCAL_DEFAULT_DIM
self._device = device # None -> 让 ST 自选 cpu/cuda
self._normalize = normalize
logger.info("加载本地嵌入模型 {} (device={}),首次会下载...", self.model, device or "auto")
self._st = st_cls(self.model, device=device)
# 实际维度自检
actual_dim = self._st.get_sentence_embedding_dimension()
if actual_dim and actual_dim != self.dim:
logger.warning(
"BGE 模型实际维度 {} 与默认 {} 不一致,以实际为准", actual_dim, self.dim
)
self.dim = actual_dim
def embed_batch(self, texts: list[str]) -> list[list[float]]:
if not texts:
return []
try:
arr = self._st.encode(
texts,
normalize_embeddings=self._normalize,
convert_to_numpy=True,
show_progress_bar=False,
)
except Exception as e: # noqa: BLE001
raise EmbeddingError(f"BGE-M3 推理失败: {type(e).__name__}: {e}") from e
return [v.tolist() for v in arr]
class LocalBGEAsyncEmbeddingProvider(AsyncEmbeddingProvider):
"""本地 BGE-M3 异步包装。
sentence-transformers 本身是同步的,这里用 asyncio.to_thread 包一层,
主要为了让批处理脚本能用统一的 async 接口。
"""
name = "local-bge"
def __init__(self, **kwargs: object) -> None:
self._sync = LocalBGEEmbeddingProvider(**kwargs) # type: ignore[arg-type]
self.model = self._sync.model
self.dim = self._sync.dim
async def embed_batch(self, texts: list[str]) -> list[list[float]]:
return await asyncio.to_thread(self._sync.embed_batch, texts)