feat: 完成 issue #46 RAG 知识库模板化接入(工艺规范/SOP/国标)
- core/rag-kb:领域 RAG 知识库按模板配置(PRD 5.4/7.3,EPIC #6 子任务) - templating.py:知识源三类分类 + 模板命名推导 + 零依赖轻量 YAML 配置加载 - documents.py:文档段落抽取分块,chunk 携带 文档/章节/段落 溯源信息 - store.py:from_template_config 按模板自动构建 + 中英混合词频检索 + 类别过滤 - config/kb.template.yaml:ti-cl4 模板 RAG 库资产示例(工艺规范/SOP/国标) - tests:29 用例全绿(模板化/分块溯源/检索排序/类别过滤/配置校验)
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""RAG 知识库模板化 —— 换行业只改模板配置,内核零改动(PRD 5.4 / Issue #46)。
|
||||
|
||||
领域 RAG 知识库按**模板配置**(对齐 data-bus 的模板化思想,见 PRD 5.4
|
||||
「知识库范围(由模板RAG库提供)」与 7.3「领域RAG知识库(一期)」):
|
||||
|
||||
- 知识源分类固定为三类,模板配置声明每类收录的文档:
|
||||
- `process` 工艺规范(沸腾氯化工艺规范等);
|
||||
- `sop` 标准作业程序 / 操作手册 / 异常处置 SOP;
|
||||
- `standard` 国标 / 行业标准 / 文献(氯、钛相关国标等)。
|
||||
- 全部外部资源命名由模板名推导(向量库 collection / 索引 / namespace),
|
||||
命名清洗规则对齐 data-bus(小写 + 非安全字符替换为 `_`),但**保留中文**
|
||||
(文档对象键 / doc_id 面向中文标题,MinIO 对象键支持 UTF-8)。
|
||||
|
||||
配置即「模板 RAG 库」资产(`config/kb.template.yaml`),运行时绑定模板
|
||||
版本,保证检索结果可复现(对齐 PRD 5.4 Prompt/知识库版本化)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
# 保留字符集:小写字母、数字、`-` `_` `.` 与中文(对齐 data-bus 清洗规则,
|
||||
# 但保留中文以便文档对象键 / doc_id 直接使用中文标题)
|
||||
_KEEP = re.compile(r"[^a-z0-9_.\u4e00-\u9fff-]+")
|
||||
|
||||
|
||||
def sanitize(name: str) -> str:
|
||||
"""通用命名清洗:小写 + 非安全字符(空格/标点等)替换为 `_`。
|
||||
|
||||
RAG 命名保留中文(文档标题即对象键),仅清洗 ASCII 不安全字符;
|
||||
与 data-bus 的 sanitize 不同点即在此。
|
||||
"""
|
||||
s = (name or "").strip().lower()
|
||||
s = _KEEP.sub("_", s)
|
||||
return s.strip("._") or "kb"
|
||||
|
||||
|
||||
class KnowledgeSourceKind(str, Enum):
|
||||
"""知识源分类(模板 RAG 库的三类知识范围,PRD 7.3)。"""
|
||||
|
||||
PROCESS = "process" # 工艺规范
|
||||
SOP = "sop" # 标准作业程序 / 操作手册 / 异常处置 SOP
|
||||
STANDARD = "standard" # 国标 / 行业标准 / 文献
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return {
|
||||
KnowledgeSourceKind.PROCESS: "工艺规范",
|
||||
KnowledgeSourceKind.SOP: "SOP/操作手册",
|
||||
KnowledgeSourceKind.STANDARD: "国标/标准",
|
||||
}[self]
|
||||
|
||||
|
||||
# 知识源分类注册表:模板配置中类别名 → 枚举(未知类别名直接拒绝,避免拼写漂移)
|
||||
SOURCE_KINDS: Dict[str, KnowledgeSourceKind] = {k.value: k for k in KnowledgeSourceKind}
|
||||
|
||||
|
||||
class KbTemplateNaming:
|
||||
"""按模板推导 RAG 知识库全部外部资源命名(向量库 / 索引 / namespace)。
|
||||
|
||||
命名对齐 data-bus(`tpl_{tpl}` schema 风格):collection 名用下划线形态,
|
||||
namespace 用连字符形态(对象存储桶风格)。
|
||||
"""
|
||||
|
||||
def __init__(self, template: str, index_suffix: str = "kb"):
|
||||
self.template = sanitize(template)
|
||||
# SQL/向量库标识符用下划线形态(避免 `-` 需引号转义)
|
||||
self.tpl_sql = self.template.replace("-", "_")
|
||||
self.index_suffix = sanitize(index_suffix)
|
||||
|
||||
def collection(self) -> str:
|
||||
"""向量库 collection:`{tpl}_kb`(对齐 `tpl_{tpl}` schema 命名)。"""
|
||||
return f"{self.tpl_sql}_{self.index_suffix}"
|
||||
|
||||
def index_name(self) -> str:
|
||||
"""检索索引名:`{tpl}_kb_idx`。"""
|
||||
return f"{self.collection()}_idx"
|
||||
|
||||
def namespace(self) -> str:
|
||||
"""对象存储 namespace:`tpl-{template}-kb`(S3 桶风格,允许 `-`)。"""
|
||||
return f"tpl-{self.template}-{self.index_suffix}"
|
||||
|
||||
def doc_object_key(self, doc_id: str, ext: str = "md") -> str:
|
||||
"""源文档对象键:`kb/{doc_id}.{ext}`(保留中文标题)。"""
|
||||
return f"kb/{sanitize(doc_id)}.{sanitize(ext) or 'md'}"
|
||||
|
||||
|
||||
class KbSourceConfig:
|
||||
"""单个知识源的配置(模板 RAG 库声明某类知识源收录哪些文档)。"""
|
||||
|
||||
def __init__(self, kind: KnowledgeSourceKind, documents: List[str]):
|
||||
self.kind = kind
|
||||
self.documents = list(documents)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - 调试辅助
|
||||
return f"KbSourceConfig({self.kind.value}, docs={len(self.documents)})"
|
||||
|
||||
|
||||
class KbTemplateConfig:
|
||||
"""模板 RAG 库配置:模板名 + 三类知识源文档清单。"""
|
||||
|
||||
def __init__(self, template: str, sources: List[KbSourceConfig], version: str = "1.0.0"):
|
||||
self.template = template
|
||||
self.sources = sources
|
||||
self.version = version
|
||||
|
||||
def documents_for(self, kind: KnowledgeSourceKind) -> List[str]:
|
||||
"""返回某类知识源收录的文档标题列表。"""
|
||||
for src in self.sources:
|
||||
if src.kind is kind:
|
||||
return list(src.documents)
|
||||
return []
|
||||
|
||||
def all_documents(self) -> List[str]:
|
||||
"""全部知识源文档标题(去重保序)。"""
|
||||
seen, out = set(), []
|
||||
for src in self.sources:
|
||||
for d in src.documents:
|
||||
if d not in seen:
|
||||
seen.add(d)
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 轻量 YAML 子集解析(零第三方依赖,递归下降):map / list / scalar / 注释。
|
||||
# 足以解析 `config/kb.template.yaml` 这类模板资产;运行时零依赖可复现。
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_scalar(text: str) -> str:
|
||||
"""去掉标量两侧引号与行内注释(`key: value # comment`)。"""
|
||||
t = text.split(" #", 1)[0].strip()
|
||||
if len(t) >= 2 and t[0] == t[-1] and t[0] in ("'", '"'):
|
||||
return t[1:-1]
|
||||
return t
|
||||
|
||||
|
||||
def _strip_comments(lines: List[str]) -> List[Tuple[str, int]]:
|
||||
"""剔除空行与整行注释,保留行号(1 起)用于报错定位。"""
|
||||
out = []
|
||||
for i, ln in enumerate(lines):
|
||||
s = ln.strip()
|
||||
if not s or s.startswith("#"):
|
||||
continue
|
||||
out.append((ln, i + 1))
|
||||
return out
|
||||
|
||||
|
||||
def _parse_node(lines: List[Tuple[str, int]], i: int, indent: int):
|
||||
"""递归解析从 lines[i] 开始、缩进为 `indent` 的一个节点。
|
||||
|
||||
返回 `(value, next_i)`:value 为 dict / list / str,next_i 为下一个
|
||||
未消费行的下标。
|
||||
"""
|
||||
text, no = lines[i]
|
||||
lead = len(text) - len(text.lstrip(" "))
|
||||
|
||||
# ---- list 节点:`- item` 或 `- key: val`(map 项) ----
|
||||
if text.lstrip(" ").startswith("- "):
|
||||
items: List[object] = []
|
||||
while i < len(lines):
|
||||
t, no2 = lines[i]
|
||||
stripped = t.lstrip(" ")
|
||||
if not stripped.startswith("- "):
|
||||
break
|
||||
lead_j = len(t) - len(t.lstrip(" "))
|
||||
if lead_j != indent:
|
||||
break # 缩进不同的 `-` 项属于外层块,交还上层处理
|
||||
item_text = stripped[2:].strip()
|
||||
if not item_text:
|
||||
raise ValueError(f"kb.yaml 第 {no2} 行:list 项为空")
|
||||
if ":" in item_text:
|
||||
# map 项:把 `- key: val` 规范化为缩进 `map_indent` 的 map 首行
|
||||
map_indent = len(t) - len(t.lstrip(" ")) + 2
|
||||
lines[i] = (" " * map_indent + item_text, no2)
|
||||
v, i = _parse_node(lines, i, map_indent)
|
||||
items.append(v)
|
||||
else:
|
||||
items.append(_parse_scalar(item_text))
|
||||
i += 1
|
||||
return items, i
|
||||
|
||||
# ---- map 节点:`key: value` / `key:`(嵌套值) ----
|
||||
result: Dict[str, object] = {}
|
||||
while i < len(lines):
|
||||
t, no = lines[i]
|
||||
lead_j = len(t) - len(t.lstrip(" "))
|
||||
if lead_j < indent or t.lstrip(" ").startswith("- "):
|
||||
break
|
||||
if lead_j > indent:
|
||||
raise ValueError(f"kb.yaml 第 {no} 行缩进异常(期望 {indent},实际 {lead_j})")
|
||||
if ":" not in t:
|
||||
raise ValueError(f"kb.yaml 第 {no} 行不是合法键值对:{t!r}")
|
||||
key, _, rest = t.partition(":")
|
||||
key = key.strip()
|
||||
rest = rest.strip()
|
||||
if rest:
|
||||
result[key] = _parse_scalar(rest)
|
||||
i += 1
|
||||
continue
|
||||
# `key:` —— 值在后续行嵌套(list 或 map),缩进必须更深
|
||||
if i + 1 >= len(lines):
|
||||
raise ValueError(f"kb.yaml 第 {no} 行 {key!r} 缺少值")
|
||||
sub_indent = len(lines[i + 1][0]) - len(lines[i + 1][0].lstrip(" "))
|
||||
if sub_indent <= indent:
|
||||
raise ValueError(f"kb.yaml 第 {no} 行 {key!r} 缺少值(无嵌套内容)")
|
||||
v, i = _parse_node(lines, i + 1, sub_indent)
|
||||
result[key] = v
|
||||
return result, i
|
||||
|
||||
|
||||
def _load_yaml_text(text: str) -> Dict[str, object]:
|
||||
"""解析 YAML 子集 → 嵌套 dict/list。顶层必须为 map。"""
|
||||
lines = _strip_comments(text.splitlines())
|
||||
if not lines:
|
||||
return {}
|
||||
top_indent = len(lines[0][0]) - len(lines[0][0].lstrip(" "))
|
||||
value, next_i = _parse_node(lines, 0, top_indent)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("kb.yaml 顶层必须是 map")
|
||||
if next_i < len(lines):
|
||||
raise ValueError(
|
||||
f"kb.yaml 第 {lines[next_i][1]} 行:顶层存在多个节点(缩进不一致)"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def load_kb_config(path: str) -> KbTemplateConfig:
|
||||
"""从模板 RAG 库 YAML 资产加载配置(零第三方依赖)。
|
||||
|
||||
期望结构(详见 `config/kb.template.yaml`):
|
||||
```yaml
|
||||
template: ti-cl4
|
||||
version: 1.0.0
|
||||
sources:
|
||||
- kind: process
|
||||
documents: [...]
|
||||
- kind: sop
|
||||
documents: [...]
|
||||
- kind: standard
|
||||
documents: [...]
|
||||
```
|
||||
"""
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
data = _load_yaml_text(fh.read())
|
||||
|
||||
template = str(data.get("template", "")).strip()
|
||||
if not template:
|
||||
raise ValueError("kb.yaml 缺少 template 字段")
|
||||
version = str(data.get("version", "1.0.0")).strip() or "1.0.0"
|
||||
|
||||
sources: List[KbSourceConfig] = []
|
||||
raw_sources = data.get("sources") or []
|
||||
if not isinstance(raw_sources, list):
|
||||
raise ValueError("kb.yaml sources 必须是 list")
|
||||
for item in raw_sources:
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError(f"kb.yaml sources 项必须是 map,实际 {item!r}")
|
||||
kind_name = str(item.get("kind", "")).strip()
|
||||
if kind_name not in SOURCE_KINDS:
|
||||
raise ValueError(
|
||||
f"kb.yaml 未知知识源类别 {kind_name!r}(应为 {sorted(SOURCE_KINDS)})"
|
||||
)
|
||||
docs = item.get("documents") or []
|
||||
if not isinstance(docs, list):
|
||||
raise ValueError(f"kb.yaml {kind_name} 的 documents 必须是 list")
|
||||
sources.append(
|
||||
KbSourceConfig(SOURCE_KINDS[kind_name], [str(d) for d in docs])
|
||||
)
|
||||
|
||||
return KbTemplateConfig(template=template, sources=sources, version=version)
|
||||
Reference in New Issue
Block a user