use std::{cmp::max, ops::Range};
use futures::{StreamExt, stream};
use crate::{
completion::Usage,
embeddings::{
Embed, EmbedError, Embedding, EmbeddingError, EmbeddingModel, EmbeddingResponse,
embed::TextEmbedder,
},
};
pub struct EmbeddingsBuilder<M, T>
where
M: EmbeddingModel,
T: Embed,
{
model: M,
documents: Vec<(T, Vec<String>)>,
}
impl<M, T> EmbeddingsBuilder<M, T>
where
M: EmbeddingModel,
T: Embed,
{
pub fn new(model: M) -> Self {
Self {
model,
documents: vec![],
}
}
pub fn document(mut self, document: T) -> Result<Self, EmbedError> {
let mut embedder = TextEmbedder::default();
document.embed(&mut embedder)?;
self.documents.push((document, embedder.texts));
Ok(self)
}
pub fn documents(self, documents: impl IntoIterator<Item = T>) -> Result<Self, EmbedError> {
let builder = documents
.into_iter()
.try_fold(self, |builder, doc| builder.document(doc))?;
Ok(builder)
}
}
impl<M, T> EmbeddingsBuilder<M, T>
where
M: EmbeddingModel,
T: Embed + Send,
{
pub async fn build(self) -> Result<Vec<(T, Vec<Embedding>)>, EmbeddingError> {
let (result, _usage) = self.build_with_usage().await?;
Ok(result)
}
pub async fn build_with_usage(
self,
) -> Result<(Vec<(T, Vec<Embedding>)>, Usage), EmbeddingError> {
use stream::TryStreamExt;
let mut docs: Vec<T> = Vec::with_capacity(self.documents.len());
let mut spans: Vec<Range<usize>> = Vec::with_capacity(self.documents.len());
let mut texts: Vec<String> = Vec::new();
for (doc, doc_texts) in self.documents {
let start = texts.len();
texts.extend(doc_texts);
spans.push(start..texts.len());
docs.push(doc);
}
let total_texts = texts.len();
let (slots, usage) = stream::iter(texts.into_iter().enumerate())
.chunks(M::MAX_DOCUMENTS)
.map(|chunk| async {
let (slots, batch): (Vec<usize>, Vec<String>) = chunk.into_iter().unzip();
let response: EmbeddingResponse = self.model.embed_texts_with_usage(batch).await?;
Ok::<_, EmbeddingError>((
slots
.into_iter()
.zip(response.embeddings)
.collect::<Vec<_>>(),
response.usage,
))
})
.buffer_unordered(max(1, 1024 / M::MAX_DOCUMENTS))
.try_fold(
(
(0..total_texts)
.map(|_| None)
.collect::<Vec<Option<Embedding>>>(),
Usage::default(),
),
|(mut slots, mut usage_acc), (chunk_embeddings, chunk_usage)| async move {
for (slot, embedding) in chunk_embeddings {
if let Some(place) = slots.get_mut(slot) {
*place = Some(embedding);
}
}
usage_acc += chunk_usage;
Ok((slots, usage_acc))
},
)
.await?;
let mut slots = slots.into_iter();
let mut result = Vec::with_capacity(docs.len());
for (index, (doc, span)) in docs.into_iter().zip(spans).enumerate() {
if span.is_empty() {
return Err(crate::embeddings::EmbeddingError::ResponseError(format!(
"document {index} produced no text to embed, so it has no \
embeddings to return; an empty collection in an `#[embed]` \
field embeds nothing"
)));
}
let embeddings = slots
.by_ref()
.take(span.len())
.collect::<Option<Vec<Embedding>>>()
.ok_or_else(|| {
crate::embeddings::EmbeddingError::ResponseError(format!(
"provider returned fewer embeddings than texts sent: \
document {index} is missing at least one of its {} texts \
(slots {}..{} of {total_texts})",
span.len(),
span.start,
span.end
))
})?;
result.push((doc, embeddings));
}
Ok((result, usage))
}
}
#[cfg(test)]
mod tests {
use crate::embeddings::embed::{EmbedError, TextEmbedder};
use crate::embeddings::{Embed, Embedding, EmbeddingError, EmbeddingModel};
use crate::test_utils::{MockEmbeddingModel, MockMultiTextDocument, MockTextDocument};
use super::EmbeddingsBuilder;
fn definitions_multiple_text() -> Vec<MockMultiTextDocument> {
vec![
MockMultiTextDocument::new(
"doc0",
[
"A green alien that lives on cold planets.",
"A fictional digital currency that originated in the animated series Rick and Morty.",
],
),
MockMultiTextDocument::new(
"doc1",
[
"An ancient tool used by the ancestors of the inhabitants of planet Jiro to farm the land.",
"A fictional creature found in the distant, swampy marshlands of the planet Glibbo in the Andromeda galaxy.",
],
),
]
}
fn definitions_multiple_text_2() -> Vec<MockMultiTextDocument> {
vec![
MockMultiTextDocument::new("doc2", ["Another fake definitions"]),
MockMultiTextDocument::new("doc3", ["Some fake definition"]),
]
}
fn definitions_single_text() -> Vec<MockTextDocument> {
vec![
MockTextDocument::new("doc0", "A green alien that lives on cold planets."),
MockTextDocument::new(
"doc1",
"An ancient tool used by the ancestors of the inhabitants of planet Jiro to farm the land.",
),
]
}
#[tokio::test]
async fn test_build_multiple_text() {
let fake_definitions = definitions_multiple_text();
let fake_model = MockEmbeddingModel;
let result = EmbeddingsBuilder::new(fake_model)
.documents(fake_definitions)
.unwrap()
.build()
.await
.unwrap();
assert_eq!(result.len(), 2);
let first_definition = &result[0];
assert_eq!(first_definition.0.id, "doc0");
assert_eq!(first_definition.1.len(), 2);
assert_eq!(
first_definition.1.first().map(|e| e.document.as_str()),
Some("A green alien that lives on cold planets.")
);
let second_definition = &result[1];
assert_eq!(second_definition.0.id, "doc1");
assert_eq!(second_definition.1.len(), 2);
assert_eq!(
second_definition.1.get(1).map(|e| e.document.as_str()),
Some(
"A fictional creature found in the distant, swampy marshlands of the planet Glibbo in the Andromeda galaxy."
)
)
}
#[tokio::test]
async fn test_build_single_text() {
let fake_definitions = definitions_single_text();
let fake_model = MockEmbeddingModel;
let result = EmbeddingsBuilder::new(fake_model)
.documents(fake_definitions)
.unwrap()
.build()
.await
.unwrap();
assert_eq!(result.len(), 2);
let first_definition = &result[0];
assert_eq!(first_definition.0.id, "doc0");
assert_eq!(first_definition.1.len(), 1);
assert_eq!(
first_definition.1.first().map(|e| e.document.as_str()),
Some("A green alien that lives on cold planets.")
);
let second_definition = &result[1];
assert_eq!(second_definition.0.id, "doc1");
assert_eq!(second_definition.1.len(), 1);
assert_eq!(
second_definition.1.first().map(|e| e.document.as_str()),
Some(
"An ancient tool used by the ancestors of the inhabitants of planet Jiro to farm the land."
)
)
}
#[tokio::test]
async fn test_build_multiple_and_single_text() {
let fake_definitions = definitions_multiple_text();
let fake_definitions_single = definitions_multiple_text_2();
let fake_model = MockEmbeddingModel;
let result = EmbeddingsBuilder::new(fake_model)
.documents(fake_definitions)
.unwrap()
.documents(fake_definitions_single)
.unwrap()
.build()
.await
.unwrap();
assert_eq!(result.len(), 4);
let second_definition = &result[1];
assert_eq!(second_definition.0.id, "doc1");
assert_eq!(second_definition.1.len(), 2);
assert_eq!(
second_definition.1.first().map(|e| e.document.as_str()),
Some(
"An ancient tool used by the ancestors of the inhabitants of planet Jiro to farm the land."
)
);
let third_definition = &result[2];
assert_eq!(third_definition.0.id, "doc2");
assert_eq!(third_definition.1.len(), 1);
assert_eq!(
third_definition.1.first().map(|e| e.document.as_str()),
Some("Another fake definitions")
)
}
#[tokio::test]
async fn test_build_string() {
let bindings = definitions_multiple_text();
let fake_definitions = bindings.iter().map(|def| def.texts.clone());
let fake_model = MockEmbeddingModel;
let result = EmbeddingsBuilder::new(fake_model)
.documents(fake_definitions)
.unwrap()
.build()
.await
.unwrap();
assert_eq!(result.len(), 2);
let first_definition = &result[0];
assert_eq!(first_definition.1.len(), 2);
assert_eq!(
first_definition.1.first().map(|e| e.document.as_str()),
Some("A green alien that lives on cold planets.")
);
let second_definition = &result[1];
assert_eq!(second_definition.1.len(), 2);
assert_eq!(
second_definition.1.get(1).map(|e| e.document.as_str()),
Some(
"A fictional creature found in the distant, swampy marshlands of the planet Glibbo in the Andromeda galaxy."
)
)
}
#[tokio::test]
async fn test_build_preserves_input_order_across_batches() {
let texts: Vec<String> = (0..12).map(|i| format!("text-{i:02}")).collect();
let fake_model = MockEmbeddingModel;
let result = EmbeddingsBuilder::new(fake_model)
.documents(texts.clone())
.unwrap()
.build()
.await
.unwrap();
assert_eq!(result.len(), texts.len());
for (i, (doc, embeddings)) in result.into_iter().enumerate() {
assert_eq!(doc, texts[i]);
assert_eq!(embeddings.len(), 1);
assert_eq!(embeddings[0].document, texts[i]);
}
}
#[derive(Clone)]
struct SlowFirstBatchModel {
calls: std::sync::Arc<std::sync::atomic::AtomicUsize>,
}
impl SlowFirstBatchModel {
fn new() -> Self {
Self {
calls: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
}
}
}
impl EmbeddingModel for SlowFirstBatchModel {
const MAX_DOCUMENTS: usize = 5;
type Client = crate::client::Nothing;
fn make(_: &Self::Client, _: impl Into<String>, _: Option<usize>) -> Self {
Self::new()
}
fn ndims(&self) -> usize {
10
}
async fn embed_texts(
&self,
documents: impl IntoIterator<Item = String> + crate::wasm_compat::WasmCompatSend,
) -> Result<Vec<Embedding>, EmbeddingError> {
let nth = self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if nth == 0 {
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
}
Ok(documents
.into_iter()
.map(|document| Embedding {
document,
vec: vec![0.0; 10],
})
.collect())
}
}
#[derive(Clone)]
struct DescendingLatencyModel {
batches: std::sync::Arc<std::sync::atomic::AtomicUsize>,
}
impl DescendingLatencyModel {
fn new() -> Self {
Self {
batches: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
}
}
}
impl EmbeddingModel for DescendingLatencyModel {
const MAX_DOCUMENTS: usize = 5;
type Client = crate::client::Nothing;
fn make(_: &Self::Client, _: impl Into<String>, _: Option<usize>) -> Self {
Self::new()
}
fn ndims(&self) -> usize {
10
}
async fn embed_texts(
&self,
documents: impl IntoIterator<Item = String> + crate::wasm_compat::WasmCompatSend,
) -> Result<Vec<Embedding>, EmbeddingError> {
let nth = self
.batches
.fetch_add(1, std::sync::atomic::Ordering::SeqCst) as u64;
tokio::time::sleep(std::time::Duration::from_millis(
120u64.saturating_sub(nth * 40),
))
.await;
Ok(documents
.into_iter()
.map(|document| Embedding {
document,
vec: vec![0.0; 10],
})
.collect())
}
}
#[derive(Debug)]
struct NTexts {
doc: usize,
n: usize,
}
impl NTexts {
fn new(doc: usize, n: usize) -> Self {
Self { doc, n }
}
fn expected(&self) -> Vec<String> {
(0..self.n).map(|i| format!("d{}t{i}", self.doc)).collect()
}
}
impl Embed for NTexts {
fn embed(&self, embedder: &mut TextEmbedder) -> Result<(), EmbedError> {
for i in 0..self.n {
embedder.embed(format!("d{}t{i}", self.doc));
}
Ok(())
}
}
fn returned(embeddings: &[Embedding]) -> Vec<String> {
embeddings
.iter()
.map(|embedding| embedding.document.clone())
.collect()
}
#[tokio::test]
async fn test_build_preserves_text_order_within_a_straddling_document() {
let doc = NTexts::new(0, 6);
let expected = doc.expected();
let result = EmbeddingsBuilder::new(SlowFirstBatchModel::new())
.document(doc)
.unwrap()
.build()
.await
.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(returned(&result[0].1), expected);
}
#[tokio::test]
async fn test_build_preserves_text_order_across_many_straddling_documents() {
let docs: Vec<NTexts> = (0..4).map(|doc| NTexts::new(doc, 3)).collect();
let expected: Vec<Vec<String>> = docs.iter().map(NTexts::expected).collect();
let result = EmbeddingsBuilder::new(DescendingLatencyModel::new())
.documents(docs)
.unwrap()
.build()
.await
.unwrap();
assert_eq!(result.len(), 4);
for (index, (_, embeddings)) in result.iter().enumerate() {
assert_eq!(
returned(embeddings),
expected[index],
"document {index} did not get its own texts, in order"
);
}
}
#[tokio::test]
async fn test_build_rejects_a_document_that_embeds_no_text() {
let error = EmbeddingsBuilder::new(MockEmbeddingModel)
.document(NTexts::new(0, 0))
.unwrap()
.build()
.await
.expect_err("a document with no texts has no embeddings");
assert!(
matches!(error, EmbeddingError::ResponseError(_)),
"unexpected error variant: {error:?}"
);
assert!(
error.to_string().contains("document 0 produced no text"),
"error should name the offending document: {error}"
);
}
#[tokio::test]
async fn test_build_names_the_document_that_embeds_no_text() {
let error = EmbeddingsBuilder::new(MockEmbeddingModel)
.documents(vec![
NTexts::new(0, 2),
NTexts::new(1, 2),
NTexts::new(2, 0),
])
.unwrap()
.build()
.await
.expect_err("a document with no texts has no embeddings");
assert!(
error.to_string().contains("document 2 produced no text"),
"error should name document 2: {error}"
);
}
#[derive(Clone, Default)]
struct OneAtATimeReversedLatency;
impl EmbeddingModel for OneAtATimeReversedLatency {
const MAX_DOCUMENTS: usize = 1;
type Client = crate::client::Nothing;
fn make(_: &Self::Client, _: impl Into<String>, _: Option<usize>) -> Self {
Self
}
fn ndims(&self) -> usize {
10
}
async fn embed_texts(
&self,
documents: impl IntoIterator<Item = String> + crate::wasm_compat::WasmCompatSend,
) -> Result<Vec<Embedding>, EmbeddingError> {
let documents: Vec<String> = documents.into_iter().collect();
let position = documents
.first()
.and_then(|text| text.rsplit_once('t'))
.and_then(|(_, n)| n.parse::<u64>().ok());
assert!(
position.is_some(),
"could not read a text position out of {documents:?}; \
this mock cannot invert completion order without it"
);
let delay = position.map_or(0, |n| 60u64.saturating_sub(n * 10));
tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
Ok(documents
.into_iter()
.map(|document| Embedding {
document,
vec: vec![0.0; 10],
})
.collect())
}
}
#[tokio::test]
async fn test_build_order_survives_one_text_per_batch_finishing_backwards() {
let doc = NTexts::new(0, 6);
let expected = doc.expected();
let result = EmbeddingsBuilder::new(OneAtATimeReversedLatency)
.document(doc)
.unwrap()
.build()
.await
.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(returned(&result[0].1), expected);
}
#[tokio::test]
async fn test_build_order_when_documents_tile_the_batch_size_exactly() {
let docs: Vec<NTexts> = (0..3).map(|doc| NTexts::new(doc, 5)).collect();
let expected: Vec<Vec<String>> = docs.iter().map(NTexts::expected).collect();
let result = EmbeddingsBuilder::new(SlowFirstBatchModel::new())
.documents(docs)
.unwrap()
.build()
.await
.unwrap();
assert_eq!(result.len(), 3);
for (index, (_, embeddings)) in result.iter().enumerate() {
assert_eq!(
returned(embeddings),
expected[index],
"document {index} did not get its own run"
);
}
}
}