Merge pull request #652 from Flying-Henanese/feat/add-semantic-chunking

feat: 增加基于自然语义的文本切分功能
This commit is contained in:
Wenjie Zhang 2026-04-24 19:41:48 +08:00 committed by GitHub
commit 743b5b03c4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1230 additions and 18 deletions

View File

@ -76,6 +76,12 @@ dependencies = [
"typer>=0.16.0",
"unstructured>=0.17.2",
"wcmatch>=8.0.0",
# Markdown 解析语义切分相关依赖
"markdown-it-py>=3.0.0",
"mdit-py-plugins>=0.4.0",
"scikit-learn>=1.3.0",
"nltk>=3.8.1",
"beautifulsoup4>=4.12.0",
]
[tool.setuptools]

View File

@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Any
from yuxi.knowledge.chunking.ragflow_like.parsers import book, general, laws, qa
from yuxi.knowledge.chunking.ragflow_like.parsers import book, general, laws, qa, semantic
from yuxi.knowledge.chunking.ragflow_like.presets import map_to_internal_parser_id, normalize_chunk_preset_id
@ -42,6 +42,8 @@ def _dispatch_markdown_parser(
return book.chunk_markdown(markdown_content, parser_config)
if parser_id == "laws":
return laws.chunk_markdown(filename, markdown_content, parser_config)
if parser_id == "semantic":
return semantic.chunk_markdown(markdown_content, parser_config)
return general.chunk_markdown(markdown_content, parser_config)

View File

@ -0,0 +1,298 @@
from __future__ import annotations
import re
from typing import Any
from markdown_it import MarkdownIt
from mdit_py_plugins.dollarmath import dollarmath_plugin
from yuxi.knowledge.chunking.ragflow_like.nlp import count_tokens
from yuxi.utils.logging_config import logger
from ..utils.md_parser_utils import (
extract_table_block,
get_title_path,
split_text_by_length_and_newline,
)
from ..utils.table_utils import html_table_to_key_value
def _flush_content(
result: list,
current_content: list,
title_stack: list,
max_length: int,
embed_fn: Any,
special_element: str = None,
allow_split: bool = False,
) -> None:
if not current_content:
return
content = "\n".join(current_content).strip()
if not content:
current_content.clear()
return
level = next((i + 1 for i in range(5, -1, -1) if title_stack[i]), 1)
title_path = get_title_path(title_stack)
if special_element and not allow_split:
header = f"{'#' * level} {title_path}|{special_element}" if title_path else f"{'#' * level} {special_element}"
result.extend([header, content, "-" * 10])
else:
if count_tokens(content) > max_length:
chunks = split_text_by_length_and_newline(
content, max_length, embed_fn=embed_fn, token_count_fn=count_tokens
)
for idx, chunk in enumerate(chunks, 1):
base_header = f"{'#' * level} {title_path}" if title_path else f"{'#' * level}"
if special_element:
header = f"{base_header}|{special_element}|Part {idx}"
else:
header = f"{base_header}|Part {idx}"
result.extend([header, chunk, "-" * 10])
else:
base_header = f"{'#' * level} {title_path}" if title_path else f"{'#' * level}"
if special_element:
header = f"{base_header}|{special_element}"
else:
header = base_header
if header:
result.append(header)
result.append("")
result.extend([content, "-" * 10])
current_content.clear()
def _handle_image_caption(tokens, i, result, current_content, title_stack, max_length, embed_fn):
token = tokens[i]
if token.type != "paragraph_open":
return False, i
inline_token = tokens[i + 1]
if inline_token.type != "inline":
return False, i
content = inline_token.content.strip()
image_pattern = r"^!\[.*?\]\(.*?\)\s*$"
caption_pattern = r"^(?:Figure|图|Fig\.|表|Table)\s*[\d\w\.]+"
img_match = re.search(r"^(!\[.*?\]\(.*?\))", content)
if img_match:
rest = content[img_match.end() :].strip()
if rest and re.match(caption_pattern, rest, re.IGNORECASE):
_flush_content(result, current_content, title_stack, max_length, embed_fn)
current_content.append(content)
caption_title = rest.split("\n")[0].strip()
_flush_content(result, current_content, title_stack, max_length, embed_fn, special_element=caption_title)
return True, i + 3
if re.match(image_pattern, content):
next_p_idx = i + 3
if next_p_idx + 1 < len(tokens) and tokens[next_p_idx].type == "paragraph_open":
next_inline = tokens[next_p_idx + 1]
if next_inline.type == "inline":
next_content = next_inline.content.strip()
if re.match(caption_pattern, next_content, re.IGNORECASE):
_flush_content(result, current_content, title_stack, max_length, embed_fn)
current_content.append(content)
current_content.append(next_content)
_flush_content(
result, current_content, title_stack, max_length, embed_fn, special_element=next_content
)
return True, i + 6
if current_content and re.match(caption_pattern, content, re.IGNORECASE):
last_item = current_content[-1].strip()
if re.match(image_pattern, last_item):
image_tag = current_content.pop()
_flush_content(result, current_content, title_stack, max_length, embed_fn)
current_content.append(image_tag)
current_content.append(content)
_flush_content(result, current_content, title_stack, max_length, embed_fn, special_element=content)
return True, i + 3
return False, i
def chunk_markdown(
markdown_content: str, parser_config: dict[str, Any] | None = None, embed_fn: Any | None = None
) -> list[str]:
"""
语义化切分 Markdown 内容
Args:
markdown_content: 待切分的 Markdown 文本
parser_config: 切分参数 chunk_token_num
embed_fn: 可选传入用于生成向量的函数如果不传将从系统配置中加载模型
通过注入此参数可以避免在单元测试中加载重型资源
"""
parser_config = parser_config or {}
max_length = int(parser_config.get("chunk_token_num", 512))
logger.info(f"语义切分开始: max_length={max_length}, content_length={len(markdown_content)}")
# 延迟加载重型资源,仅在没有注入 embed_fn 时触发
if embed_fn is None:
try:
from yuxi.config.app import config
from yuxi.models.embed import select_embedding_model
embed_model_id = parser_config.get("embed_model_id") or config.embed_model
logger.info(f"语义切分加载Embedding模型: {embed_model_id}")
embed_model = select_embedding_model(embed_model_id)
embed_fn = embed_model.encode
except Exception as e:
logger.error(f"加载 Embedding 模型失败: {e}。将退化为简单切分。")
embed_fn = None
md = MarkdownIt("commonmark").enable("table")
md.use(dollarmath_plugin, allow_space=True, allow_digits=True)
tokens: list = md.parse(markdown_content)
original_lines: list = markdown_content.split("\n")
result: list = []
current_content: list = []
title_stack: list = [""] * 6
i = 0
while i < len(tokens):
token = tokens[i]
if token.type == "heading_open":
_flush_content(result, current_content, title_stack, max_length, embed_fn)
level = int(token.tag[1:]) if token.tag and len(token.tag) > 1 else 1
inline_token = tokens[i + 1]
if inline_token.type == "inline":
full_title = inline_token.content.strip()
title_stack[level - 1] = full_title
for j in range(level, 6):
title_stack[j] = ""
i += 3
continue
elif token.type == "table_open":
_flush_content(result, current_content, title_stack, max_length, embed_fn)
j, table_content = extract_table_block(tokens, i, original_lines)
current_content.append(table_content)
_flush_content(result, current_content, title_stack, max_length, embed_fn, special_element="Table")
i = j + 1 if j < len(tokens) else len(tokens)
continue
elif token.type == "paragraph_open":
handled, new_i = _handle_image_caption(
tokens, i, result, current_content, title_stack, max_length, embed_fn
)
if handled:
i = new_i
continue
inline_token = tokens[i + 1]
if inline_token.type == "inline":
current_content.append(inline_token.content.strip())
i += 3
continue
elif token.type == "fence":
current_content.append(f"```\n{token.content}\n```")
i += 1
continue
elif token.type == "ordered_list_open":
# _flush_content(result, current_content, title_stack, max_length, embed_fn)
list_content = []
j = i + 1
list_item_counter = 1
while j < len(tokens) and tokens[j].type != "ordered_list_close":
if tokens[j].type == "list_item_open":
k = j + 1
while k < len(tokens) and tokens[k].type != "list_item_close":
if (
tokens[k].type == "paragraph_open"
and k + 1 < len(tokens)
and tokens[k + 1].type == "inline"
):
list_content.append(f"{list_item_counter}. {tokens[k + 1].content.strip()}")
list_item_counter += 1
k += 1
j += 1
if list_content:
current_content.extend(list_content)
_flush_content(result, current_content, title_stack, max_length, embed_fn, special_element=token.type)
i = j + 1
continue
elif token.type == "bullet_list_open":
# _flush_content(result, current_content, title_stack, max_length, embed_fn)
list_content = []
j = i + 1
while j < len(tokens) and tokens[j].type != "bullet_list_close":
if tokens[j].type == "list_item_open":
k = j + 1
while k < len(tokens) and tokens[k].type != "list_item_close":
if (
tokens[k].type == "paragraph_open"
and k + 1 < len(tokens)
and tokens[k + 1].type == "inline"
):
list_content.append(f"- {tokens[k + 1].content.strip()}")
k += 1
j += 1
if list_content:
current_content.extend(list_content)
_flush_content(result, current_content, title_stack, max_length, embed_fn, special_element=token.type)
i = j + 1
continue
elif token.type == "html_block":
_flush_content(result, current_content, title_stack, max_length, embed_fn)
content = token.content.strip()
is_converted_table = False
if "<table" in content.lower():
try:
kv_list = html_table_to_key_value(content)
if kv_list:
content = "\n".join([f"- {item}" for item in kv_list])
is_converted_table = True
except Exception as e:
logger.warning(f"HTML表格转KV失败: {e}")
current_content.append(content)
if is_converted_table:
_flush_content(
result,
current_content,
title_stack,
max_length,
embed_fn,
special_element="Table KV",
allow_split=True,
)
else:
_flush_content(result, current_content, title_stack, max_length, embed_fn, special_element=token.type)
i += 1
continue
elif token.type in ["list_item_close", "ordered_list_close", "bullet_list_close", "list_item_open"]:
i += 1
continue
elif token.type == "math_block":
# _flush_content(result, current_content, title_stack, max_length, embed_fn)
current_content.append(f"$ {token.content} $")
_flush_content(result, current_content, title_stack, max_length, embed_fn, special_element="Math Block")
i += 1
continue
else:
i += 1
_flush_content(result, current_content, title_stack, max_length, embed_fn)
chunks = []
current_chunk_parts = []
for item in result:
if item == "-" * 10:
if current_chunk_parts:
chunks.append("\n".join(current_chunk_parts).strip())
current_chunk_parts = []
else:
current_chunk_parts.append(item)
if current_chunk_parts:
chunks.append("\n".join(current_chunk_parts).strip())
logger.info(f"语义切分完成: chunks={len(chunks)}")
return chunks

View File

@ -9,12 +9,14 @@ CHUNK_PRESET_GENERAL = "general"
CHUNK_PRESET_QA = "qa"
CHUNK_PRESET_BOOK = "book"
CHUNK_PRESET_LAWS = "laws"
CHUNK_PRESET_SEMANTIC = "semantic"
CHUNK_PRESET_IDS = {
CHUNK_PRESET_GENERAL,
CHUNK_PRESET_QA,
CHUNK_PRESET_BOOK,
CHUNK_PRESET_LAWS,
CHUNK_PRESET_SEMANTIC,
}
CHUNK_PRESET_DESCRIPTIONS: dict[str, str] = {
@ -22,6 +24,7 @@ CHUNK_PRESET_DESCRIPTIONS: dict[str, str] = {
CHUNK_PRESET_QA: "问答分块:优先抽取问题-回答结构,适合 FAQ、题库、问答手册。",
CHUNK_PRESET_BOOK: "书籍分块:强化章节标题识别并做层级合并,适合教材、手册、长章节文档。",
CHUNK_PRESET_LAWS: "法规分块:按法条层级组织与合并,适合法律法规、制度规范类文本。",
CHUNK_PRESET_SEMANTIC: "语义分块:利用嵌入和聚类算法进行语义切分,并自动增强标题上下文。",
}
CHUNK_ENGINE_VERSION = "ragflow_like_v1"
@ -63,6 +66,14 @@ _PRESET_DEFAULTS: dict[str, dict[str, Any] | None] = {
CHUNK_PRESET_QA: {"raptor": {"use_raptor": False}, "graphrag": {"use_graphrag": False}},
CHUNK_PRESET_BOOK: {"raptor": {"use_raptor": False}, "graphrag": {"use_graphrag": False}},
CHUNK_PRESET_LAWS: {"raptor": {"use_raptor": False}, "graphrag": {"use_graphrag": False}},
CHUNK_PRESET_SEMANTIC: {
"raptor": {"use_raptor": False},
"graphrag": {
"use_graphrag": True,
"entity_types": ["organization", "person", "geo", "event", "category"],
"method": "light",
},
},
}
@ -235,4 +246,9 @@ def get_chunk_preset_options() -> list[dict[str, str]]:
"label": "Laws",
"description": CHUNK_PRESET_DESCRIPTIONS[CHUNK_PRESET_LAWS],
},
{
"value": CHUNK_PRESET_SEMANTIC,
"label": "Semantic",
"description": CHUNK_PRESET_DESCRIPTIONS[CHUNK_PRESET_SEMANTIC],
},
]

