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
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

54 changes: 52 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ The workspace provides:
and Mistral;
- OpenAI, Cohere, Ollama, Voyage, cloud, no-op, and deterministic mock
embeddings;
- standalone document reranking through Voyage, with original-position results,
cancellation, bounded retries, and provider-reported usage;
- request caching, stream accumulation, normalized provider failures,
provider-neutral retry classification and `Retry-After` parsing;
- conservative context-window and vision-capability hints for raw model ids
Expand Down Expand Up @@ -52,7 +54,7 @@ assert_eq!(response.text(), "hello");
TinyAgents vendors this repository at `vendor/tinyinference` and re-exports the
public modules through its historical `tinyagents::harness::*` paths. New code
can depend on `tinyinference-llm` for language models,
`tinyinference-embeddings` for vector generation and retrieval,
`tinyinference-embeddings` for vector generation, retrieval, and reranking,
`tinyinference-local` for endpoint-only local inference,
`tinyinference-providers` for provider authentication and routing primitives,
`tinyinference-voice` for speech inference and streaming-audio mechanics,
Expand All @@ -62,6 +64,54 @@ standards and OpenRouter media transport,
download, resume), `tinyinference-decisions` for typed Jev and Sage decisions,
and `tinyinference-core` only for shared infrastructure.

### Voyage reranking

`tinyinference-embeddings` exports `Reranker`, `RerankRequest`,
`VoyageReranker`, and `VoyageRerankConfig`. Supply your Voyage API key explicitly:

```rust,no_run
use tinyinference_embeddings::{Reranker, RerankRequest, VoyageReranker};

# async fn example(api_key: String) -> tinyinference_embeddings::Result<()> {
let model = VoyageReranker::new(api_key)?;
let documents = vec!["Reset your password in Settings.".into(), "Shipping takes two days.".into()];
let mut request = RerankRequest::new("How do I reset my password?", documents);
request.top_k = Some(1);
let response = model.rerank(request).await?;
for result in response.results {
println!("original position: {}, score: {}", result.index, result.relevance_score);
}
# Ok(())
# }
```

Use candidate texts from any search system. Our `Retriever` does not preserve
source text: resolve its returned IDs against your own document storage first.
Missing text should fail before the request rather than silently removing a
candidate. Returned indices address the exact submitted list. Keep reranking
scores separate from `ScoredDoc.score`, which remains vector similarity.
The `rerank` module's compiled rustdoc shows this mapping and caller-owned fallback.

Defaults are `rerank-3`, a 30-second total deadline, up to three retries of
429/500/502/503/504 responses, 8 MiB serialized requests, and 2 MiB responses.
`VoyageRerankConfig` makes these configurable. Requests accept up to 1,000
documents; zero results or empty candidates require no provider call.
Blank queries/documents are rejected for nonempty requests. Provider token
limits still apply. Text shortening is disabled unless `request.truncate` is
explicitly enabled; large batches are never silently split or dropped.

Clone `request.cancellation` to cancel while waiting for pacing, a response,
or a retry. The deadline covers all attempts. Transport interruptions are not
replayed; the provider may still finish and bill an interrupted request.
Explicit status retries can repeat billable work; set `max_retries` to zero to
disable them. Missing usage is unknown, and reported usage covers only the
successful response. Decide in your app whether provider failure preserves
original search order; propagate cancellation rather than treating it as fallback.

Errors use `Error::Rerank(RerankError)` for provider, timeout, and response
failures; `Validation` and `Cancelled` retain their existing roles. Downstream
exhaustive matches on the crate's `Error` must add the new `Rerank` arm.

### Levanto Sage

Sage uses typed decisions. A missing verdict means Sage is unsure and should
Expand Down Expand Up @@ -114,7 +164,7 @@ crates/tinyinference-llm/
├── tool.rs model-visible tool schemas and call/delta shapes
└── usage/ normalized token accounting
crates/tinyinference-embeddings/
└── src/ embedding clients, vector store, and retriever
└── src/ embedding clients, rerank/, vector store, and retriever
crates/tinyinference-local/
└── src/ local endpoint probing, model selection, and inference
crates/tinyinference-providers/
Expand Down
5 changes: 5 additions & 0 deletions crates/tinyinference-embeddings/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,10 @@ tokio = { workspace = true }
tracing = { workspace = true }
url = { workspace = true }

[dev-dependencies]
futures = { workspace = true }
http = "1"
tokio = { workspace = true, features = ["test-util"] }

