Files
PlantAI-rag-new/vstore/retrieval.py
T
jerryandClaude Fable 5 5db3ead73e feat(docgen): doc_writer 能力全链——vstore 独立服务 + BFF docgen 路由 + 前端文档工作台
- vstore 独立安装包(8094):解析/嵌入/加密 vault/faiss 检索,存储路径支持本地 file 与 http(s):// URL,启动握手校验,集群化预留(embed 独立模块)
- BFF docgen 路由:上传入库/generate/编辑/导出/下载/主库管理,意图护栏(与前端同词典),vstore 失败兜底,quota 507 与 TTL 惰性清理
- 前端:自然触发意图路由、拖拽上传 chips、能力徽标、右侧文档 TAB(WYSIWYG 编辑/prompt 修改/PDF+DOCX 导出下载)、右栏拖宽+手柄仿左栏
- 批量导入工具 docgen_import.py(并发 8、导入即加密+删源明文)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-03 21:13:37 +08:00

267 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""retrieval.py —— faiss 向量检索 + index.json 单一事实源。
- IndexFlatIP(内积,配合 L2 归一化向量即余弦相似度)
- index.json:检索元数据单一事实源(行号 == json 下标);faiss 内存索引
可随时由 index.json 重建
- search 三档降级:向量 → meta 关键词 → 最新 N 条
- 增量写锁(threading.Lock);rebuild 临时索引 + os.replace 原子切换
"""
from __future__ import annotations
import json
import logging
import os
import threading
from pathlib import Path
from typing import Any
import faiss
import numpy as np
from .settings import (
DEFAULT_TOP_K,
EMBED_DIM,
FALLBACK_LATEST_N,
MAX_TOP_K,
)
logger = logging.getLogger(__name__)
INDEX_FILE = "index.json"
INDEX_TMP_SUFFIX = ".rebuild.tmp"
class RetrievalError(Exception):
"""检索/索引操作失败。"""
def _now_iso() -> str:
from datetime import datetime, timezone
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _normalize(vec: list[float]) -> np.ndarray:
"""L2 归一化(IndexFlatIP 下等价余弦)。零向量原样返回。"""
arr = np.asarray(vec, dtype=np.float32)
norm = float(np.linalg.norm(arr))
if norm > 0:
arr = arr / norm
return arr
class RetrievalIndex:
"""检索索引:index.json + faiss 内存索引(同一目录,单一事实源)。"""
def __init__(self, index_path: str | Path, dim: int = EMBED_DIM) -> None:
self.dir = Path(index_path)
self.dim = dim
self._lock = threading.Lock()
self._faiss: faiss.IndexFlatIP = faiss.IndexFlatIP(dim)
self._rows: list[dict[str, Any]] = []
# ---- 加载 / 落盘 ----
@property
def json_path(self) -> Path:
return self.dir / INDEX_FILE
def load(self) -> None:
"""从 index.json 加载行并重建 faiss。文件不存在视为空库。"""
with self._lock:
self._rows = []
self._faiss = faiss.IndexFlatIP(self.dim)
path = self.json_path
if not path.exists():
return
try:
data = json.loads(path.read_text(encoding="utf-8"))
items = data.get("items", []) if isinstance(data, dict) else []
except (ValueError, OSError) as exc:
logger.warning("retrieval: index.json 读取失败,按空库处理:%s", exc)
return
for row_id, item in enumerate(items):
if not isinstance(item, dict):
continue
row = dict(item)
row["row_id"] = row_id
self._rows.append(row)
vec = row.get("vector")
if isinstance(vec, list) and vec:
self._faiss.add(_normalize(vec).reshape(1, self.dim))
def _dump_locked(self, path: Path) -> None:
"""写 json(需持有锁)。"""
tmp = path.with_name(path.name + INDEX_TMP_SUFFIX)
payload = {"version": 1, "items": [{k: v for k, v in r.items() if k != "row_id"} for r in self._rows]}
tmp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
os.replace(tmp, path)
def save(self) -> None:
"""落盘(原子)。"""
with self._lock:
self.dir.mkdir(parents=True, exist_ok=True)
self._dump_locked(self.json_path)
# ---- 增删 ----
def add_item(self, item: dict[str, Any], vector: list[float]) -> int:
"""追加一行(json 下标 == 行号)。返回 row_id。"""
with self._lock:
self.dir.mkdir(parents=True, exist_ok=True)
row_id = len(self._rows)
row = dict(item)
row["row_id"] = row_id
row["vector"] = list(vector)
self._rows.append(row)
self._faiss.add(_normalize(vector).reshape(1, self.dim))
self._dump_locked(self.json_path)
return row_id
def remove_item(self, document_id: str) -> bool:
"""按 document_id 删除(faiss 不支持逐行删,整库重建——库规模小可接受)。"""
with self._lock:
before = len(self._rows)
self._rows = [r for r in self._rows if str(r.get("id")) != document_id]
self._faiss = faiss.IndexFlatIP(self.dim)
for row in self._rows:
vec = row.get("vector")
if isinstance(vec, list) and vec:
self._faiss.add(_normalize(vec).reshape(1, self.dim))
if self.dir.exists():
self._dump_locked(self.json_path)
return len(self._rows) < before
def update_meta(self, document_id: str, meta: dict[str, Any]) -> bool:
"""更新行的 meta 字段(向量不变)。"""
with self._lock:
for row in self._rows:
if str(row.get("id")) == document_id:
for key, value in meta.items():
if key != "vector":
row[key] = value
if self.dir.exists():
self._dump_locked(self.json_path)
return True
return False
# ---- 查询 ----
def rows(self) -> list[dict[str, Any]]:
"""行快照(不含 vector,供列表接口)。"""
with self._lock:
return [{k: v for k, v in r.items() if k != "vector"} for r in self._rows]
def count(self) -> int:
with self._lock:
return len(self._rows)
def _match_meta(self, row: dict[str, Any], keywords: list[str]) -> bool:
"""meta 关键词匹配:name/category/tags/description 任一命中任一关键词。"""
if not keywords:
return True
haystack = " ".join(
str(row.get(k) or "") for k in ("name", "category", "tags", "description")
).lower()
return any(kw.lower() in haystack for kw in keywords)
def search(
self,
query_vector: list[float],
top_k: int = DEFAULT_TOP_K,
category: str | None = None,
as_template: bool | None = None,
keywords: list[str] | None = None,
) -> dict[str, Any]:
"""三档降级检索:向量 → meta 关键词 → 最新 N 条。
返回 {"mode": "vector"|"keyword"|"latest"|"empty", "items": [...]}。
"""
top_k = max(1, min(int(top_k), MAX_TOP_K))
with self._lock:
rows = self._rows
def _filtered(candidates: list[dict[str, Any]]) -> list[dict[str, Any]]:
out = []
for r in candidates:
if category and str(r.get("category", "")) != category:
continue
if as_template is not None and bool(r.get("as_template")) != bool(as_template):
continue
out.append(r)
return out
# 一档:向量
if rows:
try:
q = _normalize(query_vector).reshape(1, self.dim)
dist, idx = self._faiss.search(q, min(top_k, len(rows)))
vector_hits = []
for score, row_idx in zip(dist[0].tolist(), idx[0].tolist()):
if 0 <= row_idx < len(rows):
vector_hits.append((float(score), rows[row_idx]))
filtered = _filtered([r for _, r in vector_hits])
# 分数阈值:零向量查询(或过滤后无相关命中)时
# 最高分仍 ≈ 0,视为无命中 → 降级下一档
best_score = vector_hits[0][0] if vector_hits else 0.0
if filtered and best_score > 0.01:
return {
"mode": "vector",
"items": [
{k: v for k, v in r.items() if k != "vector"}
for r in filtered[:top_k]
],
}
except Exception as exc:
logger.warning("retrieval: 向量检索失败,降级关键词:%s", exc)
# 二档:meta 关键词
if keywords:
kw_hits = [r for r in rows if self._match_meta(r, keywords)]
filtered = _filtered(kw_hits)
if filtered:
return {
"mode": "keyword",
"items": [
{k: v for k, v in r.items() if k != "vector"}
for r in filtered[:top_k]
],
}
# 三档:最新 N 条(created_at 倒序)
latest = sorted(
rows, key=lambda r: str(r.get("created_at") or ""), reverse=True
)
filtered = _filtered(latest)
if filtered:
return {
"mode": "latest",
"items": [
{k: v for k, v in r.items() if k != "vector"}
for r in filtered[: min(top_k, FALLBACK_LATEST_N)]
],
}
return {"mode": "empty", "items": []}
def rebuild(self, items: list[tuple[dict[str, Any], list[float]]]) -> int:
"""整库重建:items = [(meta_row, vector), ...]。
临时文件写好后 os.replace 原子切换到 index.json;faiss 同步重建。
返回行数。
"""
with self._lock:
self.dir.mkdir(parents=True, exist_ok=True)
new_rows: list[dict[str, Any]] = []
new_faiss = faiss.IndexFlatIP(self.dim)
for row_id, (item, vector) in enumerate(items):
row = dict(item)
row["row_id"] = row_id
row["vector"] = list(vector)
new_rows.append(row)
new_faiss.add(_normalize(vector).reshape(1, self.dim))
# 原子切换:写临时文件 → os.replace
self._rows = new_rows
self._faiss = new_faiss
self._dump_locked(self.json_path)
return len(new_rows)