use std::sync::Arc;
use async_trait::async_trait;
use derive_builder::Builder;
use swiftide_core::{ChunkerTransformer, indexing::IndexingStream, indexing::TextNode};
use text_splitter::{Characters, ChunkConfig, TextSplitter};
const DEFAULT_MAX_CHAR_SIZE: usize = 2056;
#[derive(Debug, Clone, Builder)]
#[builder(setter(strip_option))]
pub struct ChunkText {
#[builder(default)]
concurrency: Option<usize>,
#[builder(default = "DEFAULT_MAX_CHAR_SIZE")]
#[allow(dead_code)]
max_characters: usize,
#[builder(default = "0..DEFAULT_MAX_CHAR_SIZE")]
range: std::ops::Range<usize>,
#[builder(setter(into), default = "self.default_client()")]
chunker: Arc<TextSplitter<Characters>>,
}
impl Default for ChunkText {
fn default() -> Self {
Self::from_max_characters(DEFAULT_MAX_CHAR_SIZE)
}
}
impl ChunkText {
pub fn builder() -> ChunkTextBuilder {
ChunkTextBuilder::default()
}
#[allow(clippy::missing_panics_doc)]
pub fn from_max_characters(max_characters: usize) -> Self {
Self::builder()
.max_characters(max_characters)
.build()
.expect("Cannot fail")
}
#[allow(clippy::missing_panics_doc)]
pub fn from_chunk_range(range: std::ops::Range<usize>) -> Self {
Self::builder().range(range).build().expect("Cannot fail")
}
#[must_use]
pub fn with_concurrency(mut self, concurrency: usize) -> Self {
self.concurrency = Some(concurrency);
self
}
fn min_size(&self) -> usize {
self.range.start
}
}
impl ChunkTextBuilder {
fn default_client(&self) -> Arc<TextSplitter<Characters>> {
let chunk_config: ChunkConfig<Characters> = self
.range
.clone()
.map(ChunkConfig::<Characters>::from)
.or_else(|| self.max_characters.map(Into::into))
.unwrap_or(DEFAULT_MAX_CHAR_SIZE.into());
Arc::new(TextSplitter::new(chunk_config))
}
}
#[async_trait]
impl ChunkerTransformer for ChunkText {
type Input = String;
type Output = String;
#[tracing::instrument(skip_all, name = "transformers.chunk_text")]
async fn transform_node(&self, node: TextNode) -> IndexingStream<String> {
let chunks = self
.chunker
.chunks(&node.chunk)
.filter_map(|chunk| {
let trim = chunk.trim();
if trim.is_empty() || trim.len() < self.min_size() {
None
} else {
Some(chunk.to_string())
}
})
.collect::<Vec<String>>();
IndexingStream::iter(node.into_chunks(chunks).map(Ok))
}
fn concurrency(&self) -> Option<usize> {
self.concurrency
}
}
#[cfg(test)]
mod test {
use super::*;
use futures_util::stream::TryStreamExt;
const TEXT: &str = r"
This is a text.
This is a paragraph.
This is another paragraph.
";
#[tokio::test]
async fn test_transforming_with_max_characters_and_trimming() {
let chunker = ChunkText::from_max_characters(40);
let node = TextNode::new(TEXT.to_string());
let nodes: Vec<TextNode> = chunker
.transform_node(node)
.await
.try_collect()
.await
.unwrap();
for line in TEXT.lines().filter(|line| !line.trim().is_empty()) {
assert!(nodes.iter().any(|node| node.chunk == line.trim()));
}
assert_eq!(nodes.len(), 3);
}
#[tokio::test]
async fn test_always_within_range() {
let ranges = vec![(10..15), (20..25), (30..35), (40..45), (50..55)];
for range in ranges {
let chunker = ChunkText::from_chunk_range(range.clone());
let node = TextNode::new(TEXT.to_string());
let nodes: Vec<TextNode> = chunker
.transform_node(node)
.await
.try_collect()
.await
.unwrap();
assert!(
nodes.iter().all(|node| {
let len = node.chunk.len();
range.contains(&len)
}),
"{:?}, {:?}",
range,
nodes.iter().filter(|node| {
let len = node.chunk.len();
!range.contains(&len)
})
);
}
}
#[test]
fn test_builder() {
ChunkText::builder()
.chunker(text_splitter::TextSplitter::new(40))
.concurrency(10)
.range(10..20)
.build()
.unwrap();
}
mod regression {
use std::collections::HashMap;
use futures_util::stream::TryStreamExt;
use swiftide_core::ChunkerTransformer;
use swiftide_core::SparseEmbedding;
use swiftide_core::indexing::{EmbedMode, EmbeddedField, Metadata, TextNode};
use super::ChunkText;
fn metadata() -> Metadata {
Metadata::from([("language", "日本語"), ("kind", "regression")])
}
fn dense_vectors() -> HashMap<EmbeddedField, Vec<f32>> {
HashMap::from([
(EmbeddedField::Chunk, vec![1.0, 2.0, 3.0]),
(EmbeddedField::Metadata("kind".into()), vec![4.0, 5.0]),
])
}
fn sparse_vectors() -> HashMap<EmbeddedField, SparseEmbedding> {
HashMap::from([(
EmbeddedField::Chunk,
SparseEmbedding {
indices: vec![2, 9],
values: vec![0.25, 0.75],
},
)])
}
fn assert_preserved(node: &TextNode, output: &TextNode, parent: Option<uuid::Uuid>) {
assert_eq!(output.path, node.path);
assert_eq!(output.metadata, node.metadata);
assert_eq!(output.vectors, node.vectors);
assert_eq!(output.sparse_vectors, node.sparse_vectors);
assert_eq!(output.embed_mode, node.embed_mode);
assert_eq!(output.original_size, node.original_size);
assert_eq!(output.offset, node.offset);
assert_eq!(output.parent_id, parent);
assert!(!output.chunk.is_empty());
}
#[tokio::test]
async fn preserves_full_node_fields_and_assigns_absent_parent() {
let source = "αβγ 日本語 metadata-preservation payload ".repeat(8);
let node = TextNode::builder()
.path("fixtures/unicode.txt")
.chunk(source)
.metadata(metadata())
.vectors(dense_vectors())
.sparse_vectors(sparse_vectors())
.embed_mode(EmbedMode::Both)
.original_size(4096usize)
.offset(37usize)
.build()
.unwrap();
let original_id = node.id();
let outputs: Vec<TextNode> = ChunkText::from_chunk_range(1..32)
.transform_node(node.clone())
.await
.try_collect()
.await
.unwrap();
assert!(outputs.len() > 1);
for output in &outputs {
assert_preserved(&node, output, Some(original_id));
}
}
#[tokio::test]
async fn preserves_existing_parent_unicode_and_discards_blank_chunks() {
let parent = uuid::Uuid::new_v4();
let source = "\n\n αβγ 日本語 \n\n café \n\t";
let node = TextNode::builder()
.path("fixtures/blank-and-unicode.txt")
.chunk(source)
.metadata(metadata())
.vectors(dense_vectors())
.sparse_vectors(sparse_vectors())
.embed_mode(EmbedMode::Both)
.original_size(source.len())
.offset(11usize)
.parent_id(parent)
.build()
.unwrap();
let outputs: Vec<TextNode> = ChunkText::from_chunk_range(1..32)
.transform_node(node.clone())
.await
.try_collect()
.await
.unwrap();
assert!(!outputs.is_empty());
assert!(outputs.iter().all(|output| !output.chunk.trim().is_empty()));
assert!(outputs.iter().any(|output| output.chunk.contains("α")));
assert!(outputs.iter().any(|output| output.chunk.contains("日本語")));
assert!(outputs.iter().any(|output| output.chunk.contains("café")));
for output in &outputs {
assert_preserved(&node, output, Some(parent));
}
let blank = TextNode::new("\n \t\n");
let blank_outputs: Vec<TextNode> = ChunkText::from_chunk_range(1..32)
.transform_node(blank)
.await
.try_collect()
.await
.unwrap();
assert!(blank_outputs.is_empty());
}
}
}