2026-09-03 21:13:37 +08:00
|
|
|
|
"""embed.py —— ollama 嵌入调用封装(httpx,并发 ≤8,bge-m3 1024 维)。
|
|
|
|
|
|
|
|
|
|
|
|
- embed_batch(texts):同步函数,分批并发调 POST {url}/api/embed
|
|
|
|
|
|
- 空文本返回零向量;维度不符抛 EmbedError
|
|
|
|
|
|
- 单元测试注入假 embed(monkeypatch),本模块不依赖真 ollama
|
|
|
|
|
|
"""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
|
|
|
|
|
|
from .settings import (
|
|
|
|
|
|
DEFAULT_EMBED_MODEL,
|
|
|
|
|
|
DEFAULT_EMBED_URL,
|
|
|
|
|
|
EMBED_CONCURRENCY,
|
|
|
|
|
|
EMBED_DIM,
|
|
|
|
|
|
EMBED_TIMEOUT,
|
|
|
|
|
|
get_paths,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class EmbedError(Exception):
|
|
|
|
|
|
"""嵌入调用失败(服务不可达/维度不符/响应异常)。"""
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-07 22:22:22 +08:00
|
|
|
|
# 测试注入钩子(仿 vault.set_embed_fn 模式):T1 用例按契约名 _CHUNKED_REQUEST_FN
|
|
|
|
|
|
# monkeypatch 本模块该函数后,embed_batch_chunked 不触网。
|
|
|
|
|
|
def _CHUNKED_REQUEST_FN(payload: dict) -> list[list[float]]:
|
|
|
|
|
|
"""单批 /api/embed 请求(input 数组)。返回该批 embedding 列表(真 Ollama HTTP)。"""
|
|
|
|
|
|
paths = get_paths()
|
|
|
|
|
|
url = (payload.get("url") or paths.embed_url or DEFAULT_EMBED_URL).rstrip("/")
|
|
|
|
|
|
model = payload.get("model") or paths.embed_model or DEFAULT_EMBED_MODEL
|
|
|
|
|
|
inputs = [str(t or "") for t in payload.get("input") or []]
|
|
|
|
|
|
try:
|
|
|
|
|
|
resp = httpx.post(
|
|
|
|
|
|
f"{url}/api/embed",
|
|
|
|
|
|
json={"model": model, "input": inputs},
|
|
|
|
|
|
timeout=EMBED_TIMEOUT,
|
|
|
|
|
|
)
|
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
|
data = resp.json()
|
|
|
|
|
|
embeddings = data.get("embeddings")
|
|
|
|
|
|
if not isinstance(embeddings, list):
|
|
|
|
|
|
raise EmbedError(f"嵌入响应形态异常:{str(data)[:200]}")
|
|
|
|
|
|
out: list[list[float]] = []
|
|
|
|
|
|
for vec in embeddings:
|
|
|
|
|
|
if not isinstance(vec, list):
|
|
|
|
|
|
raise EmbedError(f"嵌入响应形态异常:{str(data)[:200]}")
|
|
|
|
|
|
vector = [float(v) for v in vec]
|
|
|
|
|
|
if len(vector) != EMBED_DIM:
|
|
|
|
|
|
raise EmbedError(f"嵌入维度不符:期望 {EMBED_DIM},实际 {len(vector)}")
|
|
|
|
|
|
out.append(vector)
|
|
|
|
|
|
if len(out) != len(inputs):
|
|
|
|
|
|
raise EmbedError(f"嵌入条数不符:期望 {len(inputs)},实际 {len(out)}")
|
|
|
|
|
|
return out
|
|
|
|
|
|
except EmbedError:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except (httpx.HTTPError, ValueError) as exc:
|
|
|
|
|
|
raise EmbedError(f"嵌入服务调用失败:{exc}") from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
CHUNKED_BATCH_SIZE = 16 # embed_batch_chunked 每批条数(任务书契约)
|
|
|
|
|
|
CHUNKED_WORKERS = 4 # 批并发线程数(任务书契约)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-03 21:13:37 +08:00
|
|
|
|
def _zero_vector() -> list[float]:
|
|
|
|
|
|
return [0.0] * EMBED_DIM
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _request_one(client: httpx.Client, url: str, model: str, text: str) -> list[float]:
|
|
|
|
|
|
"""单条嵌入请求。空文本返回零向量(不打上游)。"""
|
|
|
|
|
|
if not text or not text.strip():
|
|
|
|
|
|
return _zero_vector()
|
|
|
|
|
|
try:
|
|
|
|
|
|
resp = client.post(
|
|
|
|
|
|
f"{url.rstrip('/')}/api/embed",
|
|
|
|
|
|
json={"model": model, "input": text},
|
|
|
|
|
|
timeout=EMBED_TIMEOUT,
|
|
|
|
|
|
)
|
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
|
payload = resp.json()
|
|
|
|
|
|
embeddings = payload.get("embeddings")
|
|
|
|
|
|
if not embeddings or not isinstance(embeddings[0], list):
|
|
|
|
|
|
raise EmbedError(f"嵌入响应形态异常:{str(payload)[:200]}")
|
|
|
|
|
|
vector = [float(v) for v in embeddings[0]]
|
|
|
|
|
|
if len(vector) != EMBED_DIM:
|
|
|
|
|
|
raise EmbedError(f"嵌入维度不符:期望 {EMBED_DIM},实际 {len(vector)}")
|
|
|
|
|
|
return vector
|
|
|
|
|
|
except EmbedError:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except (httpx.HTTPError, ValueError) as exc:
|
|
|
|
|
|
raise EmbedError(f"嵌入服务调用失败:{exc}") from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def embed_batch(
|
|
|
|
|
|
texts: list[str],
|
|
|
|
|
|
url: str | None = None,
|
|
|
|
|
|
model: str | None = None,
|
|
|
|
|
|
) -> list[list[float]]:
|
|
|
|
|
|
"""批量嵌入:线程池并发 ≤ EMBED_CONCURRENCY,保持输入顺序。
|
|
|
|
|
|
|
|
|
|
|
|
url/model 缺省取 settings(VSTORE_EMBED_URL / VSTORE_EMBED_MODEL)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not texts:
|
|
|
|
|
|
return []
|
|
|
|
|
|
paths = get_paths()
|
|
|
|
|
|
base_url = url or paths.embed_url or DEFAULT_EMBED_URL
|
|
|
|
|
|
model_name = model or paths.embed_model or DEFAULT_EMBED_MODEL
|
|
|
|
|
|
|
|
|
|
|
|
results: list[list[float] | None] = [None] * len(texts)
|
|
|
|
|
|
with httpx.Client() as client:
|
|
|
|
|
|
with ThreadPoolExecutor(max_workers=EMBED_CONCURRENCY) as pool:
|
|
|
|
|
|
futures = {
|
|
|
|
|
|
pool.submit(_request_one, client, base_url, model_name, text): idx
|
|
|
|
|
|
for idx, text in enumerate(texts)
|
|
|
|
|
|
}
|
|
|
|
|
|
for future in futures:
|
|
|
|
|
|
idx = futures[future]
|
|
|
|
|
|
try:
|
|
|
|
|
|
results[idx] = future.result()
|
|
|
|
|
|
except EmbedError as exc:
|
|
|
|
|
|
logger.warning("embed: 第 %d 条嵌入失败:%s", idx, exc)
|
|
|
|
|
|
results[idx] = None
|
|
|
|
|
|
except Exception as exc: # 线程池其他异常也降级为零向量
|
|
|
|
|
|
logger.warning("embed: 第 %d 条嵌入异常:%s", idx, exc)
|
|
|
|
|
|
results[idx] = None
|
|
|
|
|
|
return [v if v is not None else _zero_vector() for v in results]
|
2026-09-07 22:22:22 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def embed_batch_chunked(
|
|
|
|
|
|
texts: list[str],
|
|
|
|
|
|
batch_size: int = CHUNKED_BATCH_SIZE,
|
|
|
|
|
|
workers: int = CHUNKED_WORKERS,
|
|
|
|
|
|
url: str | None = None,
|
|
|
|
|
|
model: str | None = None,
|
|
|
|
|
|
) -> list[list[float]]:
|
|
|
|
|
|
"""chunk 批量嵌入:/api/embed 的 input 数组(batch 16 × workers 4 并发)。
|
|
|
|
|
|
|
|
|
|
|
|
与既有 embed_batch(单条循环、并发 8)区分。嵌入纪律(FR-PRIV-002):
|
|
|
|
|
|
单批失败整批重试一次,仍失败该批全部降级零向量;保持输入顺序;
|
|
|
|
|
|
批间并发上限 = workers(线程数)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not texts:
|
|
|
|
|
|
return []
|
|
|
|
|
|
batch_size = max(1, int(batch_size))
|
|
|
|
|
|
workers = max(1, int(workers))
|
|
|
|
|
|
batches = [texts[i : i + batch_size] for i in range(0, len(texts), batch_size)]
|
|
|
|
|
|
results: list[list[float]] = [None] * len(texts)
|
|
|
|
|
|
|
|
|
|
|
|
def run_batch(offset: int, batch: list[str]) -> None:
|
|
|
|
|
|
payload = {"input": list(batch), "url": url, "model": model}
|
|
|
|
|
|
vecs: list[list[float]] | None = None
|
|
|
|
|
|
try:
|
|
|
|
|
|
vecs = _CHUNKED_REQUEST_FN(payload)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.warning("chunks: 批量嵌入失败(第 %d 批,首调):%s", offset, exc)
|
|
|
|
|
|
try:
|
|
|
|
|
|
vecs = _CHUNKED_REQUEST_FN(payload)
|
|
|
|
|
|
except Exception as exc2:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"chunks: 批量嵌入重试仍失败(第 %d 批),降级零向量:%s", offset, exc2
|
|
|
|
|
|
)
|
|
|
|
|
|
vecs = None
|
|
|
|
|
|
if vecs is None:
|
|
|
|
|
|
for i in range(len(batch)):
|
|
|
|
|
|
results[offset + i] = _zero_vector()
|
|
|
|
|
|
return
|
|
|
|
|
|
for i, vec in enumerate(vecs):
|
|
|
|
|
|
if offset + i >= len(results):
|
|
|
|
|
|
break
|
|
|
|
|
|
if isinstance(vec, list) and len(vec) == EMBED_DIM:
|
|
|
|
|
|
results[offset + i] = vec
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.warning("chunks: 第 %d 条嵌入维度不符,降级零向量", offset + i)
|
|
|
|
|
|
results[offset + i] = _zero_vector()
|
|
|
|
|
|
for i in range(len(vecs), len(batch)): # 响应条数不足:缺的降级零向量
|
|
|
|
|
|
if offset + i < len(results):
|
|
|
|
|
|
results[offset + i] = _zero_vector()
|
|
|
|
|
|
|
|
|
|
|
|
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
|
|
|
|
futures = []
|
|
|
|
|
|
offset = 0
|
|
|
|
|
|
for batch in batches:
|
|
|
|
|
|
futures.append(pool.submit(run_batch, offset, batch))
|
|
|
|
|
|
offset += len(batch)
|
|
|
|
|
|
for future in futures:
|
|
|
|
|
|
try:
|
|
|
|
|
|
future.result()
|
|
|
|
|
|
except Exception as exc: # 线程池兜底异常:整批降级零向量
|
|
|
|
|
|
logger.warning("chunks: 批量嵌入线程异常:%s", exc)
|
|
|
|
|
|
return [v if v is not None else _zero_vector() for v in results]
|