Files
PlantAI-rag-new/vstore/flow.py
T

308 lines
13 KiB
Python
Raw Normal View History

"""flow.py —— 生信流程知识库(flow_docs 独立集合,只新增不动主库)。
与主库(公文/资料库)完全隔离:独立目录 + 独立 RetrievalIndex。
不加密:语料为团队内部公开分析文档,检索 meta 与正文均需在对话内引用原文。
- FlowStore(flow_dir):add_chunk / search / delete_source / stats
- 幂等:以 source_id + chunk_key 为唯一键,重跑不产生重复块
数据目录布局:
flow_dir/
├── index.json # 检索索引(复用 RetrievalIndex,含向量)
└── chunks/ # 块正文 json(chunk_key.json)
检索三档降级复用 RetrievalIndex 语义(向量 → meta 关键词 → 最新)。
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import re
from pathlib import Path
from typing import Any
from .embed import embed_batch
from .retrieval import RetrievalIndex, RetrievalError
from .settings import DEFAULT_TOP_K, MAX_TOP_K
logger = logging.getLogger(__name__)
INDEX_FILE = "index.json"
CHUNKS_DIR = "chunks"
# 各语料源稳定 ID(入库脚本与检索共用)
SOURCE_CORPUS = "corpus"
SOURCE_STAGE_CONTRACT = "stage_contract"
SOURCE_SKILL = "skill"
DOC_KIND_CORPUS = "corpus"
DOC_KIND_CONTRACT = "contract"
DOC_KIND_SKILL = "skill"
DOC_KIND_LABELS = {
DOC_KIND_CORPUS: "全流程文档",
DOC_KIND_CONTRACT: "阶段契约",
DOC_KIND_SKILL: "技能说明",
}
# 引用块对 UI 的跳转约定(A3 对齐):
# - doc_kind=corpus : path 为「仓库相对路径」,anchor 为「## 章标题」;
# UI 跳转 仓库根 /{path} 并定位 {anchor}(源文件与 A3 一并交付)
# - doc_kind=contract : path 为 dev 仓库内相对路径(gfa_core/schemas/stages/…);
# UI 以文件卡片形式打开;行号锚点直接支持时用 line_start/line_end
# - doc_kind=skill : path 为 dev 仓库内相对路径(skills/…/SKILL.md),同 contract 渲染
CITE_ANCHOR = "anchor"
CITE_LINE = "line"
# 嵌入调用函数(测试可注入假 embed,镜像 vault.py 的约定)
_EMBED_FN = embed_batch
def set_embed_fn(fn) -> None:
global _EMBED_FN
_EMBED_FN = fn
def get_embed_fn():
return _EMBED_FN
_META_KEYWORDS_FIELDS = ("name", "title", "title_path", "stage_name", "source", "doc_kind_label")
_CJK_SEG = re.compile(r"[一-鿿]{2,}")
def _cjk_2grams(text: str) -> list[str]:
"""中文连续段 2-gram 切词(检索词面通道用)。"""
out: list[str] = []
for m in _CJK_SEG.finditer(text):
seg = m.group(0)
out.append(seg[:2])
if len(seg) > 2:
for i in range(len(seg) - 1):
out.append(seg[i:i + 2])
return out
def _ascii_keywords(query: str) -> list[str]:
"""查询的 ASCII 词(词面通道专用;≥2 字符,允许 _ . - 数字)。"""
ascii_part = _CJK_SEG.sub(" ", query)
out: list[str] = []
for w in re.split(r"[\s,,。;;::/|??!!()()]+", ascii_part):
if re.fullmatch(r"[A-Za-z0-9_.\-]+", w) and len(w) >= 3:
out.append(w.lower())
return out
def _meta_to_keywords(meta: dict[str, Any]) -> list[str]:
"""flow meta → 关键词列表(检索二档/词面通道)。
规则:ASCII 按空白/标点切词(≥2 字符);中文连续段取 2-gram(含段首双字)。
"""
words: list[str] = []
for key in _META_KEYWORDS_FIELDS:
value = str(meta.get(key) or "")
if not value:
continue
ascii_part = _CJK_SEG.sub(" ", value)
words.extend([w for w in re.split(r"[\s,,。;;::/|]+", ascii_part) if len(w) >= 2])
words.extend(_cjk_2grams(value))
return words
def _dump_json(path: Path, payload: dict[str, Any]) -> None:
tmp = path.with_name(path.name + ".tmp")
tmp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
os.replace(tmp, path)
class FlowStore:
"""flow_docs 集合:chunks/ 正文 + index.json 检索索引。"""
def __init__(self, flow_dir: str | Path, dim: int | None = None) -> None:
self.dir = Path(flow_dir)
self.chunks_dir = self.dir / CHUNKS_DIR
index_kwargs: dict[str, Any] = {"index_path": self.dir} if dim is None else {"index_path": self.dir, "dim": dim}
self.index = RetrievalIndex(**index_kwargs)
self.index.load()
# ---- 写入 ----
def add_chunk(self, chunk: dict[str, Any], text: str,
vector: list[float] | None = None) -> int:
"""追加/覆盖一个块(幂等键 source_id + chunk_key)。
返回 row_id;键已存在时覆盖正文、更新向量并保持 row_id 稳定。
vector 不传时内部嵌入「标题路径 + 标题 + 正文」拼接文本
(标题路径并入嵌入文本,保证阶段名参与向量召回)。
"""
if not (chunk.get("source_id") and chunk.get("chunk_key")):
raise ValueError("chunk 必须含非空 source_id 与 chunk_key")
self.chunks_dir.mkdir(parents=True, exist_ok=True)
chunk_path = self.chunks_dir / f"{chunk["chunk_key"]}.json"
_dump_json(chunk_path, {"meta": chunk, "text": text})
if vector is None:
embed_text = " ".join(str(chunk.get(k) or "") for k in ("title_path", "heading", "title")) + " " + text
vector = _EMBED_FN([embed_text])[0]
row = {k: v for k, v in chunk.items() if k != "chunk_key"}
row.update({
"name": chunk.get("title") or chunk.get("heading") or chunk.get("chunk_key", ""),
"description": chunk.get("title_path") or "",
"id": chunk["chunk_key"],
"chunk_key": chunk["chunk_key"],
"text_chars": len(text),
"keywords": _meta_to_keywords(chunk),
})
# 幂等:同键先删旧行再追加(faiss 逐行删需重建,块规模下可接受)
existing = [r for r in self.index.rows() if str(r.get("id")) == chunk["chunk_key"]]
if existing:
self.index.remove_item(chunk["chunk_key"])
return self.index.add_item(row, vector)
# ---- 检索 ----
def search(self, query: str, top_k: int = DEFAULT_TOP_K,
source: str | None = None, doc_kind: str | None = None) -> dict[str, Any]:
"""检索 flow_docs。query 为空走 latest。
混合检索(语义 + 词面双通道):
- 语义通道:向量 top(top_k 条)
- 词面通道:标题路径/关键词字面命中(排除向量已覆盖的块)
各占一半配额(top_k 为偶数时对半;奇数时向量多 1),保持向量优先。
source 过滤:corpus / stage_contract / skill;
doc_kind 与 source 当前一一对应(corpus→corpus,contract/skill→自身)。
"""
top_k = max(1, min(int(top_k), MAX_TOP_K))
keywords = _meta_to_keywords({"title": query}) if query else None
vector = _EMBED_FN([query or ""])[0] if query else [0.0] * self.index.dim
result = self.index.search(
vector, top_k=max(1, min(top_k * 4, MAX_TOP_K)),
category=None, as_template=None, keywords=keywords,
)
items = result.get("items", [])
vector_quota = (top_k + 1) // 2
merged: list[dict[str, Any]] = []
def _pass(row: dict[str, Any]) -> bool:
if source and str(row.get("source") or "") != source:
return False
if doc_kind and str(row.get("doc_kind") or "") != doc_kind:
return False
return True
# 1) 语义通道
for row in items:
if not _pass(row):
continue
merged.append(row)
if len(merged) >= vector_quota:
break
# 2) 词面通道:全索引标题路径/heading 字面命中(不依赖向量通道候选项,
# 精确词匹配兜底语义盲区——阶段名/文件名是用户提问的高频字面词)。
# 候选按「命中词数 desc」排序,命中更全的块优先。
if query:
qwords = _ascii_keywords(query)
if qwords:
merged_ids = {str(r.get("id")) for r in merged}
lex_hits: list[tuple[int, int, dict[str, Any]]] = []
for row in self.index.rows():
if str(row.get("id")) in merged_ids:
continue
if not _pass(row):
continue
hay = " ".join(
str(row.get(k) or "") for k in ("title_path", "heading", "stage_name", "name")
).lower()
matched = sum(1 for w in qwords if w in hay)
# 阶段名精确命中加权(stage_name == 查询词时 +3)
sn = str(row.get("stage_name") or "").lower()
if sn and sn in qwords:
matched += 3
if matched > 0:
lex_hits.append((matched, len(lex_hits), row))
lex_hits.sort(key=lambda t: (-t[0], t[1]))
# 去重:契约/技能按文件粒度,corpus 按章粒度(title_path),
# 避免同文件多节块或同章多块霸占配额
seen_keys: set[str] = set()
for _, _, row in lex_hits:
if str(row.get("source") or "") == SOURCE_CORPUS:
key_path = str(row.get("title_path") or str(row.get("id")))
else:
key_path = str(row.get("path") or "")
if key_path and key_path in seen_keys:
continue
seen_keys.add(key_path)
merged.append(row)
if len(merged) >= top_k:
break
# 2b) 正文字面通道:ASCII 词扫描块正文(标题未中但正文含软件/阶段名时兜底)
if len(merged) < top_k:
for row in self.index.rows():
if str(row.get("id")) in merged_ids:
continue
if not _pass(row):
continue
doc = self.get_chunk(str(row.get("chunk_key") or row.get("id") or ""))
if not doc:
continue
body = str(doc.get("text") or "").lower()
if any(w in body for w in qwords):
merged.append(row)
if len(merged) >= top_k:
break
# 3) 若词面/正文通道命中不足且向量通道未满,用向量余量补足
if len(merged) < top_k:
for row in items:
if str(row.get("id")) in merged_ids:
continue
if not _pass(row):
continue
merged.append(row)
if len(merged) >= top_k:
break
return {"mode": result.get("mode", "empty"), "items": merged[:top_k]}
def get_chunk(self, chunk_key: str) -> dict[str, Any] | None:
"""取块正文(meta + text),供答案引用原文。"""
path = self.chunks_dir / f"{chunk_key}.json"
if not path.exists():
return None
try:
return json.loads(path.read_text(encoding="utf-8"))
except (ValueError, OSError) as exc:
logger.warning("flow: 块读取失败 %s:%s", chunk_key, exc)
return None
# ---- 维护 ----
def delete_source(self, source_id: str) -> int:
"""删除一个源的所有块(含正文文件)。返回删除数。"""
removed = 0
for row in self.index.rows():
if str(row.get("source_id")) == source_id:
if self.index.remove_item(str(row.get("id"))):
removed += 1
for f in self.chunks_dir.glob(f"{source_id}__*.json"):
f.unlink(missing_ok=True)
return removed
def stats(self) -> dict[str, Any]:
"""集合统计:总块数、按 source/doc_kind 分布、字符总量。"""
rows = self.index.rows()
per_source: dict[str, int] = {}
per_kind: dict[str, int] = {}
total_chars = 0
for row in rows:
per_source[str(row.get("source") or "?")] = per_source.get(str(row.get("source") or "?"), 0) + 1
per_kind[str(row.get("doc_kind") or "?")] = per_kind.get(str(row.get("doc_kind") or "?"), 0) + 1
total_chars += int(row.get("text_chars") or 0)
return {"total_chunks": len(rows), "per_source": per_source,
"per_doc_kind": per_kind, "total_chars": total_chars}