chore: 标准化NLTK数据卷并清理测试文件

- 将docker-compose.yml中的NLTK数据挂载从主机路径改为命名卷,确保跨平台一致性
- 移除测试中生成临时文件的副作用,改为控制台输出切分结果
- 修复语义切分工具中NLTK资源检查逻辑,避免重复下载并提供明确错误提示
- 优化语义切分函数签名,支持embed_fn为None时的退化处理
This commit is contained in:
zhou shujian 2026-04-24 16:18:01 +08:00
parent 32fef0893c
commit 2b48190b25
7 changed files with 67 additions and 2247 deletions

View File

@ -85,7 +85,7 @@ def extract_table_block(tokens: list[Any], i: int, original_lines: list[str]) ->
def split_text_by_length_and_newline(
text: str, max_length: int, embed_fn: Callable[[list[str]], Any], token_count_fn: Callable[[str], int]
text: str, max_length: int, embed_fn: Callable[[list[str]], Any] | None, token_count_fn: Callable[[str], int]
) -> list[str]:
"""
层次化文本切分策略

View File

@ -9,11 +9,23 @@ from nltk.tokenize import sent_tokenize
from sklearn.cluster import AgglomerativeClustering
from sklearn.metrics import silhouette_score
# 模块加载的时候先检查是否已经下载了punkt_tab模型用于识别句子的边界
try:
nltk.data.find("tokenizers/punkt_tab")
except LookupError:
nltk.download("punkt_tab")
_punkt_checked = False
def _ensure_punkt_tab() -> None:
"""首次使用分句能力时检查 NLTK punkt_tab 资源,缺失时给出可操作错误。"""
global _punkt_checked
if _punkt_checked:
return
try:
nltk.data.find("tokenizers/punkt_tab")
except LookupError as e:
raise RuntimeError(
"缺少 NLTK 资源 punkt_tab。请先执行: python -m nltk.downloader punkt_tab"
) from e
_punkt_checked = True
def split_sentences_chinese(text: str) -> list[str]:
@ -25,7 +37,7 @@ def split_sentences_chinese(text: str) -> list[str]:
- 使用正向/反向预查处理引号确保如果标点后面紧跟引号"),该引号会被保留在当前句子末尾,而不是被切分到下一句。
- 返回去除两端空格且非空的句子列表
"""
pattern = r'(?<=[。!?])(?![”’"])|(?<=[。!?])(?![”’"])|(?<=[。!?][”’"])'
pattern = r'(?<=[。!?][”’"])|(?<=[。!?])(?![”’"])'
sentences = re.split(pattern, text)
return [s.strip() for s in sentences if s.strip()]
@ -50,6 +62,8 @@ def split_mixed_sentences(text: str) -> list[str]:
Returns:
List[str]: 分割后的句子列表
"""
_ensure_punkt_tab()
chunks = re.split(r"(\n+)", text)
sentences = []
@ -108,7 +122,7 @@ def find_best_num_clusters(embeddings: Any, min_clusters: int = 2, max_clusters:
def semantic_chunking_with_auto_clusters(
text: str, embed_fn: Callable[[list[str]], Any], token_count_fn: Callable[[str], int], max_chunk_size: int = 512
text: str, embed_fn: Callable[[list[str]], Any] | None, token_count_fn: Callable[[str], int], max_chunk_size: int = 512
) -> list[str]:
"""
对传入的文本进行语义切分过程中会自动选择最佳的聚集数量
@ -117,26 +131,43 @@ def semantic_chunking_with_auto_clusters(
- 先将文本中的句子按语言进行分发英文/混合文本使用NLTK的sent_tokenize中文文本使用split_sentences_chinese
- 对每个句子进行嵌入向量化
- 确定最佳的聚类数量根据轮廓系数
- 对句子进行聚类将每个聚类中的句子连接起来形成一个分块
- 对句子进行聚类按原文顺序遍历当聚类标签变化或达到长度上限时切分形成连续分块
- 如果嵌入模型缺失则退化为原始切分方式
"""
sentences = split_mixed_sentences(text)
if len(sentences) < 2:
return [text.strip()]
# 向量化每个句子, 得到他们的嵌入向量
embeddings = embed_fn(sentences)
# 计算每个句子的token数量
sentence_token_counts = [token_count_fn(s) for s in sentences]
total_tokens = sum(sentence_token_counts)
# 决定合适的聚集数量需要保证每个分块的token数量都不超过max_chunk_size
best_k = max(total_tokens // max_chunk_size, 1) + 1
# 如果没有提供向量化函数,或者整体未超长,则直接进行简单合并/返回
if embed_fn is None or total_tokens <= max_chunk_size:
chunks = []
current_chunk = ""
current_chunk_tokens = 0
for s, cnt in zip(sentences, sentence_token_counts):
if current_chunk_tokens + cnt > max_chunk_size and current_chunk:
chunks.append(current_chunk.strip())
current_chunk = s
current_chunk_tokens = cnt
else:
current_chunk += s
current_chunk_tokens += cnt
if current_chunk:
chunks.append(current_chunk.strip())
return chunks
# 向量化每个句子, 得到他们的嵌入向量
embeddings = embed_fn(sentences)
# 决定合适的聚集数量:超长时按上限向上取整,避免整除时多切一块
best_k = (total_tokens + max_chunk_size - 1) // max_chunk_size
best_k = min(best_k, len(sentences))
# 根据指定的聚集数量、相似度判断方式、联动方式,对句子进行聚类
# 这里返回的labels是一个每个句子的聚类标签列表例如[0,0,1,2,2]相同ID的句子被聚类到同一个分块中
# labels 是每个句子的聚类标签列表(如 [0,0,1,2,2]),后续会按原文顺序在标签变化处切分连续分块
labels = AgglomerativeClustering(n_clusters=best_k, metric="cosine", linkage="average").fit_predict(embeddings)
chunks = []

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +0,0 @@
[
"## 风力发电机组 塔架设计规范|1 概述\n\n本标准规定了风力发电机组塔架的设计、制造、运输和安装要求。",
"### 风力发电机组 塔架设计规范|6 钢制塔架|6.1 一般要求\n\n钢制塔架的设计应考虑极端载荷和疲劳载荷。在计算连接强度时需要用到螺纹截面积 Asp 以及承载力设计值 Rd。\n此外结构应力 σ _ {y, d} 的计算必须符合相关标准要求。",
"### 风力发电机组 塔架设计规范|6 钢制塔架|6.2 疲劳极限状态\n\n疲劳计算应基于 Miner 线性累积损伤理论。",
"### 风力发电机组 塔架设计规范|7 混凝土塔架|7.1 材料特性\n\n混凝土强度等级不应低于 C50。",
"### 风力发电机组 塔架设计规范|7 混凝土塔架|7.2 施工工艺\n\n混凝土塔架可采用现浇或预制拼装方式。对于预制片段应严格控制拼装精度。"
]

View File

@ -1,7 +1,5 @@
import pytest
import numpy as np
import os
import json
# 现在可以安全地导入了,因为顶层不再有重型依赖
from yuxi.knowledge.chunking.ragflow_like.parsers import semantic
@ -244,7 +242,7 @@ def test_semantic_chunking_basic(embed_fn, sample_markdown):
# 配置切分参数
parser_config = {
"chunk_token_num": 800, # 针对标准文档调整 token 数
"chunk_token_num": 1000, # 针对标准文档调整 token 数
"overlapped_percent": 0.1
}
@ -304,14 +302,11 @@ def test_semantic_chunking_basic(embed_fn, sample_markdown):
print(f"\n[测试成功] 文档成功切分为 {len(chunks)} 个片段")
print(f"识别到的关键技术词汇: {found_keywords}")
# 将切分后的内容写入到 resource 目录
output_dir = os.path.join(os.path.dirname(__file__), "resource")
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, "semantic_chunks.json")
with open(output_path, "w", encoding="utf-8") as f:
json.dump(chunks, f, ensure_ascii=False, indent=2)
print(f"切分内容已保存至: {output_path}")
# 直接输出到控制台预览切分结果,避免文件副作用
print("\n--- 语义切分结果开始 ---")
for idx, chunk in enumerate(chunks, 1):
print(f"\n[Chunk {idx}]\n{chunk}")
print("\n--- 语义切分结果结束 ---")
def test_heading_inference():
"""测试标题层级推断工具类"""

View File

@ -7,8 +7,10 @@ sys.path.append(os.getcwd())
from yuxi.knowledge.chunking.ragflow_like.dispatcher import chunk_markdown
from yuxi.knowledge.chunking.ragflow_like.nlp import bullets_category, count_tokens
from yuxi.knowledge.chunking.ragflow_like.utils.semantic_utils import split_sentences_chinese
from yuxi.knowledge.chunking.ragflow_like.presets import (
CHUNK_ENGINE_VERSION,
CHUNK_PRESET_IDS,
get_chunk_preset_options,
map_to_internal_parser_id,
resolve_chunk_processing_params,
@ -85,6 +87,13 @@ def test_book_chunking_hierarchical_merge() -> None:
assert any("第一章" in ck["content"] for ck in chunks)
def test_split_sentences_chinese_should_keep_quote_boundary() -> None:
text = '他说:“你好。”然后问:“你在吗?”最后结束!'
sentences = split_sentences_chinese(text)
assert sentences == ["他说:“你好。”", "然后问:“你在吗?”", "最后结束!"]
def test_markdown_heading_has_higher_weight_in_bullet_category() -> None:
sections = [
"# 3.2 个人所得项目及计税、申报方式概括",
@ -108,7 +117,7 @@ def test_mid_sentence_bullet_marker_should_not_be_treated_as_heading() -> None:
def test_chunk_preset_options_include_description() -> None:
options = get_chunk_preset_options()
assert len(options) == 4
assert {option["value"] for option in options} == CHUNK_PRESET_IDS
assert all(isinstance(option.get("description"), str) and option["description"] for option in options)

View File

@ -52,7 +52,7 @@ services:
- ./.env:/app/.env
- ${MODEL_DIR:-./models}:/models # 使用默认值处理未定义的环境变量
- /var/run/docker.sock:/var/run/docker.sock
- ~/nltk_data:/root/nltk_data # 持久化语义切分使用的NLTK数据包避免重复下载
- nltk_data:/root/nltk_data # 使用命名卷持久化NLTK数据避免路径展开差异
ports:
- "5050:5050"
networks:
@ -431,6 +431,9 @@ services:
- app-network
restart: unless-stopped
volumes:
nltk_data:
networks:
app-network:
driver: bridge