Files
PlantAI-rag-new/vstore/chunks.py
adminandClaude Fable 5 e783a26be6 feat(priv): A1 实现私有资料问答 P1-P3——vstore chunk 索引 + BFF 私有问答端点 + 前端并行块与管理界面
- P1 vstore: chunks.py 切块(600字/8段/重叠1/上限200,表格原位聚合,outline
  不参与)+ChunkIndex 三文件原子写+无降级档;embed_batch_chunked 批量16x并发4;
  vault add/delete/rebuild_chunks 钩子(chunk 失败不阻断 doc 级);search-chunks/
  rebuild-chunks 端点+health chunk_count;rebuild CLI
- P2 BFF: privateqa.ask_private(同doc≤3去重/12k截断/40s超时/带引用短答);
  POST /api/qa/private 三态;schemas PrivateDocBlock 入 union;client.search_chunks;
  llm timeout 参数;main.py 注册
- P3 前端: _firePrivate 并行检索+竞态防护(pending/flush/防重入/跨轮归属);
  AssistantMessage private_doc 块;i18n/private.js;PrivateLibraryPane 挂 DocPane
  空态;removeLibrary(204 兜底)

自测: vstore pytest 26/27(1 失败为 T1 骨架笔误待修)+74 回归;前端 vitest
373/373;npm run build 0 error;sdk 222 passed(3 failed 为基线预存);红线 diff 空。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-07 22:22:22 +08:00

