Files
my_bookmate/backend/app/rag/vector.py
T

219 lines
9.1 KiB
Python
Raw Normal View History

"""向量检索服务(B 档)。
- embedding:调用 OpenAI 兼容 /embeddings API(如硅基流动 `BAAI/bge-m3`,1024 维)
- rerank:调用 /v1/rerank API(如硅基流动 `BAAI/bge-reranker-v2-m3`)
- 检索:书本段规模几千~几万,直接全量 numpy 余弦扫描,无需引入向量数据库
- 融合:关键词结果 + 向量结果做 RRF(Reciprocal Rank Fusion)
- 降级:未配置 API Key / 调用失败时自动退回纯关键词模式,绝不阻塞主流程
"""
import json
import logging
import threading
import numpy as np
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.book import Segment
logger = logging.getLogger(__name__)
EMBEDDING_ENABLED = bool(settings.embedding_api_key)
RERANK_ENABLED = bool(settings.rerank_api_key)
# book_id -> 向量生成状态:"done" / "running" / "failed"
_vector_status: dict[str, str] = {}
_status_lock = threading.Lock()
# ======================= API 客户端 =======================
async def embed_texts(texts: list[str]) -> list[list[float] | None] | None:
"""生成一批文本的向量,返回与 texts 同序的列表(单条失败置 None,其余正常)。
单次请求过大 / 服务端失败时自动对半拆开递归重试,直到单条;
单条超模型输入上限时截断重试一次,仍失败则跳过该条(置 None)。
未配置时返回 None;鉴权/配置类错误(API 不可用)直接抛出。
"""
if not EMBEDDING_ENABLED or not texts:
return None
url = settings.embedding_base_url.rstrip("/") + "/embeddings"
headers = {"Authorization": f"Bearer {settings.embedding_api_key}"}
out: list[list[float] | None] = [None] * len(texts)
# 空文本无法向量化(OpenRouter 对空串直接 400 too_small),预过滤不送请求
tasks: list[tuple[int, str]] = [(i, t) for i, t in enumerate(texts) if t and t.strip()]
if not tasks:
return out
async with httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=15.0)) as client:
async def _call(items: list[str], idxs: list[int]) -> None:
if not items:
return
try:
resp = await client.post(
url, headers=headers,
json={"model": settings.embedding_model, "input": items},
)
resp.raise_for_status()
rows = resp.json().get("data") or []
for j, row in enumerate(rows):
idx = row.get("index", j)
if 0 <= idx < len(idxs) and isinstance(row.get("embedding"), list):
out[idxs[idx]] = [float(v) for v in row["embedding"]]
except Exception as e: # noqa: BLE001
# 鉴权/配置错误拆分也救不了,直接整体失败
if isinstance(e, httpx.HTTPStatusError) and e.response.status_code in (401, 403, 404, 405):
raise
if len(items) > 1:
mid = len(items) // 2
await _call(items[:mid], idxs[:mid])
await _call(items[mid:], idxs[mid:])
elif len(items[0]) > 1500:
# 单条超模型输入上限:截断再试一次(超长段向量化本就意义有限)
try:
resp = await client.post(
url, headers=headers,
json={"model": settings.embedding_model, "input": [items[0][:1500]]},
)
resp.raise_for_status()
rows = resp.json().get("data") or []
if rows and isinstance(rows[0].get("embedding"), list):
out[idxs[0]] = [float(v) for v in rows[0]["embedding"]]
except Exception as e2: # noqa: BLE001
logger.warning("embedding 单条失败(跳过): %s", e)
else:
logger.warning("embedding 单条失败(跳过): %s", e)
await _call([t for _, t in tasks], [i for i, _ in tasks])
return out
async def rerank_documents(query: str, documents: list[str], top_n: int | None = None) -> list[int] | None:
"""调用 rerank API。返回按相关性降序的原始文档索引;未配置或失败返回 None。"""
if not RERANK_ENABLED or not documents:
return None
url = settings.rerank_base_url.rstrip("/") + "/rerank"
headers = {"Authorization": f"Bearer {settings.rerank_api_key}"}
payload = {"model": settings.rerank_model, "query": query, "documents": documents}
if top_n:
payload["top_n"] = top_n
try:
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(url, headers=headers, json=payload)
resp.raise_for_status()
results = resp.json()["results"]
ordered = sorted(results, key=lambda r: -float(r["relevance_score"]))
return [int(r["index"]) for r in ordered]
except Exception as e: # noqa: BLE001
logger.warning("rerank API 调用失败: %s", e)
return None
# ======================= 向量检索 =======================
async def load_vectors(db: AsyncSession, book_id: str) -> tuple[np.ndarray | None, list[Segment]]:
"""读取该书所有已生成向量的段。返回 (向量矩阵, 对应段列表)。"""
result = await db.execute(
select(Segment)
.where(Segment.book_id == book_id, Segment.embedding.is_not(None))
.order_by(Segment.order_idx)
)
segs = list(result.scalars().all())
vecs = []
kept = []
for s in segs:
try:
vecs.append(json.loads(s.embedding))
kept.append(s)
except (json.JSONDecodeError, TypeError):
continue
if not vecs:
return None, []
return np.asarray(vecs, dtype=np.float32), kept
def vector_search(query_vec: list[float], mat: np.ndarray, top_k: int) -> list[int]:
"""余弦相似度 top_k,返回 mat 行索引(降序)。"""
q = np.asarray(query_vec, dtype=np.float32)
q_norm = np.linalg.norm(q)
if q_norm == 0:
return []
sims = (mat @ q) / (np.linalg.norm(mat, axis=1) * q_norm + 1e-9)
top = np.argsort(-sims)[:top_k]
return [int(i) for i in top]
def rrf_fuse(*rankings: list[str], k: int = 60) -> list[str]:
"""Reciprocal Rank Fusion:合并多个按相关性降序的 segment_id 列表。"""
scores: dict[str, float] = {}
for ranking in rankings:
for rank, sid in enumerate(ranking):
scores[sid] = scores.get(sid, 0.0) + 1.0 / (k + rank + 1)
return [sid for sid, _ in sorted(scores.items(), key=lambda kv: -kv[1])]
# ======================= 向量生成(上传后后台 + 检索前懒补)=======================
def _set_status(book_id: str, status: str) -> None:
with _status_lock:
_vector_status[book_id] = status
def get_status(book_id: str) -> str | None:
with _status_lock:
return _vector_status.get(book_id)
async def ensure_embeddings(db: AsyncSession, book_id: str) -> str:
"""确保 book 的每个 Segment 都有向量。返回状态 done / running / failed / disabled。
- 后台任务与请求可能并发调用:running 状态会直接返回,由另一方补齐
- 只对缺少向量的段生成,重复调用是增量补差
"""
if not EMBEDDING_ENABLED:
_set_status(book_id, "failed")
return "disabled"
status = get_status(book_id)
if status == "running":
return "running"
result = await db.execute(
select(Segment)
.where(Segment.book_id == book_id, Segment.embedding.is_(None))
.order_by(Segment.order_idx)
)
missing = list(result.scalars().all())
# 空文本段无法向量化(API 对空串 400),也永远无检索价值,直接排除
missing = [s for s in missing if (s.text or "").strip()]
if not missing:
_set_status(book_id, "done")
return "done"
_set_status(book_id, "running")
try:
batch = settings.embedding_batch_size
for start in range(0, len(missing), batch):
segs = missing[start:start + batch]
vecs = await embed_texts([s.text for s in segs])
if vecs is None:
raise RuntimeError("embedding API 不可用")
# 单条失败时 vec 为 None,跳过不落库;其余照常持久化
for seg, vec in zip(segs, vecs):
if vec is not None:
seg.embedding = json.dumps([float(v) for v in vec], ensure_ascii=False)
await db.commit()
# 已尝试全部缺失段。个别被模型拒绝的段(内容/超限)由关键词档兜底,
# 标记 done 避免每次检索都重复重试;API 级故障会在 except 里落为 failed
_set_status(book_id, "done")
return "done"
except Exception as e: # noqa: BLE001
logger.warning("向量生成失败 book=%s: %s", book_id, e)
_set_status(book_id, "failed")
# 已提交的批次不受影响;rollback 仅丢弃本批次未提交变更
await db.rollback()
return "failed"