View File

@ -0,0 +1,150 @@
from __future__ import annotations
import re
from collections.abc import Callable
from typing import Any
from .semantic_utils import semantic_chunking_with_auto_clusters
def infer_heading_level(title: str) -> int:
"""
根据标题文本推断其层级级别1-6
逻辑说明
1. 数字序号推断
- 匹配如 "1.", "1.1", "1.2.3" 等格式
- 根据点号分隔的数量确定层级例如 "1.1" 2 "1.2.3" 3
- 层级限制在 1-6 之间
2. 中文序号推断
- 匹配如 "一、", "二." 等中文数字序号
- 统一归类为 1 级标题
3. 默认处理
- 若不匹配以上规则默认返回 1
"""
m = re.match(r"^\s*(\d+(?:\.\d+)*)[.)、]?\s*", title)
if m:
return max(1, min(len(m.group(1).split(".")), 6))
m_zh = re.match(r"^\s*[一二三四五六七八九十百千]+[、.]\s*", title)
if m_zh:
return 1
return 1
def get_title_path(stack: list[str]) -> str:
"""
根据标题栈生成标题路径"|"分隔
"""
return "|".join([t for t in stack if t])
def extract_table_block(tokens: list[Any], i: int, original_lines: list[str]) -> tuple[int, str]:
"""
从token流和原始文本中提取完整的表格块
逻辑说明
1. 定位起始通过当前 token (i) `map` 属性获取表格在原始行中的起始行号 `table_start`
2. 查找结束 token遍历后续 tokens 直到找到 `table_close`
3. 确定结束行号 (`table_end`)
- 优先使用 `table_close` token `map` 属性
- 若不存在则尝试查找下一个带有 `map` 信息的 token 的起始行作为当前表格的结束
- 若上述均失败如文件末尾或解析异常则回退到基于文本内容的启发式扫描
`table_start` 开始向下扫描直到遇到不符合 Markdown 表格特征不以 '|' 开头且不含 '|'的行为止
4. 返回结果返回 `table_close` 的索引 `j` 以及拼接后的表格原始字符串
"""
token = tokens[i]
table_start = token.map[0] if token.map else 0
j = i + 1
while j < len(tokens) and tokens[j].type != "table_close":
j += 1
if j < len(tokens):
end_token = tokens[j]
if end_token.map and end_token.map[1] is not None:
table_end = end_token.map[1]
else:
table_end = None
for k in range(j + 1, len(tokens)):
if tokens[k].map and tokens[k].map[0] is not None:
table_end = tokens[k].map[0]
break
if table_end is None:
table_end = table_start + 1
for line_idx in range(table_start, len(original_lines)):
line = original_lines[line_idx].strip()
if not line or not (line.startswith("|") or "|" in line):
table_end = line_idx
break
else:
table_end = table_start + 1
for line_idx in range(table_start, len(original_lines)):
line = original_lines[line_idx].strip()
if not line or not (line.startswith("|") or "|" in line):
table_end = line_idx
break
return j, "\n".join(original_lines[table_start:table_end])
def split_text_by_length_and_newline(
text: str, max_length: int, embed_fn: Callable[[list[str]], Any] | None, token_count_fn: Callable[[str], int]
) -> list[str]:
"""
层次化文本切分策略
"""
chunks = []
paragraphs = text.split("\n\n")
for paragraph in paragraphs:
paragraph = paragraph.strip()
if not paragraph:
continue
paragraph_token_count = token_count_fn(paragraph)
# 如果当前段落长度未超过最大 Token 数量直接作为独立分块放入chunks
# 否则继续尝试按行切分
if paragraph_token_count <= max_length:
chunks.append(paragraph)
continue
# 把段落进一步使用换行符进行切分为行
lines = paragraph.split("\n")
current_chunk_lines = []
current_chunk_tokens = 0
for line in lines:
line = line.strip()
if not line: # 跳过空行
continue
line_token_count = token_count_fn(line) # 计算当前行的 Token 数量
# 为了考虑行之间的空格,需要在计算 Token 数量时加 1如果当前行不是第一行需要添加一个换行符的Token数量
added_tokens = line_token_count + (1 if current_chunk_lines else 0)
# 如果当前行的 Token 数量超过最大 Token 数量直接作为独立分块放入chunks
if line_token_count > max_length:
if current_chunk_lines:
chunks.append("\n".join(current_chunk_lines))
current_chunk_lines = []
current_chunk_tokens = 0
sub_chunks = semantic_chunking_with_auto_clusters(
line, embed_fn=embed_fn, token_count_fn=token_count_fn, max_chunk_size=max_length
)
chunks.extend(sub_chunks)
# 如果当前行的 Token 数量与当前分块的 Token 数量合并后超过最大 Token 数量直接作为独立分块放入chunks
elif current_chunk_tokens + added_tokens > max_length:
# 把之前的分块内容放入chunks
chunks.append("\n".join(current_chunk_lines))
# 重置当前分块为当前行的内容
current_chunk_lines = [line]
# 更新当前分块的 Token 数量
current_chunk_tokens = line_token_count
# 如果当前行的内容加入当前分块后不会超过最大 Token 数量,直接加入当前分块
else:
current_chunk_lines.append(line)
current_chunk_tokens += added_tokens # 更新当前分块的 Token 数量
# 最后的收尾把最后一行内容放入chunks
if current_chunk_lines:
chunks.append("\n".join(current_chunk_lines))
return chunks