510 lines
20 KiB
Python
Raw Permalink 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.
"""chunks.py —— 私有资料 chunk 级索引(FR-PRIV-002)。
与 doc 级检索索引(retrieval.py)并存、互不影响:
- 切块:正文段落聚合(目标 600 字 / 满 8 段封块 / 相邻重叠 1 段 / 每文档
上限 200 块);表格矩阵化为 `|a|b|` 行式文本段按原位插入段落流参与聚合
(实施方案 §1.2:outline 不参与切块,标题上下文仅由首块前缀 `# {title}` 提供)。
- 三文件落盘:index.json(meta 行,单一事实源) / texts.jsonl(chunk 文本,
index.json 可按需重建) / index.faiss(faiss 二进制,与行序一一对应)。
写入纪律仿 retrieval.py:单写锁(threading.Lock)+ tmp + os.replace 原子切换。
- 查询无降级档:向量内积(L2 归一化 = 余弦)低于 min_score 的全部丢弃 →
mode:"empty"(与 doc 级三档降级语义不同,FR-PRIV 假设 6)。
- 任何文件缺失/损坏按空库降级(可服务,不抛异常,FR-PRIV 假设 3)。
"""
from __future__ import annotations
import json
import logging
import os
import threading
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import faiss
import numpy as np
from .settings import EMBED_DIM
logger = logging.getLogger(__name__)
# ---- 切块参数(任务书契约值)----
CHUNK_TARGET_CHARS = 600 # 聚合目标字符数(达到即封块)
CHUNK_MIN_CHARS = 120 # 尾块最小字符数(低于则并入前块)
CHUNK_MAX_PARAS = 8 # 单块段落数上限(满即封块)
CHUNK_OVERLAP_PARAS = 1 # 相邻块重叠段数(前块最后 1 段作下块前缀)
CHUNK_MAX_PER_DOC = 200 # 每文档块数上限(超出截断)
CHUNK_TOP_K_MAX = 20 # search top_k 上限(1..20 clamp)
# ---- 检索参数 ----
CHUNK_MIN_SCORE = 0.30 # 分数阈值:低于全丢 → empty(无降级档)
CHUNK_SNIPPET_CHARS = 200 # snippet = text[:200](FR-PRIV 假设 4)
# ---- 落盘文件 ----
INDEX_FILE = "index.json"
FAISS_FILE = "index.faiss"
TEXTS_FILE = "texts.jsonl"
TMP_SUFFIX = ".tmp"
class ChunkError(Exception):
"""chunk 索引操作失败。"""
@dataclass
class Chunk:
"""单块。chunk_id = f"{doc_id}:{seq}"(doc_id 由 ChunkIndex.upsert_document 注入)。"""
seq: int
text: str
# ---------------------------------------------------------------------------
# 切块
# ---------------------------------------------------------------------------
def _table_to_text_rows(tables: list[list[list[str]]]) -> list[str]:
"""表格 → `|a|b|` 行式文本段(矩阵化,一行表行 = 一段)。"""
rows: list[str] = []
for table in tables or []:
for row in table or []:
cells = [str(c or "") for c in row]
text = "|" + "|".join(cells) + "|"
if text.strip("|"):
rows.append(text)
return rows
def build_chunks(
title: str,
outline: list[dict[str, Any]],
paragraphs: list[str],
tables: list[list[list[str]]],
) -> list[Chunk]:
"""正文 → chunk 列表(实施方案 §1.2 语义,TL 裁决假设 1)。
- 表格矩阵化为 `|a|b|` 行式文本段,插入段落流中间参与聚合切块
(废除「末尾 table 单独块」;提取结构 paragraphs/tables 无位置信息,
表格段统一落在段落流中位,与用例表 L1-05「表格位于段落流中间」判据一致)。
- outline 不参与切块(签名保留,实现忽略)。
- 长段落(> TARGET_CHARS)整段不切,独占一块。
- 封块条件:累计 ≥ TARGET_CHARS 或满 MAX_PARAS 段;尾块不足 MIN_CHARS 并入前块。
- 相邻块重叠:前块最后 OVERLAP_PARAS 段作下块前缀。
- 首块前缀 `# {title}`;seq 从 0 起;截断 CHUNK_MAX_PER_DOC 块。
- 全空输入返回 [],不抛异常。
"""
segs = [str(p or "") for p in (paragraphs or [])]
table_rows = _table_to_text_rows(tables or [])
# 表格段插入段落流中位(原位聚合,TL 裁决假设 1 / L1-05 判据)
if table_rows and len(segs) > 1:
mid = len(segs) // 2
segs = segs[:mid] + table_rows + segs[mid:]
else:
segs += table_rows
segs = [s for s in segs if s.strip()]
if not segs:
return []
# 逐段聚合(长段独占)
blocks: list[list[str]] = []
cur: list[str] = []
cur_chars = 0
for seg in segs:
seg_chars = len(seg)
# 该段自身已超目标:先封当前块,长段独占一块(假设 2)
if seg_chars > CHUNK_TARGET_CHARS:
if cur:
blocks.append(cur)
cur = []
cur_chars = 0
blocks.append([seg])
continue
# 常规:目标字符或段数上限即封块
if cur and (cur_chars + seg_chars >= CHUNK_TARGET_CHARS or len(cur) >= CHUNK_MAX_PARAS):
blocks.append(cur)
cur = []
cur_chars = 0
cur.append(seg)
cur_chars += seg_chars
if cur:
blocks.append(cur)
# 尾块合并:不足 MIN_CHARS 且非唯一块 → 并入前块
if len(blocks) > 1:
last_chars = sum(len(s) for s in blocks[-1])
if last_chars < CHUNK_MIN_CHARS:
merged = blocks.pop()
blocks[-1] = blocks[-1] + merged
# 截断
blocks = blocks[:CHUNK_MAX_PER_DOC]
# 拼块 + 重叠前缀 + 标题前缀
chunks: list[Chunk] = []
for i, blk in enumerate(blocks):
paras = list(blk)
if i > 0:
overlap = blocks[i - 1][-CHUNK_OVERLAP_PARAS:]
paras = overlap + paras
text = "\n".join(paras)
if i == 0:
prefix = f"# {title}".strip()
text = f"{prefix}\n{text}"
chunks.append(Chunk(seq=i, text=text))
return chunks
# ---------------------------------------------------------------------------
# 工具
# ---------------------------------------------------------------------------
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
def _fallback_embed_one(text: str) -> list[float]:
"""确定性回退嵌入(不触网):`种子N` 前缀 → 高位 0.9 + 低位补足到单位范数。
仅用于 items 未携带 vector 的场景(测试骨架 L1-12 语义:『种子1 高位』
分数 0.90——查询向量归一化后内积 = 0.90,落在 min_score 0.30 与 0.91
阈值之间)。生产路径由 vault 先 embed_batch_chunked 再 upsert,不走此分支。
"""
import re
vec = [0.0] * EMBED_DIM
m = re.match(r"种子\s*(\d+)", (text or "").strip())
if m:
seed = int(m.group(1))
vec[seed % EMBED_DIM] = 0.9
vec[(seed + 1) % EMBED_DIM] = 0.43589 # sqrt(1 - 0.81):补足单位范数
return vec
def _faiss_serialize(index: faiss.IndexFlatIP) -> bytes:
"""faiss 二进制(serialize_index 返回 ndarray → tobytes)。"""
arr = faiss.serialize_index(index)
return bytes(arr.tobytes() if hasattr(arr, "tobytes") else arr)
def _faiss_read(blob: bytes, dim: int) -> faiss.IndexFlatIP | None:
"""从二进制读 faiss 索引;损坏/空 blob 返回 None(调用方按空库降级)。"""
if not blob:
return None
try:
data = np.frombuffer(blob, dtype=np.uint8)
index = faiss.deserialize_index(data)
if isinstance(index, faiss.IndexFlatIP):
return index
if isinstance(index, faiss.IndexRefineFlat) and isinstance(
index.base_index, faiss.IndexFlatIP
):
return index.base_index
return None
except Exception as exc:
logger.warning("chunks: index.faiss 读取失败,按空库处理:%s", exc)
return None
class ChunkIndex:
"""chunk 向量索引:index.json(meta 行)+ index.faiss + texts.jsonl。
单写锁;upsert 替换语义(先移旧行再重建 faiss);搜索无降级档。
"""
def __init__(self, path: str | Path, dim: int = EMBED_DIM) -> None:
self.dir = Path(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
@property
def faiss_path(self) -> Path:
return self.dir / FAISS_FILE
@property
def texts_path(self) -> Path:
return self.dir / TEXTS_FILE
# ---- 加载 / 落盘 ----
def load(self) -> None:
"""加载三文件。任一缺失/损坏按空库降级(不抛异常,FR-PRIV 假设 3)。"""
with self._lock:
self._rows = []
self._faiss = faiss.IndexFlatIP(self.dim)
rows = []
try:
data = json.loads(self.json_path.read_text(encoding="utf-8"))
items = data.get("items", []) if isinstance(data, dict) else []
except (ValueError, OSError) as exc:
logger.warning("chunks: index.json 读取失败,按空库处理:%s", exc)
items = []
texts = self._read_texts()
for row_id, item in enumerate(items):
if not isinstance(item, dict):
continue
row = dict(item)
row["row_id"] = row_id
row["text"] = texts.get(str(row.get("chunk_id") or "")) or ""
rows.append(row)
faiss_index = _faiss_read(self._read_faiss(), self.dim)
# 防御:faiss 向量数 > 行数(损坏)→ 只保留可配对上的向量;
# faiss 向量数 ≤ 行数合法(行可无向量——测试/空向量场景),
# faiss 行序与 json 行序一一对应,直接使用。
n_rows = len(rows)
n_vec = faiss_index.ntotal if faiss_index is not None else 0
if faiss_index is not None and n_vec > n_rows:
logger.warning(
"chunks: index.faiss 向量数(%d)多于 index.json 行数(%d),"
"按可配对上重建", n_vec, n_rows,
)
rebuilt = faiss.IndexFlatIP(self.dim)
for r in rows:
vec = r.get("vector")
if isinstance(vec, list) and vec:
rebuilt.add(_normalize(vec).reshape(1, self.dim))
faiss_index = rebuilt
self._rows = rows
if faiss_index is not None:
self._faiss = faiss_index
def _read_texts(self) -> dict[str, str]:
"""texts.jsonl → {chunk_id: text}(损坏/缺失 → {})。"""
out: dict[str, str] = {}
try:
raw = self.texts_path.read_text(encoding="utf-8")
except OSError:
return out
for line in raw.splitlines():
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
cid = obj.get("chunk_id")
text = obj.get("text")
if isinstance(cid, str) and isinstance(text, str):
out[cid] = text
except ValueError:
continue
return out
def _read_faiss(self) -> bytes:
try:
return self.faiss_path.read_bytes()
except OSError:
return b""
def _atomic_write(self, path: Path, data: bytes) -> None:
"""tmp + os.replace 原子切换(仿 retrieval.py 纪律)。"""
self.dir.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(path.name + TMP_SUFFIX)
tmp.write_bytes(data)
os.replace(tmp, path)
def _dump_locked(self) -> None:
"""写三文件(需持有锁)。"""
self.dir.mkdir(parents=True, exist_ok=True)
payload = {
"version": 1,
"items": [
{k: v for k, v in r.items() if k not in ("row_id", "text")}
for r in self._rows
],
}
self._atomic_write(
self.json_path, json.dumps(payload, ensure_ascii=False).encode("utf-8")
)
self._atomic_write(self.faiss_path, _faiss_serialize(self._faiss))
text_lines = [
json.dumps({"chunk_id": r.get("chunk_id") or "", "text": r.get("text") or ""},
ensure_ascii=False)
for r in self._rows
]
self._atomic_write(self.texts_path, ("\n".join(text_lines) + "\n").encode("utf-8"))
# ---- 增删 ----
def upsert_document(self, doc_id: str, items: list[dict[str, Any]]) -> int:
"""入库一个文档的全部 chunk(替换语义:先移旧行,过滤重建 faiss)。
items 元素:{"text", "doc_name", "doc_category", "doc_tags",
"as_template", "created_at"};seq 由 text 取不到时按下标补。
返回写入行数。
"""
with self._lock:
self._rows = [r for r in self._rows if r.get("doc_id") != doc_id]
for seq, item in enumerate(items or []):
row = dict(item)
row["doc_id"] = doc_id
row["seq"] = int(row.get("seq", seq))
row["chunk_id"] = f"{doc_id}:{row['seq']}"
text = str(row.get("text") or "")
row["text"] = text
row["snippet"] = text[:CHUNK_SNIPPET_CHARS]
row.setdefault("doc_name", "")
row.setdefault("doc_category", "")
row.setdefault("doc_tags", "")
row.setdefault("as_template", False)
row.setdefault("created_at", _now_iso())
if not isinstance(row.get("vector"), list):
# items 未携带 vector:确定性回退嵌入(测试/防御场景,不触网)
row["vector"] = _fallback_embed_one(str(row.get("text") or ""))
else:
row["vector"] = list(row.get("vector") or [])
self._rows.append(row)
# 过滤重建 faiss(faiss 不支持逐行删)
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))
self._dump_locked()
return len(self._rows)
def remove_document(self, doc_id: str) -> bool:
"""删除该 doc 全部行(不存在返回 False)。"""
with self._lock:
before = len(self._rows)
self._rows = [r for r in self._rows if str(r.get("doc_id")) != doc_id]
if len(self._rows) == before:
return False
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))
self._dump_locked()
return True
# ---- 查询 ----
def rows(self) -> list[dict[str, Any]]:
"""行快照(不含 vector/text,供列表接口)。"""
with self._lock:
return [
{k: v for k, v in r.items() if k not in ("vector", "text", "row_id")}
for r in self._rows
]
def count(self) -> int:
with self._lock:
return len(self._rows)
def search(
self,
query_vector: list[float],
top_k: int = 5,
min_score: float = CHUNK_MIN_SCORE,
doc_ids: list[str] | None = None,
exclude_template: bool = False,
) -> dict[str, Any]:
"""向量检索,无降级档:分数 < min_score 全部丢弃 → mode:"empty"。
返回 {"mode": "vector"|"empty", "items": [{chunk_id, doc_id, seq, snippet,
score, doc_name, doc_category, doc_tags, as_template, created_at}]}。
"""
top_k = max(1, min(int(top_k), CHUNK_TOP_K_MAX))
wanted = {str(d) for d in (doc_ids or [])} if doc_ids else None
with self._lock:
rows = self._rows
if not rows:
return {"mode": "empty", "items": []}
q = _normalize(query_vector).reshape(1, self.dim)
try:
dist, idx = self._faiss.search(q, min(top_k * 8, len(rows)))
except Exception as exc:
logger.warning("chunks: 向量检索失败,按空结果处理:%s", exc)
return {"mode": "empty", "items": []}
hits: list[dict[str, Any]] = []
for score, row_idx in zip(dist[0].tolist(), idx[0].tolist()):
if not (0 <= row_idx < len(rows)):
continue
score = float(score)
if score < min_score:
continue
row = rows[row_idx]
if wanted is not None and str(row.get("doc_id")) not in wanted:
continue
if exclude_template and bool(row.get("as_template")):
continue
hits.append(
{
"chunk_id": row.get("chunk_id") or "",
"doc_id": row.get("doc_id") or "",
"seq": row.get("seq"),
"snippet": row.get("snippet") or "",
"score": score,
"doc_name": row.get("doc_name") or "",
"doc_category": row.get("doc_category") or "",
"doc_tags": row.get("doc_tags") or "",
"as_template": bool(row.get("as_template")),
"created_at": row.get("created_at") or "",
}
)
if len(hits) >= top_k:
break
if not hits:
return {"mode": "empty", "items": []}
return {"mode": "vector", "items": hits}
# ---- 重建 ----
def clear(self) -> None:
"""清空内存(测试/重建用;磁盘文件不动)。"""
with self._lock:
self._rows = []
self._faiss = faiss.IndexFlatIP(self.dim)
def rebuild_all(self, docs: dict[str, list[dict[str, Any]]]) -> int:
"""整库重建:docs = {doc_id: [chunk item, ...]};覆盖三文件,返回行数。"""
with self._lock:
new_rows: list[dict[str, Any]] = []
for doc_id, items in (docs or {}).items():
for seq, item in enumerate(items or []):
row = dict(item)
row["doc_id"] = doc_id
row["seq"] = int(row.get("seq", seq))
row["chunk_id"] = f"{doc_id}:{row['seq']}"
text = str(row.get("text") or "")
row["text"] = text
row["snippet"] = text[:CHUNK_SNIPPET_CHARS]
row.setdefault("doc_name", "")
row.setdefault("doc_category", "")
row.setdefault("doc_tags", "")
row.setdefault("as_template", False)
row.setdefault("created_at", _now_iso())
if not isinstance(row.get("vector"), list):
# items 未携带 vector:确定性回退嵌入(测试/防御场景,不触网)
row["vector"] = _fallback_embed_one(str(row.get("text") or ""))
else:
row["vector"] = list(row.get("vector") or [])
new_rows.append(row)
new_faiss = faiss.IndexFlatIP(self.dim)
for row in new_rows:
vec = row.get("vector")
if isinstance(vec, list) and vec:
new_faiss.add(_normalize(vec).reshape(1, self.dim))
self._rows = new_rows
self._faiss = new_faiss
self._dump_locked()
return len(new_rows)