Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""语义切分必须拿到每条句子对应的向量,否则会静默丢句。"""

import numpy as np
import pytest

from yuxi.modules.knowledge.chunking.ragflow_like.utils import semantic_utils
from yuxi.modules.knowledge.chunking.ragflow_like.utils.semantic_utils import (
semantic_chunking_with_auto_clusters,
)


def _token_count(text: str) -> int:
return len(text)


def test_short_embeddings_raise_instead_of_dropping_sentences():
"""4 句只返回 2 个向量时,后两句会被 zip 截断丢失,必须报错。

预算取 30 时簇数为 2,与返回的向量行数一致,旧代码能正常聚类后再丢句;
取 10 时簇数算成 4,会先被聚类以「簇数多于样本数」拒绝,测不到本问题。
"""
text = "Sentence one. Sentence two. Sentence three. Sentence four."
embeddings = np.array([[1.0, 0.0], [0.0, 1.0]])

with pytest.raises(ValueError, match="embedding"):
semantic_chunking_with_auto_clusters(
text,
embed_fn=lambda texts: embeddings,
token_count_fn=_token_count,
max_chunk_size=30,
)


def test_extra_embeddings_raise():
text = "Sentence one. Sentence two. Sentence three."
embeddings = np.array([[1.0, 0.0], [0.0, 1.0], [1.0, 1.0], [0.5, 0.5]])

with pytest.raises(ValueError, match="embedding"):
semantic_chunking_with_auto_clusters(
text,
embed_fn=lambda texts: embeddings,
token_count_fn=_token_count,
max_chunk_size=10,
)


def test_aligned_embeddings_keep_every_sentence(monkeypatch):
"""条数对齐时,输出必须包含全部原文句子。"""
sentences = ["Alpha beta.", "Gamma delta.", "Epsilon zeta."]
monkeypatch.setattr(semantic_utils, "split_mixed_sentences", lambda _text: sentences)

embeddings = np.array([[1.0, 0.0], [0.9, 0.1], [0.0, 1.0]])
chunks = semantic_chunking_with_auto_clusters(
"ignored",
embed_fn=lambda texts: embeddings,
token_count_fn=_token_count,
max_chunk_size=5,
)

joined = "".join(chunks)
for sentence in sentences:
assert sentence in joined
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ def semantic_chunking_with_auto_clusters(
chunks = []
current_chunk = ""
current_chunk_tokens = 0
for sentence, token_count in zip(sentences, sentence_token_counts):
for sentence, token_count in zip(sentences, sentence_token_counts, strict=True):
# 首句置空时 current_chunk 为空串,避免产出空 chunk
if current_chunk and current_chunk_tokens + token_count > max_chunk_size:
chunks.append(current_chunk.strip())
Expand All @@ -90,6 +90,11 @@ def semantic_chunking_with_auto_clusters(
return chunks

embeddings = embed_fn(sentences)
# 聚类标签的条数等于向量行数;不足或多余都会让下面的 zip 截断,静默丢弃句子。
if len(embeddings) != len(sentences):
raise ValueError(
f"semantic chunking expects one embedding per sentence, got {len(embeddings)} embeddings for {len(sentences)} sentences"
)

# 簇数按预算向上取整,避免整除时多切一块;聚类数不能超过句子数
best_k = min((total_tokens + max_chunk_size - 1) // max_chunk_size, len(sentences))
Expand All @@ -102,7 +107,7 @@ def semantic_chunking_with_auto_clusters(
current_chunk_tokens = 0
current_label = labels[0]

for sentence, label, token_count in zip(sentences, labels, sentence_token_counts):
for sentence, label, token_count in zip(sentences, labels, sentence_token_counts, strict=True):
if label != current_label or current_chunk_tokens + token_count > max_chunk_size:
# 空块只会出现在首句超预算时,跳过避免产出空 chunk
if current_chunk.strip():
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# 语义切分要求每条句子都有对应向量

状态:implemented
类型:bug-fix
Owner:backend/yuxi/modules/knowledge/chunking/ragflow_like/utils/semantic_utils.py

## 问题

`semantic_chunking_with_auto_clusters` 在聚类后按位置配对句子、簇标签与 token 数:

```python
labels = AgglomerativeClustering(...).fit_predict(embeddings)
for sentence, label, token_count in zip(sentences, labels, sentence_token_counts):
```

`labels` 的长度等于 `embeddings` 的行数,而不是 `sentences` 的长度。`zip` 未加 `strict`,条数不等时按较短方截断,后面的句子被静默丢弃:既不进任何 chunk,也不产生日志或异常。

sklearn 不会因条数不匹配报错——实测 4 句配 2 个向量时返回 2 个标签,`zip` 得到 2 对,另外 2 句消失。

进入该函数的 `embed_fn` 来自 `semantic.py:56` 的 `select_embedding_model(...).encode`,是单次调用、不经 `batch_encode`;调用方注入自定义 `embed_fn`(单测、外部集成)时完全绕过模型层校验。

## 决策

### 实现方案

在聚类之前校验 `len(embeddings) == len(sentences)`,不符抛出固定 `ValueError`。同一函数的另一条按预算合并路径(第二个 `zip`)与聚类路径的 `zip` 均加 `strict=True`,使后续任何条数偏差都以明确异常暴露,而不是截断。

该校验位于切分工具内部,不依赖具体 embedding 实现:无论 `embed_fn` 来自模型层还是调用方注入,契约都在此成立。`#1143` / `#1145` 处理的是模型响应边界(条数、索引、维度),本变更处理的是切分算法自身的输入契约,两者互补。

不选择「不足时降级为按预算顺序合并」:那会把部分失败的聚类伪装成成功结果,且用户无法得知有句子未参与切分。

## 替代方案

- 只加 `strict=True` 的 zip:异常消息不说明是向量条数问题,且发生在聚类之后,失败位置离原因较远。
- 截断 `sentences` 到 `len(embeddings)`:与当前缺陷行为相同,只是显式化。
- 在模型层校验:覆盖不到调用方注入的 `embed_fn`。

## 后果

向量条数与句子数不符时切分整批失败,不产出任何 chunk;调用方得到明确异常而不是残缺的分块结果。条数一致但聚类质量、向量语义正确性不在本契约范围内。

## 验证

`backend/test/unit/knowledge/chunking/test_semantic_embedding_alignment.py`:

- 向量少于句子数时抛出 `ValueError`;
- 向量多于句子数时抛出 `ValueError`;
- 条数对齐时输出包含全部原文句子(负向对照,确认正常路径不受影响)。

与既有 `test_semantic_chunking.py`、`test_semantic_tables.py` 合并运行共 24 项通过。修复前新增用例 2 项失败、1 项通过。

`cd backend && pytest test/unit/knowledge/chunking/ -q`。真实 embedding 服务与聚类质量不由这些单元测试证明。
Loading