View File

@ -0,0 +1,192 @@
from __future__ import annotations
import re
from collections.abc import Callable
from typing import Any
import nltk
from nltk.tokenize import sent_tokenize
from sklearn.cluster import AgglomerativeClustering
from sklearn.metrics import silhouette_score
_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]:
"""
使用正则表达式将中文文本分割成句子
逻辑
- 匹配中文句号感叹号问号作为分隔点
- 使用正向/反向预查处理引号确保如果标点后面紧跟引号"),该引号会被保留在当前句子末尾,而不是被切分到下一句。
- 返回去除两端空格且非空的句子列表
"""
pattern = r'(?<=[。!?][”’"])|(?<=[。!?])(?![”’"])'
sentences = re.split(pattern, text)
return [s.strip() for s in sentences if s.strip()]
def split_mixed_sentences(text: str) -> list[str]:
"""
处理中英文混合文本的分句逻辑支持按物理段落分发不同的分句策略
该函数采用分而治之的策略来处理复杂的混合文本
1. **物理分块**首先按换行符 (`\\n+`) 将原始文本切分为多个物理段落chunks确保物理结构不被破坏
2. **语言检测与分发**
- **英文/混合路径**若段落中包含英文字母 (`[A-Za-z]`)则视为英文或混合文本
调用 NLTK `sent_tokenize` 进行处理NLTK 能更好地处理英文缩写句点等复杂情况
- **中文路径**若段落不含字母则视为纯中文文本调用 `split_sentences_chinese`
该方法通过正则精准匹配中文标点及后续引号
- **兜底方案**若上述方法未产生结果则使用简单的正则表达式按中文标点强制分割
3. **清洗与过滤**汇总所有子句去除两端空白字符并过滤掉空字符串
Args:
text: 待分句的原始字符串
Returns:
List[str]: 分割后的句子列表
"""
_ensure_punkt_tab()
chunks = re.split(r"(\n+)", text)
sentences = []
for ch in chunks:
if not ch.strip():
continue
if re.search(r"[A-Za-z]", ch):
parts = sent_tokenize(ch)
sentences.extend([p.strip() for p in parts if p.strip()])
else:
sents = split_sentences_chinese(ch)
if sents:
sentences.extend([s.strip() for s in sents if s.strip()])
else:
parts = re.split(r"(?<=[。!?])", ch)
sentences.extend([p.strip() for p in parts if p.strip()])
return sentences
def find_best_num_clusters(embeddings: Any, min_clusters: int = 2, max_clusters: int = 10) -> int:
"""
使用轮廓系数选择最佳聚类数量让每个分段语义集中且分段之间界限分明
逻辑
- 遍历可能的聚类数量 min_clusters max_clusters
- 对每个聚类数量使用 `AgglomerativeClustering` 进行聚类
- 计算轮廓系数Silhouette Score
- 选择轮廓系数最高的聚类数量作为最佳聚类数量
- 如果聚类数量为 1 或更少直接返回 1
Args:
embeddings: 待聚类的向量数据所有句子的嵌入向量列表
min_clusters: 搜索的最佳聚类数量下限默认为 2
max_clusters: 搜索的最佳聚类数量上限默认为 10
Returns:
int: 轮廓系数表现最好的聚类数量
"""
if len(embeddings) <= min_clusters:
return len(embeddings)
best_score = -1
best_k = min_clusters
limit_k = min(max_clusters, len(embeddings))
for k in range(min_clusters, limit_k + 1):
labels = AgglomerativeClustering(n_clusters=k, metric="cosine", linkage="average").fit_predict(embeddings)
if len(set(labels)) <= 1:
continue
score = silhouette_score(embeddings, labels, metric="cosine")
if score > best_score:
best_score = score
best_k = k
return best_k
def semantic_chunking_with_auto_clusters(
text: str, embed_fn: Callable[[list[str]], Any] | None, token_count_fn: Callable[[str], int], max_chunk_size: int = 512
) -> list[str]:
"""
对传入的文本进行语义切分过程中会自动选择最佳的聚集数量
逻辑
- 先将文本中的句子按语言进行分发英文/混合文本使用NLTK的sent_tokenize中文文本使用split_sentences_chinese
- 对每个句子进行嵌入向量化
- 确定最佳的聚类数量根据轮廓系数
- 对句子进行聚类后按原文顺序遍历当聚类标签变化或达到长度上限时切分形成连续分块
- 如果嵌入模型缺失则退化为原始切分方式
"""
sentences = split_mixed_sentences(text)
if len(sentences) < 2:
return [text.strip()]
# 计算每个句子的token数量
sentence_token_counts = [token_count_fn(s) for s in sentences]
total_tokens = sum(sentence_token_counts)
# 如果没有提供向量化函数,或者整体未超长,则直接进行简单合并/返回
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]),后续会按原文顺序在标签变化处切分连续分块
labels = AgglomerativeClustering(n_clusters=best_k, metric="cosine", linkage="average").fit_predict(embeddings)
chunks = []
current_chunk = ""
current_chunk_tokens = 0
current_label = labels[0]
for sentence, label, token_count in zip(sentences, labels, sentence_token_counts):
if label != current_label or current_chunk_tokens + token_count > max_chunk_size:
if current_chunk.strip():
chunks.append(current_chunk.strip())
current_chunk = sentence
current_chunk_tokens = token_count
current_label = label
else:
current_chunk += sentence
current_chunk_tokens += token_count
if current_chunk.strip():
chunks.append(current_chunk.strip())
return chunks

