diff --git a/backend/package/pyproject.toml b/backend/package/pyproject.toml index c2983862..30eff0e3 100644 --- a/backend/package/pyproject.toml +++ b/backend/package/pyproject.toml @@ -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] diff --git a/backend/package/yuxi/knowledge/chunking/ragflow_like/dispatcher.py b/backend/package/yuxi/knowledge/chunking/ragflow_like/dispatcher.py index 598b96f6..a9d5971a 100644 --- a/backend/package/yuxi/knowledge/chunking/ragflow_like/dispatcher.py +++ b/backend/package/yuxi/knowledge/chunking/ragflow_like/dispatcher.py @@ -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) diff --git a/backend/package/yuxi/knowledge/chunking/ragflow_like/parsers/semantic.py b/backend/package/yuxi/knowledge/chunking/ragflow_like/parsers/semantic.py new file mode 100644 index 00000000..91a84ab9 --- /dev/null +++ b/backend/package/yuxi/knowledge/chunking/ragflow_like/parsers/semantic.py @@ -0,0 +1,274 @@ +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.utils.logging_config import logger +from yuxi.knowledge.chunking.ragflow_like.nlp import count_tokens + +from ..utils.md_parser_utils import ( + infer_heading_level, + get_title_path, + extract_table_block, + 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 ' 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], + }, ] diff --git a/backend/package/yuxi/knowledge/chunking/ragflow_like/utils/md_parser_utils.py b/backend/package/yuxi/knowledge/chunking/ragflow_like/utils/md_parser_utils.py new file mode 100644 index 00000000..e1b37869 --- /dev/null +++ b/backend/package/yuxi/knowledge/chunking/ragflow_like/utils/md_parser_utils.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import re +from typing import List, Callable, Any +from .semantic_utils import semantic_chunking_with_auto_clusters + +def infer_heading_level(title: str) -> int: + """ + 根据标题文本推断其层级级别(1-6级)。 + """ + 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流和原始文本中提取完整的表格块。 + """ + 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], + 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) + + 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) + added_tokens = line_token_count + (1 if current_chunk_lines else 0) + + 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) + + elif current_chunk_tokens + added_tokens > max_length: + chunks.append('\n'.join(current_chunk_lines)) + current_chunk_lines = [line] + current_chunk_tokens = line_token_count + + else: + current_chunk_lines.append(line) + current_chunk_tokens += added_tokens + + if current_chunk_lines: + chunks.append('\n'.join(current_chunk_lines)) + + return chunks diff --git a/backend/package/yuxi/knowledge/chunking/ragflow_like/utils/semantic_utils.py b/backend/package/yuxi/knowledge/chunking/ragflow_like/utils/semantic_utils.py new file mode 100644 index 00000000..231d7320 --- /dev/null +++ b/backend/package/yuxi/knowledge/chunking/ragflow_like/utils/semantic_utils.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import re +import nltk +from nltk.tokenize import sent_tokenize +from sklearn.cluster import AgglomerativeClustering +from sklearn.metrics import silhouette_score +from typing import Callable, List, Any + +# Ensure punkt_tab is available +try: + nltk.data.find('tokenizers/punkt_tab') +except LookupError: + nltk.download('punkt_tab') + +def split_sentences_chinese(text: str) -> List[str]: + """ + Split sentences by Chinese punctuation while keeping the punctuation. + """ + 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]: + """ + Handle both Chinese and English sentence splitting. + """ + 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: + """ + Select best number of clusters using silhouette score. + """ + 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], + token_count_fn: Callable[[str], int], + max_chunk_size: int = 512 +) -> List[str]: + """ + Semantic chunking with automatic cluster number selection. + """ + sentences = split_mixed_sentences(text) + if len(sentences) < 2: + return [text.strip()] + + # Vectorization + embeddings = embed_fn(sentences) + + # Pre-calculate token counts + sentence_token_counts = [token_count_fn(s) for s in sentences] + total_tokens = sum(sentence_token_counts) + + # Determine number of clusters + best_k = max(total_tokens // max_chunk_size, 1) + 1 + best_k = min(best_k, len(sentences)) + + # Clustering + 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 diff --git a/backend/package/yuxi/knowledge/chunking/ragflow_like/utils/table_utils.py b/backend/package/yuxi/knowledge/chunking/ragflow_like/utils/table_utils.py new file mode 100644 index 00000000..9b71a4b0 --- /dev/null +++ b/backend/package/yuxi/knowledge/chunking/ragflow_like/utils/table_utils.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from typing import List +from bs4 import BeautifulSoup +from yuxi.utils.logging_config import logger + +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表格转换为键值对格式的列表 + """ + 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 diff --git a/backend/test/unit/backends/test_semantic_chunking.py b/backend/test/unit/backends/test_semantic_chunking.py new file mode 100644 index 00000000..bdfe5b73 --- /dev/null +++ b/backend/test/unit/backends/test_semantic_chunking.py @@ -0,0 +1,78 @@ +import pytest +import numpy as np + +# 现在可以安全地导入了,因为顶层不再有重型依赖 +from yuxi.knowledge.chunking.ragflow_like.parsers import semantic + +@pytest.fixture +def mock_embed_fn(): + """模拟一个简单的嵌入函数""" + def encode(sentences): + if isinstance(sentences, str): + sentences = [sentences] + # 返回模拟的向量 (N, 384) + return np.random.rand(len(sentences), 384).astype(np.float32) + return encode + +@pytest.fixture +def sample_markdown(): + return """ +# 核心业务说明 + +本节主要介绍公司的核心业务流程。 + +## 第一部分:研发流程 +1. 需求分析:收集用户反馈。 +2. 系统设计:架构方案评审。 +3. 编码实现:遵循规范。 + +## 第二部分:交付标准 +| 阶段 | 交付物 | 责任人 | +| --- | --- | --- | +| 交付1 | 源码包 | 开发 | +| 交付2 | 测试报告 | 测试 | + +这里是一些额外的语义内容。 +语义内容应当被聚类在一起。 +这也是语义内容。 +""" + +def test_semantic_chunking_basic(mock_embed_fn, sample_markdown): + """测试基本的语义切分逻辑 (使用注入方式)""" + + # 配置切分参数 + parser_config = { + "chunk_token_num": 100, # 触发语义切分 + "overlapped_percent": 0 + } + + # 直接注入 mock_embed_fn,完全跳过 config 加载 + chunks = semantic.chunk_markdown( + sample_markdown, + parser_config=parser_config, + embed_fn=mock_embed_fn + ) + + # 验证结果 + assert isinstance(chunks, list) + assert len(chunks) > 0 + + # 验证标题路径增强是否生效 + has_enhanced_title = any("# 核心业务说明|第一部分:研发流程" in chunk for chunk in chunks) + assert has_enhanced_title, "标题路径增强失效" + + # 验证表格是否被正确保留 + has_table = any("交付1" in chunk and "源码包" in chunk for chunk in chunks) + assert has_table, "表格内容丢失" + + print(f"\n成功切分为 {len(chunks)} 个片段") + +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 diff --git a/docker-compose.yml b/docker-compose.yml index 39242efd..5b562c23 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/web/src/utils/chunk_presets.js b/web/src/utils/chunk_presets.js index ebd6285f..642dddd1 100644 --- a/web/src/utils/chunk_presets.js +++ b/web/src/utils/chunk_presets.js @@ -18,6 +18,11 @@ export const CHUNK_PRESET_OPTIONS = [ value: 'laws', label: 'Laws', description: '法规分块:按法条层级组织与合并,适合法律法规、制度规范类文本。' + }, + { + value: 'semantic', + label: 'Semantic', + description: '语义分块:利用嵌入和聚类算法进行语义切分,并自动增强标题上下文。' } ]