[lints]
workspace = true
3 changes: 3 additions & 0 deletions crates/tinyinference-embeddings/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,7 @@ pub enum Error {
/// The caller cancelled an embedding request.
#[error("embedding request cancelled")]
Cancelled,
/// Reranking failed at the provider boundary.
#[error(transparent)]
Rerank(#[from] crate::rerank::RerankError),
}
5 changes: 5 additions & 0 deletions crates/tinyinference-embeddings/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,15 @@ pub mod catalog;
mod error;
pub mod factory;
pub mod probe;
pub mod rerank;
pub mod served_models;
mod types;

pub use error::{Error, Result};
pub use rerank::{
RerankCancellation, RerankError, RerankRequest, RerankResponse, RerankResult, RerankUsage,
Reranker, VoyageRerankConfig, VoyageReranker,
};
pub use types::*;

use async_trait::async_trait;
Expand Down
131 changes: 131 additions & 0 deletions crates/tinyinference-embeddings/src/rerank/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
//! Provider-neutral reranking of candidate document texts.
//!
//! [`VoyageReranker`] accepts an explicit API key and defaults to `rerank-3`.
//! Candidates may come from any search system. Results address the original
//! text list; keep document IDs, metadata, and vector similarity scores with
//! the caller. Truncation is disabled unless explicitly requested.
//!
//! A total deadline includes pacing, HTTP requests, response reads, and retry
//! waits. Transport interruptions are not retried because the provider may
//! already have billed the request. Explicit temporary HTTP statuses may be
//! retried; configure zero retries to disable this. Reported usage covers only
//! the successful response and missing usage means unknown, never zero.
//!
//! # Rerank search hits using caller-owned source text
//!
//! [`crate::Retriever`] stores vectors and metadata, not document text. Resolve
//! texts by ID before sending any request; missing text should not silently
//! remove a candidate and change the index mapping.
//!
//! ```no_run
//! use std::collections::HashMap;
//! use tinyinference_embeddings::{
//! Error, Result, Retriever, Reranker, RerankRequest, ScoredDoc,
//! };
//!
//! async fn search(
//! retriever: &Retriever,
//! reranker: &dyn Reranker,
//! texts: &HashMap<String, String>,
//! query: &str,
//! ) -> Result<Vec<(ScoredDoc, Option<f64>)>> {
//! let hits = retriever.retrieve(query, 30).await?;
//! let documents = hits.iter().map(|hit| {
//! texts.get(&hit.id).cloned()
//! .ok_or_else(|| Error::Validation("missing source document".into()))
//! }).collect::<Result<Vec<_>>>()?;
//! let mut request = RerankRequest::new(query, documents);
//! request.top_k = Some(5);
//! match reranker.rerank(request).await {
//! Ok(response) => response.results.into_iter().map(|result| {
//! let hit = hits.get(result.index).cloned()
//! .ok_or_else(|| Error::Validation("invalid reranking index".into()))?;
//! Ok((hit, Some(result.relevance_score)))
//! }).collect(),
//! Err(Error::Cancelled) => Err(Error::Cancelled),
//! // This caller elects to retain search order on provider failure.
//! Err(Error::Rerank(_)) => Ok(hits.into_iter().take(5)
//! .map(|hit| (hit, None)).collect()),
//! Err(error) => Err(error),
//! }
//! }
//! ```

mod types;
mod voyage;

pub use types::{
RerankCancellation, RerankError, RerankRequest, RerankResponse, RerankResult, RerankUsage,
Reranker, VoyageRerankConfig,
};
pub use voyage::VoyageReranker;

fn validate_request(request: &RerankRequest) -> crate::Result<usize> {
let count = request.documents.len();
let top_k = request.top_k.unwrap_or(count).min(count);
if top_k == 0 {
return Ok(0);
}
if request.query.trim().is_empty() {
return Err(crate::Error::Validation(
"reranking query must not be blank".into(),
));
}
if let Some(index) = request
.documents
.iter()
.position(|text| text.trim().is_empty())
{
return Err(crate::Error::Validation(format!(
"reranking document at index {index} must not be blank"
)));
}
Ok(top_k)
}

fn validate_results(
response: &mut RerankResponse,
document_count: usize,
top_k: usize,
) -> crate::Result<()> {
if response.results.len() != top_k {
return Err(RerankError::InvalidResponse {
reason: "unexpected result count",
}
.into());
}
let mut seen = vec![false; document_count];
for result in &response.results {
let Some(already_seen) = seen.get_mut(result.index) else {
return Err(RerankError::InvalidResponse {
reason: "document index out of range",
}
.into());
};
if *already_seen {
return Err(RerankError::InvalidResponse {
reason: "duplicate document index",
}
.into());
}
*already_seen = true;
if !result.relevance_score.is_finite() {
return Err(RerankError::InvalidResponse {
reason: "non-finite relevance score",
}
.into());
}
}
response.results.sort_by(|a, b| {
if a.relevance_score == b.relevance_score {
a.index.cmp(&b.index)
} else {
b.relevance_score.total_cmp(&a.relevance_score)
}
});
Ok(())
}

#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;
Loading
Loading