View File

@ -0,0 +1,147 @@
from __future__ import annotations
from bs4 import BeautifulSoup
def html_table_to_markdown(html: str) -> str:
"""
将HTML表格转换为Markdown格式的具体实现
"""
soup = BeautifulSoup(html, "html.parser")
table = soup.find("table")
if table is None:
return ""
rows = table.find_all("tr")
if not rows:
return ""
grid = []
for r_idx, row in enumerate(rows):
while len(grid) <= r_idx:
grid.append([])
cells = row.find_all(["td", "th"])
c_idx = 0
for cell in cells:
while c_idx < len(grid[r_idx]) and grid[r_idx][c_idx] is not None:
c_idx += 1
text = cell.get_text(strip=True)
text = text.replace("\n", " ")
rowspan = int(cell.get("rowspan", 1))
colspan = int(cell.get("colspan", 1))
for r in range(rowspan):
target_r = r_idx + r
while len(grid) <= target_r:
grid.append([])
for c in range(colspan):
target_c = c_idx + c
while len(grid[target_r]) <= target_c:
grid[target_r].append(None)
grid[target_r][target_c] = text
c_idx += colspan
if not grid:
return ""
markdown_lines = []
max_cols = max(len(r) for r in grid)
for r in grid:
while len(r) < max_cols:
r.append("")
header = grid[0]
header = [h if h is not None else "" for h in header]
markdown_lines.append("| " + " | ".join(header) + " |")
markdown_lines.append("|" + "|".join([" --- " for _ in range(max_cols)]) + "|")
for row in grid[1:]:
row_clean = [cell if cell is not None else "" for cell in row]
line = "| " + " | ".join(row_clean) + " |"
markdown_lines.append(line)
return "\n".join(markdown_lines)
def html_table_to_key_value(html: str) -> list[str]:
"""
将HTML表格转换为键值对格式的列表为了应对过长的表格的切分问题
处理逻辑
1. **网格重建**由于 HTML 表格可能包含 `rowspan` `colspan`合并单元格
函数首先构建一个完整的二维网格grid
2. **单元格展开**遍历 HTML 行和列遇到合并单元格时将其内容填充到网格中受影响的所有坐标点
这确保了原本被合并的区域在逻辑网格中每个点都有对应的值
3. **键值对转换**
- 将网格的第一行视为表头Key
- 从第二行开始将每一行与表头对应生成 "键:值" 形式的字符串
例如
- 输入HTML表格包含姓名年龄性别三列
- 输出['姓名张三年龄25性别', '姓名李四年龄30性别']
"""
soup = BeautifulSoup(html, "html.parser")
table = soup.find("table")
if table is None:
return []
rows = table.find_all("tr")
if not rows:
return []
grid = []
for r_idx, row in enumerate(rows):
while len(grid) <= r_idx:
grid.append([])
cells = row.find_all(["td", "th"])
c_idx = 0
for cell in cells:
while c_idx < len(grid[r_idx]) and grid[r_idx][c_idx] is not None:
c_idx += 1
text = cell.get_text(strip=True)
rowspan = int(cell.get("rowspan", 1))
colspan = int(cell.get("colspan", 1))
for r in range(rowspan):
target_r = r_idx + r
while len(grid) <= target_r:
grid.append([])
for c in range(colspan):
target_c = c_idx + c
while len(grid[target_r]) <= target_c:
grid[target_r].append(None)
grid[target_r][target_c] = text
c_idx += colspan
if not grid:
return []
headers = grid[0]
headers = [h if h is not None else "" for h in headers]
kv_lines = []
for row_values in grid[1:]:
min_len = min(len(headers), len(row_values))
row_parts = []
for i in range(min_len):
key = headers[i]
val = row_values[i] if row_values[i] is not None else ""
if key:
row_parts.append(f"{key}{val}")
if row_parts:
kv_lines.append("".join(row_parts) + "")
return kv_lines

