use crate::embeddings::{cosine_similarity, Embeddings};
use crate::vector_stores::Document;
pub struct SemanticSplitter<E> {
embeddings: E,
breakpoint_threshold: f32,
max_chunk_size: usize,
}
impl<E: Embeddings> SemanticSplitter<E> {
pub fn new(embeddings: E, breakpoint_threshold: f32, max_chunk_size: usize) -> Self {
Self {
embeddings,
breakpoint_threshold,
max_chunk_size,
}
}
pub fn with_defaults(embeddings: E) -> Self {
Self::new(embeddings, 0.5, 1000)
}
fn split_sentences(text: &str) -> Vec<String> {
let mut sentences = Vec::new();
let mut current = String::new();
for ch in text.chars() {
current.push(ch);
if matches!(ch, '。' | '!' | '?' | ';' | '\n' | '.' | '!' | '?') {
let trimmed = current.trim().to_string();
if !trimmed.is_empty() {
sentences.push(trimmed);
}
current.clear();
}
}
let trimmed = current.trim().to_string();
if !trimmed.is_empty() {
sentences.push(trimmed);
}
sentences
}
pub async fn split_text(&self, text: &str) -> Vec<String> {
let sentences = Self::split_sentences(text);
if sentences.is_empty() {
return Vec::new();
}
if sentences.len() == 1 {
return vec![sentences.into_iter().next().expect("已校验非空")];
}
let refs: Vec<&str> = sentences.iter().map(|s| s.as_str()).collect();
let embeddings = match self.embeddings.embed_documents(&refs).await {
Ok(e) => e,
Err(_) => {
return vec![text.to_string()];
}
};
let mut chunks = Vec::new();
let mut current = sentences[0].clone();
for i in 1..sentences.len() {
let sim = cosine_similarity(&embeddings[i - 1], &embeddings[i]);
let would_exceed = current.len() + sentences[i].len() + 1 > self.max_chunk_size;
if sim < self.breakpoint_threshold || would_exceed {
chunks.push(std::mem::take(&mut current));
current = sentences[i].clone();
} else {
current.push('\n');
current.push_str(&sentences[i]);
}
}
if !current.is_empty() {
chunks.push(current);
}
chunks
}
pub async fn split_document(&self, document: &Document) -> Vec<Document> {
let chunks = self.split_text(&document.content).await;
chunks
.into_iter()
.enumerate()
.map(|(i, chunk)| {
let mut metadata = document.metadata.clone();
metadata.insert("chunk".to_string(), i.to_string());
Document {
content: chunk,
metadata,
id: None,
}
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::embeddings::{EmbeddingError, Embeddings, MockEmbeddings};
use async_trait::async_trait;
struct FailingEmbeddings;
#[async_trait]
impl Embeddings for FailingEmbeddings {
async fn embed_query(&self, _text: &str) -> Result<Vec<f32>, EmbeddingError> {
Err(EmbeddingError::ApiError("intentional failure".to_string()))
}
fn dimension(&self) -> usize {
32
}
fn model_name(&self) -> &str {
"failing"
}
}
fn splitter(threshold: f32, max_chunk: usize) -> SemanticSplitter<MockEmbeddings> {
SemanticSplitter::new(MockEmbeddings::new(32), threshold, max_chunk)
}
#[tokio::test]
async fn test_empty_text() {
let s = splitter(0.5, 1000);
assert!(s.split_text("").await.is_empty());
}
#[tokio::test]
async fn test_single_sentence() {
let s = splitter(0.5, 1000);
let chunks = s.split_text("只有一句没有标点").await;
assert_eq!(chunks, vec!["只有一句没有标点".to_string()]);
}
#[tokio::test]
async fn test_chunks_contain_all_sentences() {
let s = splitter(0.5, 1000);
let text = "苹果是一种水果。香蕉是黄色的。樱桃很小。";
let chunks = s.split_text(text).await;
assert!(!chunks.is_empty());
let joined = chunks.join("");
assert!(joined.contains("苹果是一种水果"));
assert!(joined.contains("香蕉是黄色的"));
assert!(joined.contains("樱桃很小"));
}
#[tokio::test]
async fn test_max_chunk_size_enforces_break() {
let s = splitter(0.0, 5);
let text = "AAAA。BBBB。CCCC。";
let chunks = s.split_text(text).await;
assert!(
chunks.len() >= 2,
"max_chunk=5 应强制断块, 实际 {} 块",
chunks.len()
);
}
#[tokio::test]
async fn test_split_document_metadata() {
let s = splitter(0.0, 5);
let doc = Document::new("AAAA。BBBB。CCCC。").with_metadata("source", "test");
let chunks = s.split_document(&doc).await;
assert!(chunks.len() >= 2);
for (i, c) in chunks.iter().enumerate() {
assert_eq!(c.metadata.get("chunk"), Some(&i.to_string()));
assert_eq!(c.metadata.get("source"), Some(&"test".to_string()));
assert!(c.id.is_none());
}
}
#[tokio::test]
async fn test_embedding_failure_fallback() {
let s = SemanticSplitter::new(FailingEmbeddings, 0.5, 1000);
let text = "句子一。句子二。句子三。";
let chunks = s.split_text(text).await;
assert_eq!(chunks, vec![text.to_string()]);
}
}