- 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 用例全绿(模板化/分块溯源/检索排序/类别过滤/配置校验)
274 lines
11 KiB
Python
274 lines
11 KiB
Python
# -*- 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)
|