View File

@ -0,0 +1,319 @@
import pytest
import numpy as np
# 现在可以安全地导入了,因为顶层不再有重型依赖
from yuxi.knowledge.chunking.ragflow_like.parsers import semantic
from yuxi.models import select_embedding_model
@pytest.fixture
def embed_fn():
"""使用真实的嵌入函数 (从 SiliconFlow 获取)"""
model_id = "siliconflow/Qwen/Qwen3-Embedding-0.6B"
try:
model = select_embedding_model(model_id)
def encode(sentences):
if isinstance(sentences, str):
sentences = [sentences]
return model.encode(sentences)
return encode
except Exception as e:
pytest.skip(f"无法初始化真实嵌入模型 {model_id}: {e}")
@pytest.fixture
def sample_markdown():
"""提供一个包含多章节、公式符号和复杂结构的临时 Markdown 样本"""
return """
## 痛点与挑战
在传统的RAG知识库前置处理过程中我们面临着诸多挑战
- **格式繁杂兼容难**企业文档格式多样传统工具例如python-docx和pymupdf难以同时高质量处理 Office 文档和复杂的 PDF 扫描件
- **语义割裂检索差**简单的按字符切分导致上下文丢失检索匹配度低无法满足业务需求
- **解析效率瓶颈**面对海量存量文档缺乏高并发处理能力响应速度慢难以支撑大规模应用
## 技术方案概览
为了解决上述问题我们构建了一套高性能可扩展的文档处理架构
- **全格式支持**全面覆盖 **WordExcelPDFPPT和图片**等主流办公文档格式
- **高精度解析**通过引入MinerU VLM模型实现准确的布局解析 and 元素提取
- **跨平台支持**同时支持**英伟达CUDA和华为CANN框架**
- **知识库应用优化**为了更好支持上层应用解析后增加了智能切分和实体识别功能增强后续的检索效果
- **高并发架构设计**
- 采用 **FastAPI** 作为服务入口保障请求的高效接收与响应
- 引入 **Celery** 分布式任务队列实现请求响应与处理过程的完全解耦有效应对流量洪峰保障系统稳定性和可用性
- **资源利用最大化**通过创建多组 **Celery Worker**执行CPU密集任务 + **vLLM Server**执行GPU密集推理任务的Pod单元实现对多核CPU+多卡GPU的并行调用大幅提升单机处理吞吐量
- 使用Docker Compose进行服务编排不同功能划分为不同的进程保证服务的并发量和性能
![image.png](attachment:393b3113-298b-4063-b572-db9694dddb4b:image.png)
## 架构设计
| 服务名称 | 容器名称 | 职责描述 |
| --- | --- | --- |
| **mineru-api** | `mineru-api` | **API 网关**基于 FastAPI提供对外 HTTP 接口 负责接收请求鉴权及任务分发 |
| **mineru-worker** | `mineru-worker` | **异步任务处理器**基于 Celery负责 PDF 解析OCR文档转换等耗时任务 |
| **vllm** | `mineru-vllm` | **推理后端**运行 MinerU 视觉大模型 (VLM)提供高并发的文档理解与提取能力 |
| **redis** | `mineru-redis` | **消息中间件**作为 Celery Broker Backend同时缓存部分运行时数据 |
## 核心技术亮点
### 1. 多模态融合的高精度解析
针对不同类型的文档我们采用了差异化的解析策略以确保最佳效果
* **Office 文档**利用 **Docling** 快速精准地从文件中提取文本与格式信息
* **复杂 PDF 文档**引入 **MinerU 视觉大模型VLM**该模型具备类似人类的视觉能力能够深度理解文档排版完美还原文档结构和准确提取页面上的公式和表格元素并统一输出为标准的 Markdown 格式
![原始文件](attachment:45b43827-6c38-49ed-9996-e08c037d6858:公式1_1原始文件.png)
原始文件
![解析后效果](attachment:90e6a6da-0ac6-4b43-bfcb-f0921960c5f1:公式1_3解析后预览.png)
解析后效果
### 2. 智能表格处理
针对文档中的表格数据我们提供了两种灵活的转换方式满足不同应用场景的需求
* **键值对转换**将表格行列关系转换为自然语义表达便于大模型理解和问答并且**支持合并单元格的处理**避免生成关系错乱的表格数据例如产品名称 | 价格 | 库存的表格转换为产品名称A价格100库存50产品名称B价格150库存30等键值对形式
* **Markdown表格转换**保持表格的原始结构转换为标准的Markdown表格格式便于上层应用中可视化展示
![原始表格](attachment:b02cd061-728f-4fd8-ac12-5298dd427cc3:表格1_1原始文件.png)
原始表格
![markdown格式表格](attachment:efaa0df1-96d6-4c9f-bbb4-80a561d10009:表格1_3解析后预览.png)
markdown格式表格
![key-value格式表格](attachment:a476994c-6d6b-4a74-9a55-b6907ce40bde:表格1_4切分后.png)
key-value格式表格
此外**针对过长的表格支持按照长度切分在不丢失信息的前提下**适配向量数据库的存储保证后续的检索效果
![原始表格](attachment:71523288-f21f-413e-a69b-e9fda14532e9:表格2_3解析后预览.png)
原始表格
![表格切分后](attachment:ffa664bb-5eb8-4820-a4fb-9968266e8fd2:表格2_4切分后.png)
表格切分后
### 3. 基于语义的智能切分
摒弃了传统机械的字数切分方式我们引入了 **BGE Embedding 模型**通过计算文本向量并利用相似度聚类算法将含义紧密相关的段落自动聚合这种方式确保了每一个数据切片都是一个完整的语义单元大幅提升了后续检索的准确性与此同时模型规模为24M资源占用率极小对整体解析流程的影响微乎其微
![语义切分前1](attachment:7e4e4c3c-126b-4c5b-8dc7-e4da93252a54:语义分析1_2解析后.png)
语义切分前1
![语义切分后2](attachment:3febe837-7722-4c59-8e40-25e9bc8dbbaa:语义分析1_4切分后.png)
语义切分后2
![语义切分前2](attachment:e17278be-b749-4618-8b51-793f83892caf:语义分析2_2解析后.png)
语义切分前2
![语义切分后2](attachment:18f40c6d-6c08-40e8-b369-927b33f09816:语义分析2_3切分后.png)
语义切分后2
### 4. 上下文感知的标题聚合
针对企业长文档层级复杂的特点我们开发了**标题聚合功能**系统会自动将多级标题信息聚合到对应的段落中无论文档如何切分每个切片都能保留父级标题-子标题的完整路径有效解决了碎片化导致的上下文丢失问题从而提升了后续检索的准确性和相关性
![多级标题聚合](attachment:7dd756ee-b097-4b2c-b7ca-638915fd7b23:语义分析2_3切分后.png)
多级标题聚合
### 5. 关键信息自动提取
为了进一步提升检索效率我们集成了中英文的 **NER命名实体识别模型**在解析过程中系统会自动抽取文档中的组织机构人名专有名词等关键实体为文档打上智能标签实现多维度的精准检索
![image.png](attachment:62685770-d067-4df5-9415-a559b0e20658:image.png)
![image.png](attachment:4d3847f8-317d-4393-8e7c-bddb5f3c50f7:image.png)
### 6. 内容检索
![原始PDF页面](attachment:4ad6a991-7d23-494d-a653-95ec8ce24f75:image.png)
原始PDF页面
![搜索关键字叶片描述](attachment:cc341750-9fb0-4863-b758-d77194a783c1:image.png)
搜索关键字叶片描述
返回结果如下
```json
{
"status": "success",
"message": "搜索成功",
"data": {
"result": [
{
"page_idx": 14,
"span_range": [
0,
0
],
"bbox": [
70,
586,
525,
614
]
},
{
"page_idx": 14,
"span_range": [
0,
0
],
"bbox": [
69,
625,
147,
638
]
}
]
}
}
```
应用展示
![c17614a4ad447a787d3a1131825a2d22.png](attachment:0c3997f7-3107-43c9-8c90-b99b8283dbf7:c17614a4ad447a787d3a1131825a2d22.png)
### 7. 性能优化
通过将CPU密集型任务和GPU密集型任务切分为相互独立的进程更好利用了高性能服务器的硬件资源不仅可以进行多路解析在单任务的处理上也有显著提升在附加了实体识别和智能分段等附加功能处理时间和使用MinerU CLI所需的时间几乎没有差别在多路服务器上可以通过简单配置使不同的任务使用服务器上独立硬件资源成倍提速
| 解析样例 | 原生MinerU | 本方案 |
| --- | --- | --- |
| PDF测试1158页英文内容 | 79.26s | 79.661 |
| PDF测试2731页英文内容 | 402s | 393s |
| PDF测试2670页中文内容 | 163 | 116.53 |
测试环境
- GPU: NVIDIA A100 80GB
- CPU: Intel(R) Xeon(R) Gold 6348 CPU @ 2.60GHz
## 核心组件
| 依赖名称 | 版本范围 | 功能描述 |
| --- | --- | --- |
| **fastapi** | `>=0.115.12` | **Web 框架**提供高性能的 API 服务接口支持异步编程 |
| **mineru** | `>=2.5.0` | **核心引擎**MinerU 核心库提供 PDF 解析布局分析和公式提取能力 |
| **celery** | `>=5.5.3` | **异步任务队列**用于处理耗时的 PDF 解析和 NLP任务 |
| **vllm** | `>=0.11.0` | **大模型推理**高性能 LLM/VLM 推理引擎用于加速VLM视觉大模型的推理 |
| **transformers** | `>=4.53.0` | **NLP 模型库**用于加载和运行各种预训练模型 NEREmbedding |
| **minio** | `>=7.2.15` | **对象存储客户端**用于上传和下载 PDF 源文件及解析后的结果文件 |
| **sentence-transformers** | `>=5.1.0` | **文本向量化**用于生成文本 Embedding实现基于文本语义的切分 |
| **sqlalchemy** | `>=2.0.41` | **ORM 框架**用于数据库操作 and 连接管理 |
| **redis** | (Implied) | **缓存与消息中间件**作为 Celery Broker 和应用缓存通过 `redislite` 或外部服务 |
## 应用价值
- **提质**将非结构化文档转化为高质量的 Markdown 数据为大模型知识库建设提供了坚实的基础
- **增效**通过自动化并行化的处理流程结合软件架构的优化将文档处理效率提升数倍实现了从小时级分钟级的跨越
- **赋能**该平台可广泛应用于政策文件检索合同智能审核技术文档问答等多种业务场景切实助力企业提效减负
| 对比维度 | 原生 MinerU | 本方案 | 优势 |
| --- | --- | --- | --- |
| **系统架构** | 单体应用命令行工具 | 分布式微服务架构FastAPI + Celery + vLLM + Redis | 支持高并发可水平扩展适合企业级部署 |
| **服务化能力** | 无服务接口需本地调用 | 提供 RESTful API支持 HTTP 调用 | 易于集成到现有系统支持多语言调用 |
| **并发处理** | 单任务串行处理 | 分布式任务队列支持多 Worker 并行 | 处理效率提升数倍支持海量文档批量处理 |
| **资源利用** | CPU/GPU 资源利用率低 | CPU 密集任务与 GPU 推理任务分离多卡并行 | 单机吞吐量大幅提升资源利用最大化 |
| **文档格式支持** | 主要支持 PDF | 全格式支持WordExcelPDFPPT图片 | 一站式解决企业多格式文档处理需求 |
| **表格处理** | 基础表格识别 | 智能表格处理键值对转换 + Markdown 表格 | 满足问答 and 可视化展示双重场景需求 |
| **文本切分** | 无切分功能 | 基于语义的智能切分BGE Embedding + 聚类 | 保证语义完整性提升检索准确性 |
| **上下文管理** | 无标题聚合功能 | 上下文感知的标题聚合多级标题路径保留 | 解决碎片化导致的上下文丢失问题 |
| **信息提取** | 无实体识别功能 | 集成 NER 模型自动提取关键实体 | 支持多维度精准检索为文档打智能标签 |
"""
def test_semantic_chunking_basic(embed_fn, sample_markdown):
"""测试基本的语义切分逻辑 (使用真实嵌入模型)"""
# 配置切分参数
parser_config = {
"chunk_token_num": 1000, # 针对标准文档调整 token 数
"overlapped_percent": 0.1
}
# 执行语义切分
chunks = semantic.chunk_markdown(
sample_markdown,
parser_config=parser_config,
embed_fn=embed_fn
)
# 1. 基础验证
assert isinstance(chunks, list)
assert len(chunks) >= 5, f"预期至少切分为 5 个片段,实际仅有 {len(chunks)}"
# 2. 验证上下文感知与标题聚合 (检查 RAGFlow 风格的层级路径)
# 验证子章节片段是否保留了父级标题路径
# 检查 "Docling" 相关的片段 (属于 "核心技术亮点" -> "多模态融合的高精度解析")
docling_chunks = [c for c in chunks if "Docling" in c]
assert docling_chunks, "未找到包含 'Docling' 的片段"
for chunk in docling_chunks:
assert "核心技术亮点" in chunk, "子章节片段丢失了父级标题 '核心技术亮点'"
assert "多模态融合的高精度解析" in chunk, "子章节片段丢失了自身标题 '多模态融合的高精度解析'"
# 验证层级分隔符
path_part = chunk.split("\n")[0] if "\n" in chunk else chunk
assert "|" in path_part, f"标题路径中缺失层级分隔符 '|': {path_part}"
assert path_part.count("|") >= 1, f"标题路径层级深度不足: {path_part}"
# 检查顶级章节 (Level 2) 是否保持独立 (不应包含其他不相关的顶级标题)
pain_point_chunks = [c for c in chunks if "格式繁杂兼容难" in c]
for chunk in pain_point_chunks:
path_part = chunk.split("\n")[0]
assert "痛点与挑战" in path_part
assert "技术方案概览" not in path_part, "顶级标题路径污染:包含了不相关的顶级标题"
# 3. 验证关键技术词汇识别
# 重点检查文档中出现的核心组件词汇
keywords_to_check = ["FastAPI", "Celery", "vLLM", "MinerU"]
found_keywords = [s for s in keywords_to_check if any(s in chunk for chunk in chunks)]
assert len(found_keywords) > 0, f"在切分结果中未找到关键技术词汇: {keywords_to_check}"
# 4. 验证核心章节内容是否存在
has_arch_section = any("架构设计" in chunk for chunk in chunks)
has_highlight_section = any("核心技术亮点" in chunk for chunk in chunks)
assert has_arch_section, "未找到 '架构设计' 相关内容"
assert has_highlight_section, "未找到 '核心技术亮点' 相关内容"
# 5. 验证语义聚类是否将不同主题分开
# 架构设计和核心技术亮点是两个独立的大章节,语义聚类应该尽量避免将它们的核心内容混在一个 chunk 中
arch_start_chunks = [i for i, c in enumerate(chunks) if "## 架构设计" in c]
highlight_start_chunks = [i for i, c in enumerate(chunks) if "## 核心技术亮点" in c]
if arch_start_chunks and highlight_start_chunks:
# 确保起始片段不重合
assert not set(arch_start_chunks).intersection(set(highlight_start_chunks)), \
"语义聚类错误:架构设计与核心技术亮点的章节头部被挤在了同一个 chunk 中"
print(f"\n[测试成功] 文档成功切分为 {len(chunks)} 个片段")
print(f"识别到的关键技术词汇: {found_keywords}")
# 直接输出到控制台预览切分结果,避免文件副作用
print("\n--- 语义切分结果开始 ---")
for idx, chunk in enumerate(chunks, 1):
print(f"\n[Chunk {idx}]\n{chunk}")
print("\n--- 语义切分结果结束 ---")
def test_heading_inference():
"""测试标题层级推断工具类"""
from yuxi.knowledge.chunking.ragflow_like.utils.md_parser_utils import infer_heading_level
assert infer_heading_level("1. 简介") == 1
assert infer_heading_level("1.1 详细设计") == 2
assert infer_heading_level("1.2.3 核心逻辑") == 3
assert infer_heading_level("一、 背景") == 1
assert infer_heading_level("普通文本") == 1

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

@ -2521,6 +2521,18 @@ wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" },
]
[[package]]
name = "mdit-py-plugins"
version = "0.5.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "markdown-it-py" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
@ -4337,6 +4349,38 @@ torch = [
{ name = "torch", version = "2.8.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform != 'darwin'" },
]
[[package]]
name = "scikit-learn"
version = "1.8.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "joblib" },
{ name = "numpy" },
{ name = "scipy" },
{ name = "threadpoolctl" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/90/74/e6a7cc4b820e95cc38cf36cd74d5aa2b42e8ffc2d21fe5a9a9c45c1c7630/scikit_learn-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fb63362b5a7ddab88e52b6dbb47dac3fd7dafeee740dc6c8d8a446ddedade8e", size = 8548242, upload-time = "2025-12-10T07:07:51.568Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/49/d8/9be608c6024d021041c7f0b3928d4749a706f4e2c3832bbede4fb4f58c95/scikit_learn-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5025ce924beccb28298246e589c691fe1b8c1c96507e6d27d12c5fadd85bfd76", size = 8079075, upload-time = "2025-12-10T07:07:53.697Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/dd/47/f187b4636ff80cc63f21cd40b7b2d177134acaa10f6bb73746130ee8c2e5/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4496bb2cf7a43ce1a2d7524a79e40bc5da45cf598dbf9545b7e8316ccba47bb4", size = 8660492, upload-time = "2025-12-10T07:07:55.574Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/97/74/b7a304feb2b49df9fafa9382d4d09061a96ee9a9449a7cbea7988dda0828/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bcfe4d0d14aec44921545fd2af2338c7471de9cb701f1da4c9d85906ab847a", size = 8931904, upload-time = "2025-12-10T07:07:57.666Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/9f/c4/0ab22726a04ede56f689476b760f98f8f46607caecff993017ac1b64aa5d/scikit_learn-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:35c007dedb2ffe38fe3ee7d201ebac4a2deccd2408e8621d53067733e3c74809", size = 8019359, upload-time = "2025-12-10T07:07:59.838Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/24/90/344a67811cfd561d7335c1b96ca21455e7e472d281c3c279c4d3f2300236/scikit_learn-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:8c497fff237d7b4e07e9ef1a640887fa4fb765647f86fbe00f969ff6280ce2bb", size = 7641898, upload-time = "2025-12-10T07:08:01.36Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409, upload-time = "2025-12-10T07:08:12.028Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760, upload-time = "2025-12-10T07:08:13.688Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994, upload-time = "2025-12-10T07:08:23.943Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" },
]
[[package]]
name = "scipy"
version = "1.17.1"
@ -4756,6 +4800,15 @@ wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f5/15/a11f7bb3cbc97dfecf32a90552f5a8f8a5c99316a99c6c17bdabf5baf256/thinc-8.3.13-cp313-cp313-win_arm64.whl", hash = "sha256:723949cab11d1925c15447928513a718276316cec6e0de28337cca0a62be0521", size = 1644606, upload-time = "2026-03-23T07:22:21.339Z" },
]
[[package]]
name = "threadpoolctl"
version = "3.6.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" },
]
[[package]]
name = "tiktoken"
version = "0.12.0"
@ -4878,9 +4931,9 @@ dependencies = [
{ name = "typing-extensions", marker = "sys_platform == 'darwin'" },
]
wheels = [
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:a47b7986bee3f61ad217d8a8ce24605809ab425baf349f97de758815edd2ef54" },
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:fbe2e149c5174ef90d29a5f84a554dfaf28e003cb4f61fa2c8c024c17ec7ca58" },
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:057efd30a6778d2ee5e2374cd63a63f63311aa6f33321e627c655df60abdd390" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:a47b7986bee3f61ad217d8a8ce24605809ab425baf349f97de758815edd2ef54" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:fbe2e149c5174ef90d29a5f84a554dfaf28e003cb4f61fa2c8c024c17ec7ca58" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:057efd30a6778d2ee5e2374cd63a63f63311aa6f33321e627c655df60abdd390" },
]
[[package]]
@ -4903,19 +4956,19 @@ dependencies = [
{ name = "typing-extensions", marker = "sys_platform != 'darwin'" },
]
wheels = [
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-linux_s390x.whl", hash = "sha256:0e34e276722ab7dd0dffa9e12fe2135a9b34a0e300c456ed7ad6430229404eb5" },
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:610f600c102386e581327d5efc18c0d6edecb9820b4140d26163354a99cd800d" },
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:cb9a8ba8137ab24e36bf1742cb79a1294bd374db570f09fc15a5e1318160db4e" },
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-win_amd64.whl", hash = "sha256:2be20b2c05a0cce10430cc25f32b689259640d273232b2de357c35729132256d" },
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-win_arm64.whl", hash = "sha256:99fc421a5d234580e45957a7b02effbf3e1c884a5dd077afc85352c77bf41434" },
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp313-cp313-linux_s390x.whl", hash = "sha256:8b5882276633cf91fe3d2d7246c743b94d44a7e660b27f1308007fdb1bb89f7d" },
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a5064b5e23772c8d164068cc7c12e01a75faf7b948ecd95a0d4007d7487e5f25" },
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:8f81dedb4c6076ec325acc3b47525f9c550e5284a18eae1d9061c543f7b6e7de" },
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp313-cp313-win_amd64.whl", hash = "sha256:e1ee1b2346ade3ea90306dfbec7e8ff17bc220d344109d189ae09078333b0856" },
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp313-cp313-win_arm64.whl", hash = "sha256:64c187345509f2b1bb334feed4666e2c781ca381874bde589182f81247e61f88" },
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:af81283ac671f434b1b25c95ba295f270e72db1fad48831eb5e4748ff9840041" },
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:a9dbb6f64f63258bc811e2c0c99640a81e5af93c531ad96e95c5ec777ea46dab" },
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp313-cp313t-win_amd64.whl", hash = "sha256:6d93a7165419bc4b2b907e859ccab0dea5deeab261448ae9a5ec5431f14c0e64" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-linux_s390x.whl", hash = "sha256:0e34e276722ab7dd0dffa9e12fe2135a9b34a0e300c456ed7ad6430229404eb5" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:610f600c102386e581327d5efc18c0d6edecb9820b4140d26163354a99cd800d" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:cb9a8ba8137ab24e36bf1742cb79a1294bd374db570f09fc15a5e1318160db4e" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-win_amd64.whl", hash = "sha256:2be20b2c05a0cce10430cc25f32b689259640d273232b2de357c35729132256d" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-win_arm64.whl", hash = "sha256:99fc421a5d234580e45957a7b02effbf3e1c884a5dd077afc85352c77bf41434" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp313-cp313-linux_s390x.whl", hash = "sha256:8b5882276633cf91fe3d2d7246c743b94d44a7e660b27f1308007fdb1bb89f7d" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a5064b5e23772c8d164068cc7c12e01a75faf7b948ecd95a0d4007d7487e5f25" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:8f81dedb4c6076ec325acc3b47525f9c550e5284a18eae1d9061c543f7b6e7de" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp313-cp313-win_amd64.whl", hash = "sha256:e1ee1b2346ade3ea90306dfbec7e8ff17bc220d344109d189ae09078333b0856" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp313-cp313-win_arm64.whl", hash = "sha256:64c187345509f2b1bb334feed4666e2c781ca381874bde589182f81247e61f88" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:af81283ac671f434b1b25c95ba295f270e72db1fad48831eb5e4748ff9840041" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:a9dbb6f64f63258bc811e2c0c99640a81e5af93c531ad96e95c5ec777ea46dab" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp313-cp313t-win_amd64.whl", hash = "sha256:6d93a7165419bc4b2b907e859ccab0dea5deeab261448ae9a5ec5431f14c0e64" },
]
[[package]]
@ -5616,6 +5669,7 @@ dependencies = [
{ name = "aiosqlite" },
{ name = "argon2-cffi" },
{ name = "asyncpg" },
{ name = "beautifulsoup4" },
{ name = "chardet" },
{ name = "colorlog" },
{ name = "dashscope" },
@ -5642,11 +5696,14 @@ dependencies = [
{ name = "llama-index" },
{ name = "llama-index-readers-file" },
{ name = "loguru" },
{ name = "markdown-it-py" },
{ name = "markdownify" },
{ name = "mcp" },
{ name = "mdit-py-plugins" },
{ name = "minio" },
{ name = "neo4j" },
{ name = "networkx" },
{ name = "nltk" },
{ name = "openai" },
{ name = "opencv-python-headless" },
{ name = "pillow" },
@ -5664,6 +5721,7 @@ dependencies = [
{ name = "readability-lxml" },
{ name = "redis" },
{ name = "rich" },
{ name = "scikit-learn" },
{ name = "sqlalchemy", extra = ["asyncio"] },
{ name = "tabulate" },
{ name = "tavily-python" },
@ -5689,6 +5747,7 @@ requires-dist = [
{ name = "aiosqlite", specifier = ">=0.20.0" },
{ name = "argon2-cffi", specifier = ">=25.1.0" },
{ name = "asyncpg", specifier = ">=0.30.0" },
{ name = "beautifulsoup4", specifier = ">=4.12.0" },
{ name = "chardet", specifier = ">=5.0.0" },
{ name = "colorlog", specifier = ">=6.9.0" },
{ name = "dashscope", specifier = ">=1.23.2" },
@ -5715,11 +5774,14 @@ requires-dist = [
{ name = "llama-index", specifier = ">=0.14" },
{ name = "llama-index-readers-file", specifier = ">=0.4.7" },
{ name = "loguru", specifier = ">=0.7.3" },
{ name = "markdown-it-py", specifier = ">=3.0.0" },
{ name = "markdownify", specifier = ">=1.1.0" },
{ name = "mcp", specifier = ">=1.20" },
{ name = "mdit-py-plugins", specifier = ">=0.4.0" },
{ name = "minio", specifier = ">=7.2.7" },
{ name = "neo4j", specifier = ">=5.28.1" },
{ name = "networkx", specifier = ">=3.5" },
{ name = "nltk", specifier = ">=3.8.1" },
{ name = "openai", specifier = ">=1.109" },
{ name = "opencv-python-headless", specifier = ">=4.11.0.86" },
{ name = "pillow", specifier = ">=10.5.0" },
@ -5737,6 +5799,7 @@ requires-dist = [
{ name = "readability-lxml", specifier = ">=0.8.1" },
{ name = "redis", specifier = ">=5.2.0" },
{ name = "rich", specifier = ">=13.7.1" },
{ name = "scikit-learn", specifier = ">=1.3.0" },
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.0" },
{ name = "tabulate", specifier = ">=0.9.0" },
{ name = "tavily-python", specifier = ">=0.7.0" },

View File

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

View File

@ -2,6 +2,7 @@
"name": "yuxi-web",
"version": "0.6.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"server": "vite serve --host",

View File

@ -18,6 +18,11 @@ export const CHUNK_PRESET_OPTIONS = [
value: 'laws',
label: 'Laws',
description: '法规分块:按法条层级组织与合并,适合法律法规、制度规范类文本。'
},
{
value: 'semantic',
label: 'Semantic',
description: '语义分块:利用嵌入和聚类算法进行语义切分,并自动增强标题上下文。'
}
]