benchmarks: ready for hybrid revised

This commit is contained in:
Per Stark
2025-12-03 11:38:07 +01:00
parent 2939e4c2a4
commit dd881efbf9
22 changed files with 760 additions and 476 deletions

176
Cargo.lock generated
View File

@@ -1929,13 +1929,22 @@ dependencies = [
"subtle",
]
[[package]]
name = "dirs"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
dependencies = [
"dirs-sys 0.4.1",
]
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys",
"dirs-sys 0.5.0",
]
[[package]]
@@ -1948,6 +1957,18 @@ dependencies = [
"dirs-sys-next",
]
[[package]]
name = "dirs-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
dependencies = [
"libc",
"option-ext",
"redox_users 0.4.6",
"windows-sys 0.48.0",
]
[[package]]
name = "dirs-sys"
version = "0.5.0"
@@ -2159,6 +2180,9 @@ name = "esaxx-rs"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6"
dependencies = [
"cc",
]
[[package]]
name = "euclid"
@@ -2198,6 +2222,7 @@ dependencies = [
"tokio",
"tracing",
"tracing-subscriber",
"unicode-normalization",
"uuid",
]
@@ -2278,12 +2303,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2e9bf3ea201e5d338450555088e02cff23b00be92bead3eff7ed341c68f5ac6"
dependencies = [
"anyhow",
"hf-hub",
"hf-hub 0.4.3",
"image",
"ndarray 0.16.1",
"ort",
"serde_json",
"tokenizers",
"tokenizers 0.22.1",
]
[[package]]
@@ -2808,13 +2833,30 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hf-hub"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b780635574b3d92f036890d8373433d6f9fc7abb320ee42a5c25897fc8ed732"
dependencies = [
"dirs 5.0.1",
"indicatif",
"log",
"native-tls",
"rand 0.8.5",
"serde",
"serde_json",
"thiserror 1.0.69",
"ureq",
]
[[package]]
name = "hf-hub"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "629d8f3bbeda9d148036d6b0de0a3ab947abd08ce90626327fc3547a49d59d97"
dependencies = [
"dirs",
"dirs 6.0.0",
"http",
"indicatif",
"libc",
@@ -3337,6 +3379,7 @@ dependencies = [
"surrealdb",
"tempfile",
"text-splitter",
"tokenizers 0.20.4",
"tokio",
"tracing",
"url",
@@ -4986,6 +5029,17 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "pulldown-cmark"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14"
dependencies = [
"bitflags 2.9.0",
"memchr",
"unicase",
]
[[package]]
name = "pxfm"
version = "0.1.25"
@@ -5251,6 +5305,17 @@ dependencies = [
"rayon-core",
]
[[package]]
name = "rayon-cond"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "059f538b55efd2309c9794130bc149c6a553db90e9d99c2030785c82f0bd7df9"
dependencies = [
"either",
"itertools 0.11.0",
"rayon",
]
[[package]]
name = "rayon-cond"
version = "0.4.0"
@@ -6746,9 +6811,11 @@ dependencies = [
"either",
"itertools 0.13.0",
"once_cell",
"pulldown-cmark",
"regex",
"strum",
"thiserror 1.0.69",
"tokenizers 0.20.4",
"unicode-segmentation",
]
@@ -6908,6 +6975,39 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokenizers"
version = "0.20.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b08cc37428a476fc9e20ac850132a513a2e1ce32b6a31addf2b74fa7033b905"
dependencies = [
"aho-corasick",
"derive_builder",
"esaxx-rs",
"getrandom 0.2.16",
"hf-hub 0.3.2",
"indicatif",
"itertools 0.12.1",
"lazy_static",
"log",
"macro_rules_attribute",
"monostate",
"onig",
"paste",
"rand 0.8.5",
"rayon",
"rayon-cond 0.3.0",
"regex",
"regex-syntax 0.8.5",
"serde",
"serde_json",
"spm_precompiled",
"thiserror 1.0.69",
"unicode-normalization-alignments",
"unicode-segmentation",
"unicode_categories",
]
[[package]]
name = "tokenizers"
version = "0.22.1"
@@ -6929,7 +7029,7 @@ dependencies = [
"paste",
"rand 0.9.1",
"rayon",
"rayon-cond",
"rayon-cond 0.4.0",
"regex",
"regex-syntax 0.8.5",
"serde",
@@ -7919,6 +8019,15 @@ dependencies = [
"windows-link 0.1.1",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
@@ -7946,6 +8055,21 @@ dependencies = [
"windows-targets 0.53.5",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
@@ -7979,6 +8103,12 @@ dependencies = [
"windows_x86_64_msvc 0.53.0",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
@@ -7991,6 +8121,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
@@ -8003,6 +8139,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@@ -8027,6 +8169,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
@@ -8039,6 +8187,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
@@ -8051,6 +8205,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
@@ -8063,6 +8223,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"

View File

@@ -42,7 +42,9 @@ sha2 = "0.10.8"
surrealdb-migrations = "2.2.2"
surrealdb = { version = "2", features = ["kv-mem"] }
tempfile = "3.12.0"
text-splitter = "0.18.1"
text-splitter = { version = "0.18.1", features = ["markdown", "tokenizers"] }
tokenizers = { version = "0.20.4", features = ["http"] }
unicode-normalization = "0.1.24"
thiserror = "1.0.63"
tokio-util = { version = "0.7.15", features = ["io"] }
tokio = { version = "1", features = ["full"] }

View File

@@ -1,6 +1,7 @@
use std::time::Duration;
use anyhow::{Context, Result};
use futures::future::try_join_all;
use serde::Deserialize;
use serde_json::Value;
use tracing::{info, warn};
@@ -83,10 +84,11 @@ async fn ensure_runtime_indexes_inner(
) -> Result<()> {
create_fts_analyzer(db).await?;
for spec in fts_index_specs() {
let fts_tasks = fts_index_specs().into_iter().map(|spec| async move {
if index_exists(db, spec.table, spec.index_name).await? {
continue;
return Ok(());
}
create_index_with_polling(
db,
spec.definition(),
@@ -94,53 +96,52 @@ async fn ensure_runtime_indexes_inner(
spec.table,
Some(spec.table),
)
.await?;
}
.await
});
for spec in hnsw_index_specs() {
ensure_hnsw_index(db, &spec, embedding_dimension).await?;
}
let hnsw_tasks = hnsw_index_specs()
.into_iter()
.map(|spec| async move {
match hnsw_index_state(db, &spec, embedding_dimension).await? {
HnswIndexState::Missing => {
create_index_with_polling(
db,
spec.definition_if_not_exists(embedding_dimension),
spec.index_name,
spec.table,
Some(spec.table),
)
.await
}
HnswIndexState::Matches => Ok(()),
HnswIndexState::Different(existing) => {
info!(
index = spec.index_name,
table = spec.table,
existing_dimension = existing,
target_dimension = embedding_dimension,
"Overwriting HNSW index to match new embedding dimension"
);
create_index_with_polling(
db,
spec.definition_overwrite(embedding_dimension),
spec.index_name,
spec.table,
Some(spec.table),
)
.await
}
}
});
futures::try_join!(
async { try_join_all(fts_tasks).await.map(|_| ()) },
async { try_join_all(hnsw_tasks).await.map(|_| ()) },
)?;
Ok(())
}
async fn ensure_hnsw_index(
db: &SurrealDbClient,
spec: &HnswIndexSpec,
dimension: usize,
) -> Result<()> {
match hnsw_index_state(db, spec, dimension).await? {
HnswIndexState::Missing => {
create_index_with_polling(
db,
spec.definition_if_not_exists(dimension),
spec.index_name,
spec.table,
Some(spec.table),
)
.await
}
HnswIndexState::Matches => Ok(()),
HnswIndexState::Different(existing) => {
info!(
index = spec.index_name,
table = spec.table,
existing_dimension = existing,
target_dimension = dimension,
"Overwriting HNSW index to match new embedding dimension"
);
create_index_with_polling(
db,
spec.definition_overwrite(dimension),
spec.index_name,
spec.table,
Some(spec.table),
)
.await
}
}
}
async fn hnsw_index_state(
db: &SurrealDbClient,
spec: &HnswIndexSpec,
@@ -265,9 +266,10 @@ async fn poll_index_build_status(
tokio::time::sleep(poll_every).await;
let info_query = format!("INFO FOR INDEX {index_name} ON TABLE {table};");
let mut info_res = db.client.query(info_query).await.with_context(|| {
format!("checking index build status for {index_name} on {table}")
})?;
let mut info_res =
db.client.query(info_query).await.with_context(|| {
format!("checking index build status for {index_name} on {table}")
})?;
let info: Option<Value> = info_res
.take(0)
@@ -461,7 +463,7 @@ const fn hnsw_index_specs() -> [HnswIndexSpec; 2] {
]
}
const fn fts_index_specs() -> [FtsIndexSpec; 9] {
const fn fts_index_specs() -> [FtsIndexSpec; 8] {
[
FtsIndexSpec {
index_name: "text_content_fts_idx",
@@ -470,13 +472,6 @@ const fn fts_index_specs() -> [FtsIndexSpec; 9] {
analyzer: Some(FTS_ANALYZER_NAME),
method: "BM25",
},
FtsIndexSpec {
index_name: "text_content_category_fts_idx",
table: "text_content",
field: "category",
analyzer: Some(FTS_ANALYZER_NAME),
method: "BM25",
},
FtsIndexSpec {
index_name: "text_content_context_fts_idx",
table: "text_content",

View File

@@ -18,6 +18,7 @@ tracing = { workspace = true }
tracing-subscriber = { workspace = true }
uuid = { workspace = true }
text-splitter = { workspace = true }
unicode-normalization = { workspace = true }
rand = "0.8"
sha2 = { workspace = true }
object_store = { workspace = true }

View File

@@ -52,14 +52,6 @@ impl std::fmt::Display for EmbeddingBackend {
#[derive(Debug, Clone, Args)]
pub struct RetrievalSettings {
/// Minimum characters per chunk for text splitting
#[arg(long, default_value_t = 500)]
pub chunk_min_chars: usize,
/// Maximum characters per chunk for text splitting
#[arg(long, default_value_t = 2000)]
pub chunk_max_chars: usize,
/// Override chunk vector candidate cap
#[arg(long)]
pub chunk_vector_take: Option<usize>,
@@ -68,10 +60,6 @@ pub struct RetrievalSettings {
#[arg(long)]
pub chunk_fts_take: Option<usize>,
/// Override chunk token budget estimate for assembly
#[arg(long)]
pub chunk_token_budget: Option<usize>,
/// Override average characters per token used for budgeting
#[arg(long)]
pub chunk_avg_chars_per_token: Option<usize>,
@@ -80,18 +68,22 @@ pub struct RetrievalSettings {
#[arg(long)]
pub max_chunks_per_entity: Option<usize>,
/// Disable the FastEmbed reranking stage
#[arg(long = "no-rerank", action = clap::ArgAction::SetFalse)]
/// Enable the FastEmbed reranking stage
#[arg(long = "rerank", action = clap::ArgAction::SetTrue, default_value_t = false)]
pub rerank: bool,
/// Reranking engine pool size / parallelism
#[arg(long, default_value_t = 16)]
#[arg(long, default_value_t = 4)]
pub rerank_pool_size: usize,
/// Keep top-N entities after reranking
#[arg(long, default_value_t = 10)]
pub rerank_keep_top: usize,
/// Cap the number of chunks returned by retrieval (revised strategy)
#[arg(long, default_value_t = 5)]
pub chunk_result_cap: usize,
/// Require verified chunks (disable with --llm-mode)
#[arg(skip = true)]
pub require_verified_chunks: bool,
@@ -104,16 +96,14 @@ pub struct RetrievalSettings {
impl Default for RetrievalSettings {
fn default() -> Self {
Self {
chunk_min_chars: 500,
chunk_max_chars: 2_000,
chunk_vector_take: None,
chunk_fts_take: None,
chunk_token_budget: None,
chunk_avg_chars_per_token: None,
max_chunks_per_entity: None,
rerank: true,
rerank_pool_size: 16,
rerank: false,
rerank_pool_size: 4,
rerank_keep_top: 10,
chunk_result_cap: 5,
require_verified_chunks: true,
strategy: RetrievalStrategy::Initial,
}
@@ -175,7 +165,7 @@ pub struct Config {
pub retrieval: RetrievalSettings,
/// Concurrency level
#[arg(long, default_value_t = 4)]
#[arg(long, default_value_t = 1)]
pub concurrency: usize,
/// Embedding backend
@@ -195,19 +185,23 @@ pub struct Config {
pub ingestion_cache_dir: PathBuf,
/// Minimum tokens per chunk for ingestion
#[arg(long, default_value_t = 500)]
#[arg(long, default_value_t = 256)]
pub ingest_chunk_min_tokens: usize,
/// Maximum tokens per chunk for ingestion
#[arg(long, default_value_t = 2_000)]
#[arg(long, default_value_t = 512)]
pub ingest_chunk_max_tokens: usize,
/// Overlap between chunks during ingestion (tokens)
#[arg(long, default_value_t = 50)]
pub ingest_chunk_overlap_tokens: usize,
/// Run ingestion in chunk-only mode (skip analyzer/graph generation)
#[arg(long)]
pub ingest_chunks_only: bool,
/// Number of paragraphs to ingest concurrently
#[arg(long, default_value_t = 5)]
#[arg(long, default_value_t = 10)]
pub ingestion_batch_size: usize,
/// Maximum retries for ingestion failures per paragraph
@@ -354,15 +348,9 @@ impl Config {
}
// Validations
if self.retrieval.chunk_min_chars >= self.retrieval.chunk_max_chars {
return Err(anyhow!(
"--chunk-min must be less than --chunk-max (got {} >= {})",
self.retrieval.chunk_min_chars,
self.retrieval.chunk_max_chars
));
}
if self.ingest_chunk_min_tokens == 0 || self.ingest_chunk_min_tokens >= self.ingest_chunk_max_tokens {
if self.ingest_chunk_min_tokens == 0
|| self.ingest_chunk_min_tokens >= self.ingest_chunk_max_tokens
{
return Err(anyhow!(
"--ingest-chunk-min-tokens must be greater than zero and less than --ingest-chunk-max-tokens (got {} >= {})",
self.ingest_chunk_min_tokens,
@@ -370,6 +358,14 @@ impl Config {
));
}
if self.ingest_chunk_overlap_tokens >= self.ingest_chunk_min_tokens {
return Err(anyhow!(
"--ingest-chunk-overlap-tokens ({}) must be less than --ingest-chunk-min-tokens ({})",
self.ingest_chunk_overlap_tokens,
self.ingest_chunk_min_tokens
));
}
if self.retrieval.rerank && self.retrieval.rerank_pool_size == 0 {
return Err(anyhow!(
"--rerank-pool must be greater than zero when reranking is enabled"
@@ -444,9 +440,7 @@ pub struct ParsedArgs {
pub fn parse() -> Result<ParsedArgs> {
let mut config = Config::parse();
config.finalize()?;
Ok(ParsedArgs {
config,
})
Ok(ParsedArgs { config })
}
pub fn ensure_parent(path: &Path) -> Result<()> {

View File

@@ -15,7 +15,6 @@ use common::{
types::{system_settings::SystemSettings, user::User, StoredObject},
},
};
use retrieval_pipeline::RetrievalTuning;
use serde::Deserialize;
use tokio::io::AsyncWriteExt;
use tracing::{info, warn};
@@ -120,37 +119,6 @@ pub(crate) fn ledger_target(config: &Config) -> Option<usize> {
}
}
pub(crate) fn apply_dataset_tuning_overrides(
dataset: &ConvertedDataset,
config: &Config,
tuning: &mut RetrievalTuning,
) {
let is_long_form = dataset
.metadata
.id
.to_ascii_lowercase()
.contains("natural-questions");
if !is_long_form {
return;
}
if config.retrieval.chunk_vector_take.is_none() {
tuning.chunk_vector_take = tuning.chunk_vector_take.max(80);
}
if config.retrieval.chunk_fts_take.is_none() {
tuning.chunk_fts_take = tuning.chunk_fts_take.max(80);
}
if config.retrieval.chunk_token_budget.is_none() {
tuning.token_budget_estimate = tuning.token_budget_estimate.max(20_000);
}
if config.retrieval.max_chunks_per_entity.is_none() {
tuning.max_chunks_per_entity = tuning.max_chunks_per_entity.max(12);
}
if tuning.lexical_match_weight < 0.25 {
tuning.lexical_match_weight = 0.3;
}
}
pub(crate) async fn write_chunk_diagnostics(path: &Path, cases: &[CaseDiagnostics]) -> Result<()> {
args::ensure_parent(path)?;
let mut file = tokio::fs::File::create(path)
@@ -175,9 +143,10 @@ pub(crate) async fn warm_hnsw_cache(db: &SurrealDbClient, dimension: usize) -> R
let _ = db
.client
.query(
"SELECT chunk_id \
FROM text_chunk_embedding \
WHERE embedding <|1,1|> $embedding LIMIT 5",
r#"SELECT chunk_id
FROM text_chunk_embedding
WHERE embedding <|1,1|> $embedding
LIMIT 5"#,
)
.bind(("embedding", dummy_embedding.clone()))
.await
@@ -187,9 +156,10 @@ pub(crate) async fn warm_hnsw_cache(db: &SurrealDbClient, dimension: usize) -> R
let _ = db
.client
.query(
"SELECT entity_id \
FROM knowledge_entity_embedding \
WHERE embedding <|1,1|> $embedding LIMIT 5",
r#"SELECT entity_id
FROM knowledge_entity_embedding
WHERE embedding <|1,1|> $embedding
LIMIT 5"#,
)
.bind(("embedding", dummy_embedding))
.await
@@ -427,12 +397,10 @@ pub(crate) async fn enforce_system_settings(
) -> Result<SystemSettings> {
let mut updated_settings = settings.clone();
let mut needs_settings_update = false;
// let mut embedding_dimension_changed = false;
if provider_dimension != settings.embedding_dimensions as usize {
updated_settings.embedding_dimensions = provider_dimension as u32;
needs_settings_update = true;
// embedding_dimension_changed = true;
}
if let Some(query_override) = config.query_model.as_deref() {
if settings.query_model != query_override {
@@ -449,12 +417,6 @@ pub(crate) async fn enforce_system_settings(
.await
.context("updating system settings overrides")?;
}
// We dont need to do this, we've changed from default settings already
// if embedding_dimension_changed {
// change_embedding_length_in_hnsw_indexes(db, provider_dimension)
// .await
// .context("redefining HNSW indexes for new embedding dimension")?;
// }
Ok(settings)
}

View File

@@ -6,7 +6,7 @@ use futures::stream::{self, StreamExt};
use tracing::{debug, info};
use crate::eval::{
adapt_strategy_output, apply_dataset_tuning_overrides, build_case_diagnostics,
adapt_strategy_output, build_case_diagnostics,
text_contains_answer, CaseDiagnostics, CaseSummary, RetrievedSummary,
};
use retrieval_pipeline::{
@@ -56,15 +56,13 @@ pub(crate) async fn run_queries(
if retrieval_config.tuning.fallback_min_results < config.retrieval.rerank_keep_top {
retrieval_config.tuning.fallback_min_results = config.retrieval.rerank_keep_top;
}
retrieval_config.tuning.chunk_result_cap = config.retrieval.chunk_result_cap.max(1);
if let Some(value) = config.retrieval.chunk_vector_take {
retrieval_config.tuning.chunk_vector_take = value;
}
if let Some(value) = config.retrieval.chunk_fts_take {
retrieval_config.tuning.chunk_fts_take = value;
}
if let Some(value) = config.retrieval.chunk_token_budget {
retrieval_config.tuning.token_budget_estimate = value;
}
if let Some(value) = config.retrieval.chunk_avg_chars_per_token {
retrieval_config.tuning.avg_chars_per_token = value;
}
@@ -72,8 +70,6 @@ pub(crate) async fn run_queries(
retrieval_config.tuning.max_chunks_per_entity = value;
}
apply_dataset_tuning_overrides(dataset, config, &mut retrieval_config.tuning);
let active_tuning = retrieval_config.tuning.clone();
let effective_chunk_vector = config
.retrieval
@@ -95,11 +91,8 @@ pub(crate) async fn run_queries(
rerank_enabled = config.retrieval.rerank,
rerank_pool_size = config.retrieval.rerank_pool_size,
rerank_keep_top = config.retrieval.rerank_keep_top,
chunk_min = config.retrieval.chunk_min_chars,
chunk_max = config.retrieval.chunk_max_chars,
chunk_vector_take = effective_chunk_vector,
chunk_fts_take = effective_chunk_fts,
chunk_token_budget = active_tuning.token_budget_estimate,
embedding_backend = ctx.embedding_provider().backend_label(),
embedding_model = ctx
.embedding_provider()
@@ -405,4 +398,3 @@ fn calculate_ndcg(retrieved: &[RetrievedSummary], k: usize) -> f64 {
dcg / idcg
}
}

View File

@@ -201,9 +201,13 @@ pub(crate) async fn summarize(
concurrency: config.concurrency.max(1),
detailed_report: config.detailed_report,
retrieval_strategy: config.retrieval.strategy.to_string(),
chunk_result_cap: config.retrieval.chunk_result_cap,
ingest_chunk_min_tokens: config.ingest_chunk_min_tokens,
ingest_chunk_max_tokens: config.ingest_chunk_max_tokens,
ingest_chunks_only: config.ingest_chunks_only,
ingest_chunk_overlap_tokens: config.ingest_chunk_overlap_tokens,
chunk_vector_take: active_tuning.chunk_vector_take,
chunk_fts_take: active_tuning.chunk_fts_take,
chunk_token_budget: active_tuning.token_budget_estimate,
chunk_avg_chars_per_token: active_tuning.avg_chars_per_token,
max_chunks_per_entity: active_tuning.max_chunks_per_entity,
cases: summaries,

View File

@@ -6,6 +6,7 @@ use retrieval_pipeline::{
PipelineDiagnostics, PipelineStageTimings, RetrievedChunk, RetrievedEntity, StrategyOutput,
};
use serde::{Deserialize, Serialize};
use unicode_normalization::UnicodeNormalization;
#[derive(Debug, Serialize)]
pub struct EvaluationSummary {
@@ -68,9 +69,13 @@ pub struct EvaluationSummary {
pub concurrency: usize,
pub detailed_report: bool,
pub retrieval_strategy: String,
pub chunk_result_cap: usize,
pub ingest_chunk_min_tokens: usize,
pub ingest_chunk_max_tokens: usize,
pub ingest_chunks_only: bool,
pub ingest_chunk_overlap_tokens: usize,
pub chunk_vector_take: usize,
pub chunk_fts_take: usize,
pub chunk_token_budget: usize,
pub chunk_avg_chars_per_token: usize,
pub max_chunks_per_entity: usize,
pub cases: Vec<CaseSummary>,
@@ -107,7 +112,17 @@ pub struct LatencyStats {
pub p95: u128,
}
#[derive(Debug, Clone, Serialize)]
impl Default for LatencyStats {
fn default() -> Self {
Self {
avg: 0.0,
p50: 0,
p95: 0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct StageLatencyBreakdown {
pub embed: LatencyStats,
pub collect_candidates: LatencyStats,
@@ -117,7 +132,7 @@ pub struct StageLatencyBreakdown {
pub assemble: LatencyStats,
}
#[derive(Debug, Default, Clone, Serialize)]
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct EvaluationStageTimings {
pub prepare_slice_ms: u128,
pub prepare_db_ms: u128,
@@ -128,7 +143,7 @@ pub struct EvaluationStageTimings {
pub finalize_ms: u128,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PerformanceTimings {
pub openai_base_url: String,
pub ingestion_ms: u128,
@@ -254,8 +269,44 @@ pub fn text_contains_answer(text: &str, answers: &[String]) -> bool {
if answers.is_empty() {
return true;
}
let haystack = text.to_ascii_lowercase();
answers.iter().any(|needle| haystack.contains(needle))
let haystack = normalize_for_match(text);
answers
.iter()
.map(|needle| normalize_for_match(needle))
.any(|needle| !needle.is_empty() && haystack.contains(&needle))
}
fn normalize_for_match(input: &str) -> String {
// NFKC normalize, lowercase, and collapse whitespace/punctuation to a single space
// to reduce false negatives from formatting or punctuation differences.
let mut out = String::with_capacity(input.len());
let mut last_space = false;
for ch in input.nfkc().flat_map(|c| c.to_lowercase()) {
let is_space = ch.is_whitespace();
let is_punct = ch.is_ascii_punctuation()
|| matches!(
ch,
'“' | '”' | '' | '' | '«' | '»' | '' | '—' | '…' | '·' | '•'
);
if is_space || is_punct {
if !last_space {
out.push(' ');
last_space = true;
}
} else {
out.push(ch);
last_space = false;
}
}
let trimmed = out.trim();
if trimmed.is_empty() {
return String::new();
}
trimmed
.trim_matches(|c: char| c.is_ascii_punctuation() || c.is_whitespace())
.to_string()
}
fn chunk_snippet(text: &str) -> String {

View File

@@ -17,6 +17,7 @@ pub fn make_ingestion_config(config: &crate::args::Config) -> ingestion_pipeline
let mut tuning = ingestion_pipeline::IngestionTuning::default();
tuning.chunk_min_tokens = config.ingest_chunk_min_tokens;
tuning.chunk_max_tokens = config.ingest_chunk_max_tokens;
tuning.chunk_overlap_tokens = config.ingest_chunk_overlap_tokens;
ingestion_pipeline::IngestionConfig {
tuning,

View File

@@ -172,13 +172,14 @@ async fn async_main() -> anyhow::Result<()> {
.await
.context("running retrieval evaluation")?;
let report_paths = report::write_reports(
let report = report::write_reports(
&summary,
parsed.config.report_dir.as_path(),
parsed.config.summary_sample,
)
.with_context(|| format!("writing reports to {}", parsed.config.report_dir.display()))?;
let perf_log_path = perf::write_perf_logs(
let perf_mirrors = perf::mirror_perf_outputs(
&report.record,
&summary,
parsed.config.report_dir.as_path(),
parsed.config.perf_log_json.as_deref(),
@@ -186,14 +187,27 @@ async fn async_main() -> anyhow::Result<()> {
)
.with_context(|| {
format!(
"writing perf logs under {}",
"writing perf mirrors under {}",
parsed.config.report_dir.display()
)
})?;
let perf_note = if perf_mirrors.is_empty() {
String::new()
} else {
format!(
" | Perf mirrors: {}",
perf_mirrors
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(", ")
)
};
if summary.llm_cases > 0 {
println!(
"[{}] Retrieval Precision@{k}: {precision:.3} ({correct}/{retrieval_total}) + LLM: {llm_answered}/{llm_total} ({llm_precision:.3}) → JSON: {json} | Markdown: {md} | Perf: {perf}",
"[{}] Retrieval Precision@{k}: {precision:.3} ({correct}/{retrieval_total}) + LLM: {llm_answered}/{llm_total} ({llm_precision:.3}) → JSON: {json} | Markdown: {md} | History: {history}{perf_note}",
summary.dataset_label,
k = summary.k,
precision = summary.precision,
@@ -202,26 +216,28 @@ async fn async_main() -> anyhow::Result<()> {
llm_answered = summary.llm_answered,
llm_total = summary.llm_cases,
llm_precision = summary.llm_precision,
json = report_paths.json.display(),
md = report_paths.markdown.display(),
perf = perf_log_path.display()
json = report.paths.json.display(),
md = report.paths.markdown.display(),
history = report.history_path.display(),
perf_note = perf_note,
);
} else {
println!(
"[{}] Retrieval Precision@{k}: {precision:.3} ({correct}/{retrieval_total}) → JSON: {json} | Markdown: {md} | Perf: {perf}",
"[{}] Retrieval Precision@{k}: {precision:.3} ({correct}/{retrieval_total}) → JSON: {json} | Markdown: {md} | History: {history}{perf_note}",
summary.dataset_label,
k = summary.k,
precision = summary.precision,
correct = summary.correct,
retrieval_total = summary.retrieval_cases,
json = report_paths.json.display(),
md = report_paths.markdown.display(),
perf = perf_log_path.display()
json = report.paths.json.display(),
md = report.paths.markdown.display(),
history = report.history_path.display(),
perf_note = perf_note,
);
}
if parsed.config.perf_log_console {
perf::print_console_summary(&summary);
perf::print_console_summary(&report.record);
}
Ok(())

View File

@@ -1,160 +1,37 @@
use std::{
fs::{self, OpenOptions},
io::Write,
fs,
path::{Path, PathBuf},
};
use anyhow::{Context, Result};
use serde::Serialize;
use crate::{
args,
eval::{format_timestamp, EvaluationStageTimings, EvaluationSummary},
report,
eval::EvaluationSummary,
report::{self, EvaluationReport},
};
#[derive(Debug, Serialize)]
struct PerformanceLogEntry {
generated_at: String,
dataset_id: String,
dataset_label: String,
run_label: Option<String>,
retrieval_strategy: String,
slice_id: String,
slice_seed: u64,
slice_window_offset: usize,
slice_window_length: usize,
limit: Option<usize>,
total_cases: usize,
correct: usize,
precision: f64,
retrieval_cases: usize,
llm_cases: usize,
llm_answered: usize,
llm_precision: f64,
k: usize,
openai_base_url: String,
ingestion: IngestionPerf,
namespace: NamespacePerf,
retrieval: RetrievalPerf,
evaluation_stages: EvaluationStageTimings,
}
#[derive(Debug, Serialize)]
struct IngestionPerf {
duration_ms: u128,
cache_path: String,
reused: bool,
embeddings_reused: bool,
fingerprint: String,
positives_total: usize,
negatives_total: usize,
}
#[derive(Debug, Serialize)]
struct NamespacePerf {
reused: bool,
seed_ms: Option<u128>,
}
#[derive(Debug, Serialize)]
struct RetrievalPerf {
latency_ms: crate::eval::LatencyStats,
stage_latency: crate::eval::StageLatencyBreakdown,
concurrency: usize,
rerank_enabled: bool,
rerank_pool_size: Option<usize>,
rerank_keep_top: usize,
evaluated_cases: usize,
}
impl PerformanceLogEntry {
fn from_summary(summary: &EvaluationSummary) -> Self {
let ingestion = IngestionPerf {
duration_ms: summary.perf.ingestion_ms,
cache_path: summary.ingestion_cache_path.clone(),
reused: summary.ingestion_reused,
embeddings_reused: summary.ingestion_embeddings_reused,
fingerprint: summary.ingestion_fingerprint.clone(),
positives_total: summary.slice_positive_paragraphs,
negatives_total: summary.slice_negative_paragraphs,
};
let namespace = NamespacePerf {
reused: summary.namespace_reused,
seed_ms: summary.perf.namespace_seed_ms,
};
let retrieval = RetrievalPerf {
latency_ms: summary.latency_ms.clone(),
stage_latency: summary.perf.stage_latency.clone(),
concurrency: summary.concurrency,
rerank_enabled: summary.rerank_enabled,
rerank_pool_size: summary.rerank_pool_size,
rerank_keep_top: summary.rerank_keep_top,
evaluated_cases: summary.retrieval_cases,
};
Self {
generated_at: format_timestamp(&summary.generated_at),
dataset_id: summary.dataset_id.clone(),
dataset_label: summary.dataset_label.clone(),
run_label: summary.run_label.clone(),
retrieval_strategy: summary.retrieval_strategy.clone(),
slice_id: summary.slice_id.clone(),
slice_seed: summary.slice_seed,
slice_window_offset: summary.slice_window_offset,
slice_window_length: summary.slice_window_length,
limit: summary.limit,
total_cases: summary.total_cases,
correct: summary.correct,
precision: summary.precision,
retrieval_cases: summary.retrieval_cases,
llm_cases: summary.llm_cases,
llm_answered: summary.llm_answered,
llm_precision: summary.llm_precision,
k: summary.k,
openai_base_url: summary.perf.openai_base_url.clone(),
ingestion,
namespace,
retrieval,
evaluation_stages: summary.perf.evaluation_stage_ms.clone(),
}
}
}
pub fn write_perf_logs(
pub fn mirror_perf_outputs(
record: &EvaluationReport,
summary: &EvaluationSummary,
report_root: &Path,
extra_json: Option<&Path>,
extra_dir: Option<&Path>,
) -> Result<PathBuf> {
let entry = PerformanceLogEntry::from_summary(summary);
let dataset_dir = report::dataset_report_dir(report_root, &summary.dataset_id);
fs::create_dir_all(&dataset_dir)
.with_context(|| format!("creating dataset perf directory {}", dataset_dir.display()))?;
let log_path = dataset_dir.join("perf-log.jsonl");
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.with_context(|| format!("opening perf log {}", log_path.display()))?;
let line = serde_json::to_vec(&entry).context("serialising perf log entry")?;
file.write_all(&line)?;
file.write_all(b"\n")?;
file.flush()?;
) -> Result<Vec<PathBuf>> {
let mut written = Vec::new();
if let Some(path) = extra_json {
args::ensure_parent(path)?;
let blob = serde_json::to_vec_pretty(&entry).context("serialising perf log JSON")?;
let blob = serde_json::to_vec_pretty(record).context("serialising perf log JSON")?;
fs::write(path, blob)
.with_context(|| format!("writing perf log copy to {}", path.display()))?;
written.push(path.to_path_buf());
}
if let Some(dir) = extra_dir {
fs::create_dir_all(dir)
.with_context(|| format!("creating perf log directory {}", dir.display()))?;
let dataset_dir = report::dataset_report_dir(report_root, &summary.dataset_id);
let dataset_slug = dataset_dir
.file_name()
.and_then(|os| os.to_str())
@@ -162,22 +39,24 @@ pub fn write_perf_logs(
let timestamp = summary.generated_at.format("%Y%m%dT%H%M%S").to_string();
let filename = format!("perf-{}-{}.json", dataset_slug, timestamp);
let path = dir.join(filename);
let blob = serde_json::to_vec_pretty(&entry).context("serialising perf log JSON")?;
let blob = serde_json::to_vec_pretty(record).context("serialising perf log JSON")?;
fs::write(&path, blob)
.with_context(|| format!("writing perf log mirror {}", path.display()))?;
written.push(path);
}
Ok(log_path)
Ok(written)
}
pub fn print_console_summary(summary: &EvaluationSummary) {
let perf = &summary.perf;
pub fn print_console_summary(record: &EvaluationReport) {
let perf = &record.performance;
println!(
"[perf] retrieval strategy={} | rerank={} (pool {:?}, keep {})",
summary.retrieval_strategy,
summary.rerank_enabled,
summary.rerank_pool_size,
summary.rerank_keep_top
"[perf] retrieval strategy={} | concurrency={} | rerank={} (pool {:?}, keep {})",
record.retrieval.strategy,
record.retrieval.concurrency,
record.retrieval.rerank_enabled,
record.retrieval.rerank_pool_size,
record.retrieval.rerank_keep_top
);
println!(
"[perf] ingestion={}ms | namespace_seed={}",
@@ -194,7 +73,7 @@ pub fn print_console_summary(summary: &EvaluationSummary) {
stage.rerank.avg,
stage.assemble.avg,
);
let eval = &perf.evaluation_stage_ms;
let eval = &perf.evaluation_stages_ms;
println!(
"[perf] eval stage ms → slice {} | db {} | corpus {} | namespace {} | queries {} | summarize {} | finalize {}",
eval.prepare_slice_ms,
@@ -315,9 +194,12 @@ mod tests {
concurrency: 2,
retrieval_strategy: "initial".into(),
detailed_report: false,
ingest_chunk_min_tokens: 256,
ingest_chunk_max_tokens: 512,
ingest_chunk_overlap_tokens: 50,
ingest_chunks_only: false,
chunk_vector_take: 20,
chunk_fts_take: 20,
chunk_token_budget: 10000,
chunk_avg_chars_per_token: 4,
max_chunks_per_entity: 4,
average_ndcg: 0.0,
@@ -327,18 +209,34 @@ mod tests {
}
#[test]
fn writes_perf_log_jsonl() {
fn writes_perf_mirrors_from_record() {
let tmp = tempdir().unwrap();
let report_root = tmp.path().join("reports");
let summary = sample_summary();
let log_path = write_perf_logs(&summary, &report_root, None, None).expect("perf log write");
assert!(log_path.exists());
let contents = std::fs::read_to_string(&log_path).expect("reading perf log jsonl");
let record = report::EvaluationReport::from_summary(&summary, 5);
let json_path = tmp.path().join("extra.json");
let dir_path = tmp.path().join("copies");
let outputs = mirror_perf_outputs(
&record,
&summary,
&report_root,
Some(json_path.as_path()),
Some(dir_path.as_path()),
)
.expect("perf mirrors");
assert!(json_path.exists());
let content = std::fs::read_to_string(&json_path).expect("reading mirror json");
assert!(
contents.contains("\"openai_base_url\":\"https://example.com\""),
"serialized log should include base URL"
content.contains("\"evaluation_stages_ms\""),
"perf mirror should include evaluation stage timings"
);
let dataset_dir = report::dataset_report_dir(&report_root, &summary.dataset_id);
assert!(dataset_dir.join("perf-log.jsonl").exists());
assert_eq!(outputs.len(), 2);
let mirrored = outputs
.into_iter()
.filter(|path| path.starts_with(&dir_path))
.collect::<Vec<_>>();
assert_eq!(mirrored.len(), 1, "expected timestamped mirror in dir");
}
}

View File

@@ -19,7 +19,7 @@ pub struct ReportPaths {
pub markdown: PathBuf,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvaluationReport {
pub overview: OverviewSection,
pub dataset: DatasetSection,
@@ -28,14 +28,15 @@ pub struct EvaluationReport {
#[serde(skip_serializing_if = "Option::is_none")]
pub llm: Option<LlmSection>,
pub performance: PerformanceSection,
#[serde(skip_serializing_if = "Vec::is_empty")]
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub misses: Vec<MissEntry>,
#[serde(skip_serializing_if = "Vec::is_empty")]
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub llm_cases: Vec<LlmCaseEntry>,
#[serde(default)]
pub detailed_report: bool,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OverviewSection {
pub generated_at: String,
pub run_label: Option<String>,
@@ -43,7 +44,7 @@ pub struct OverviewSection {
pub filtered_questions: usize,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DatasetSection {
pub id: String,
pub label: String,
@@ -55,7 +56,7 @@ pub struct DatasetSection {
pub embedding_dimension: usize,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SliceSection {
pub id: String,
pub seed: u64,
@@ -69,7 +70,7 @@ pub struct SliceSection {
pub negative_multiplier: f32,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetrievalSection {
pub k: usize,
pub cases: usize,
@@ -86,16 +87,21 @@ pub struct RetrievalSection {
pub rerank_enabled: bool,
pub rerank_pool_size: Option<usize>,
pub rerank_keep_top: usize,
pub chunk_result_cap: usize,
pub ingest_chunk_min_tokens: usize,
pub ingest_chunk_max_tokens: usize,
pub ingest_chunk_overlap_tokens: usize,
pub ingest_chunks_only: bool,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlmSection {
pub cases: usize,
pub answered: usize,
pub precision: f64,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PerformanceSection {
pub openai_base_url: String,
pub ingestion_ms: u128,
@@ -111,7 +117,7 @@ pub struct PerformanceSection {
pub negative_paragraphs_reused: usize,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MissEntry {
pub question_id: String,
pub paragraph_title: String,
@@ -122,7 +128,7 @@ pub struct MissEntry {
pub retrieved: Vec<RetrievedSnippet>,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlmCaseEntry {
pub question_id: String,
pub answered: bool,
@@ -131,7 +137,7 @@ pub struct LlmCaseEntry {
pub retrieved: Vec<RetrievedSnippet>,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetrievedSnippet {
pub rank: usize,
pub source_id: String,
@@ -139,6 +145,13 @@ pub struct RetrievedSnippet {
pub matched: bool,
}
#[derive(Debug)]
pub struct ReportOutcome {
pub record: EvaluationReport,
pub paths: ReportPaths,
pub history_path: PathBuf,
}
impl EvaluationReport {
pub fn from_summary(summary: &EvaluationSummary, sample: usize) -> Self {
let overview = OverviewSection {
@@ -188,6 +201,11 @@ impl EvaluationReport {
rerank_enabled: summary.rerank_enabled,
rerank_pool_size: summary.rerank_pool_size,
rerank_keep_top: summary.rerank_keep_top,
chunk_result_cap: summary.chunk_result_cap,
ingest_chunk_min_tokens: summary.ingest_chunk_min_tokens,
ingest_chunk_max_tokens: summary.ingest_chunk_max_tokens,
ingest_chunk_overlap_tokens: summary.ingest_chunk_overlap_tokens,
ingest_chunks_only: summary.ingest_chunks_only,
};
let llm = if summary.llm_cases > 0 {
@@ -215,24 +233,30 @@ impl EvaluationReport {
negative_paragraphs_reused: summary.negative_paragraphs_reused,
};
let misses = summary
.cases
.iter()
.filter(|case| !case.matched && !case.is_impossible)
.take(sample)
.map(MissEntry::from_case)
.collect();
let llm_cases = if llm.is_some() {
summary
let (misses, llm_cases) = if summary.detailed_report {
let misses = summary
.cases
.iter()
.filter(|case| case.is_impossible)
.filter(|case| !case.matched && !case.is_impossible)
.take(sample)
.map(LlmCaseEntry::from_case)
.collect()
.map(MissEntry::from_case)
.collect();
let llm_cases = if llm.is_some() {
summary
.cases
.iter()
.filter(|case| case.is_impossible)
.take(sample)
.map(LlmCaseEntry::from_case)
.collect()
} else {
Vec::new()
};
(misses, llm_cases)
} else {
Vec::new()
(Vec::new(), Vec::new())
};
Self {
@@ -299,7 +323,7 @@ pub fn write_reports(
summary: &EvaluationSummary,
report_dir: &Path,
sample: usize,
) -> Result<ReportPaths> {
) -> Result<ReportOutcome> {
fs::create_dir_all(report_dir)
.with_context(|| format!("creating report directory {}", report_dir.display()))?;
let dataset_dir = dataset_report_dir(report_dir, &summary.dataset_id);
@@ -331,11 +355,15 @@ pub fn write_reports(
fs::write(&latest_md, markdown)
.with_context(|| format!("writing latest Markdown report to {}", latest_md.display()))?;
record_history(summary, &dataset_dir)?;
let history_path = record_history(&report, &dataset_dir)?;
Ok(ReportPaths {
json: json_path,
markdown: md_path,
Ok(ReportOutcome {
record: report,
paths: ReportPaths {
json: json_path,
markdown: md_path,
},
history_path,
})
}
@@ -555,10 +583,14 @@ fn render_markdown(report: &EvaluationReport) -> String {
);
if report.misses.is_empty() {
md.push_str("\\n_All evaluated retrieval queries matched within the top-k window._\\n");
if report.detailed_report {
md.push_str(
"\\nSuccess measures were captured for each query (entity, chunk text, chunk ID).\\n",
"\\n_All evaluated retrieval queries matched within the top-k window._\\n\
\\nSuccess measures were captured for each query (entity, chunk text, chunk ID).\\n",
);
} else {
md.push_str(
"\\n_Misses omitted. Re-run with `--detailed-report` to see sampled failures._\\n",
);
}
} else {
@@ -597,7 +629,11 @@ fn render_markdown(report: &EvaluationReport) -> String {
if report.llm.is_some() {
md.push_str("\\n## LLM-Only Cases (sample)\\n\\n");
if report.llm_cases.is_empty() {
md.push_str("All LLM-only cases matched within the evaluation window.\\n");
if report.detailed_report {
md.push_str("All LLM-only cases matched within the evaluation window.\\n");
} else {
md.push_str("LLM-only cases omitted. Re-run with `--detailed-report` to see samples.\\n");
}
} else {
md.push_str("| Question ID | Answered | Match Rank | Top Retrieved |\\n");
md.push_str("| --- | --- | --- | --- |\\n");
@@ -681,7 +717,7 @@ pub fn dataset_report_dir(report_dir: &Path, dataset_id: &str) -> PathBuf {
}
#[derive(Debug, Serialize, Deserialize)]
struct HistoryEntry {
struct LegacyHistoryEntry {
generated_at: String,
run_label: Option<String>,
dataset_id: String,
@@ -719,7 +755,18 @@ struct HistoryEntry {
rerank_enabled: bool,
rerank_keep_top: usize,
rerank_pool_size: Option<usize>,
delta: Option<HistoryDelta>,
#[serde(default)]
chunk_result_cap: Option<usize>,
#[serde(default)]
ingest_chunk_min_tokens: Option<usize>,
#[serde(default)]
ingest_chunk_max_tokens: Option<usize>,
#[serde(default)]
ingest_chunk_overlap_tokens: Option<usize>,
#[serde(default)]
ingest_chunks_only: Option<bool>,
#[serde(default)]
delta: Option<LegacyHistoryDelta>,
openai_base_url: String,
ingestion_ms: u128,
#[serde(default)]
@@ -727,92 +774,173 @@ struct HistoryEntry {
}
#[derive(Debug, Serialize, Deserialize)]
struct HistoryDelta {
struct LegacyHistoryDelta {
precision: f64,
precision_at_1: f64,
latency_avg_ms: f64,
}
fn record_history(summary: &EvaluationSummary, report_dir: &Path) -> Result<()> {
let path = report_dir.join("evaluations.json");
let mut entries: Vec<HistoryEntry> = if path.exists() {
let contents = fs::read(&path)
.with_context(|| format!("reading evaluation log {}", path.display()))?;
match serde_json::from_slice(&contents) {
Ok(entries) => entries,
Err(err) => {
let timestamp = Utc::now().format("%Y%m%dT%H%M%S");
let backup_path =
report_dir.join(format!("evaluations.json.corrupted.{}", timestamp));
fn default_stage_latency() -> StageLatencyBreakdown {
StageLatencyBreakdown {
embed: LatencyStats::default(),
collect_candidates: LatencyStats::default(),
graph_expansion: LatencyStats::default(),
chunk_attach: LatencyStats::default(),
rerank: LatencyStats::default(),
assemble: LatencyStats::default(),
}
}
fn convert_legacy_entry(entry: LegacyHistoryEntry) -> EvaluationReport {
let overview = OverviewSection {
generated_at: entry.generated_at,
run_label: entry.run_label,
total_cases: entry.slice_cases,
filtered_questions: 0,
};
let dataset = DatasetSection {
id: entry.dataset_id,
label: entry.dataset_label,
source: String::new(),
includes_unanswerable: entry.llm_cases > 0,
require_verified_chunks: true,
embedding_backend: entry.embedding_backend,
embedding_model: entry.embedding_model,
embedding_dimension: 0,
};
let slice = SliceSection {
id: entry.slice_id,
seed: entry.slice_seed,
window_offset: entry.slice_window_offset,
window_length: entry.slice_window_length,
slice_cases: entry.slice_cases,
ledger_total_cases: entry.slice_total_cases,
positives: 0,
negatives: 0,
total_paragraphs: 0,
negative_multiplier: 0.0,
};
let retrieval_cases = if entry.retrieval_cases > 0 {
entry.retrieval_cases
} else {
entry.slice_cases.saturating_sub(entry.llm_cases)
};
let retrieval_precision = if entry.retrieval_precision > 0.0 {
entry.retrieval_precision
} else {
entry.precision
};
let retrieval = RetrievalSection {
k: entry.k,
cases: retrieval_cases,
correct: 0,
precision: retrieval_precision,
precision_at_1: entry.precision_at_1,
precision_at_2: entry.precision_at_2,
precision_at_3: entry.precision_at_3,
mrr: entry.mrr,
average_ndcg: entry.average_ndcg,
latency: entry.latency_ms,
concurrency: 0,
strategy: "unknown".into(),
rerank_enabled: entry.rerank_enabled,
rerank_pool_size: entry.rerank_pool_size,
rerank_keep_top: entry.rerank_keep_top,
chunk_result_cap: entry.chunk_result_cap.unwrap_or(5),
ingest_chunk_min_tokens: entry.ingest_chunk_min_tokens.unwrap_or(256),
ingest_chunk_max_tokens: entry.ingest_chunk_max_tokens.unwrap_or(512),
ingest_chunk_overlap_tokens: entry.ingest_chunk_overlap_tokens.unwrap_or(50),
ingest_chunks_only: entry.ingest_chunks_only.unwrap_or(false),
};
let llm = if entry.llm_cases > 0 {
Some(LlmSection {
cases: entry.llm_cases,
answered: 0,
precision: entry.llm_precision,
})
} else {
None
};
let performance = PerformanceSection {
openai_base_url: entry.openai_base_url,
ingestion_ms: entry.ingestion_ms,
namespace_seed_ms: entry.namespace_seed_ms,
evaluation_stages_ms: EvaluationStageTimings::default(),
stage_latency: default_stage_latency(),
namespace_reused: false,
ingestion_reused: entry.ingestion_reused,
embeddings_reused: entry.ingestion_embeddings_reused,
ingestion_cache_path: String::new(),
corpus_paragraphs: 0,
positive_paragraphs_reused: 0,
negative_paragraphs_reused: 0,
};
EvaluationReport {
overview,
dataset,
slice,
retrieval,
llm,
performance,
misses: Vec::new(),
llm_cases: Vec::new(),
detailed_report: false,
}
}
fn load_history(path: &Path) -> Result<Vec<EvaluationReport>> {
if !path.exists() {
return Ok(Vec::new());
}
let contents =
fs::read(path).with_context(|| format!("reading evaluation log {}", path.display()))?;
if let Ok(entries) = serde_json::from_slice::<Vec<EvaluationReport>>(&contents) {
return Ok(entries);
}
match serde_json::from_slice::<Vec<LegacyHistoryEntry>>(&contents) {
Ok(entries) => Ok(entries.into_iter().map(convert_legacy_entry).collect()),
Err(err) => {
let timestamp = Utc::now().format("%Y%m%dT%H%M%S");
let backup_path = path
.parent()
.unwrap_or_else(|| Path::new("."))
.join(format!("evaluations.json.corrupted.{timestamp}"));
warn!(
path = %path.display(),
backup = %backup_path.display(),
error = %err,
"Evaluation history file is corrupted; backing up and starting fresh"
);
if let Err(e) = fs::rename(path, &backup_path) {
warn!(
path = %path.display(),
backup = %backup_path.display(),
error = %err,
"Evaluation history file is corrupted; backing up and starting fresh"
error = %e,
"Failed to backup corrupted evaluation history"
);
if let Err(e) = fs::rename(&path, &backup_path) {
warn!(
path = %path.display(),
error = %e,
"Failed to backup corrupted evaluation history"
);
}
Vec::new()
}
Ok(Vec::new())
}
} else {
Vec::new()
};
}
}
let delta = entries.last().map(|prev| HistoryDelta {
precision: summary.precision - prev.precision,
precision_at_1: summary.precision_at_1 - prev.precision_at_1,
latency_avg_ms: summary.latency_ms.avg - prev.latency_ms.avg,
});
let entry = HistoryEntry {
generated_at: format_timestamp(&summary.generated_at),
run_label: summary.run_label.clone(),
dataset_id: summary.dataset_id.clone(),
dataset_label: summary.dataset_label.clone(),
slice_id: summary.slice_id.clone(),
slice_seed: summary.slice_seed,
slice_window_offset: summary.slice_window_offset,
slice_window_length: summary.slice_window_length,
slice_cases: summary.slice_cases,
slice_total_cases: summary.slice_total_cases,
k: summary.k,
limit: summary.limit,
precision: summary.precision,
precision_at_1: summary.precision_at_1,
precision_at_2: summary.precision_at_2,
precision_at_3: summary.precision_at_3,
mrr: summary.mrr,
average_ndcg: summary.average_ndcg,
retrieval_cases: summary.retrieval_cases,
retrieval_precision: summary.retrieval_precision,
llm_cases: summary.llm_cases,
llm_precision: summary.llm_precision,
duration_ms: summary.duration_ms,
latency_ms: summary.latency_ms.clone(),
embedding_backend: summary.embedding_backend.clone(),
embedding_model: summary.embedding_model.clone(),
ingestion_reused: summary.ingestion_reused,
ingestion_embeddings_reused: summary.ingestion_embeddings_reused,
rerank_enabled: summary.rerank_enabled,
rerank_keep_top: summary.rerank_keep_top,
rerank_pool_size: summary.rerank_pool_size,
delta,
openai_base_url: summary.perf.openai_base_url.clone(),
ingestion_ms: summary.perf.ingestion_ms,
namespace_seed_ms: summary.perf.namespace_seed_ms,
};
entries.push(entry);
fn record_history(report: &EvaluationReport, report_dir: &Path) -> Result<PathBuf> {
let path = report_dir.join("evaluations.json");
let mut entries = load_history(&path)?;
entries.push(report.clone());
let blob = serde_json::to_vec_pretty(&entries).context("serialising evaluation log")?;
fs::write(&path, blob).with_context(|| format!("writing evaluation log {}", path.display()))?;
Ok(())
Ok(path)
}
#[cfg(test)]
@@ -822,6 +950,7 @@ mod tests {
EvaluationStageTimings, PerformanceTimings, RetrievedSummary, StageLatencyBreakdown,
};
use chrono::Utc;
use tempfile::tempdir;
fn latency(ms: f64) -> LatencyStats {
LatencyStats {
@@ -961,9 +1090,13 @@ mod tests {
concurrency: 2,
detailed_report: true,
retrieval_strategy: "initial".into(),
chunk_result_cap: 5,
ingest_chunk_min_tokens: 256,
ingest_chunk_max_tokens: 512,
ingest_chunk_overlap_tokens: 50,
ingest_chunks_only: false,
chunk_vector_take: 50,
chunk_fts_take: 50,
chunk_token_budget: 10_000,
chunk_avg_chars_per_token: 4,
max_chunks_per_entity: 4,
cases,
@@ -987,4 +1120,25 @@ mod tests {
assert!(!md.contains("LLM Mode Metrics"));
assert!(!md.contains("LLM-Only Cases"));
}
#[test]
fn evaluations_history_captures_strategy_and_concurrency() {
let tmp = tempdir().unwrap();
let summary = sample_summary(false);
let outcome =
write_reports(&summary, tmp.path(), 5).expect("writing consolidated reports");
let contents =
std::fs::read_to_string(&outcome.history_path).expect("reading evaluations history");
let entries: Vec<EvaluationReport> =
serde_json::from_str(&contents).expect("parsing evaluations history");
assert_eq!(entries.len(), 1);
let stored = &entries[0];
assert_eq!(stored.retrieval.concurrency, summary.concurrency);
assert_eq!(stored.retrieval.strategy, summary.retrieval_strategy);
assert_eq!(
stored.performance.evaluation_stages_ms.run_queries_ms,
summary.perf.evaluation_stage_ms.run_queries_ms
);
}
}

View File

@@ -16,8 +16,6 @@ pub struct SnapshotMetadata {
pub embedding_backend: String,
pub embedding_model: Option<String>,
pub embedding_dimension: usize,
pub chunk_min_chars: usize,
pub chunk_max_chars: usize,
pub rerank_enabled: bool,
}
@@ -55,8 +53,6 @@ impl Descriptor {
embedding_backend: embedding_provider.backend_label().to_string(),
embedding_model: embedding_provider.model_code(),
embedding_dimension: embedding_provider.dimension(),
chunk_min_chars: config.retrieval.chunk_min_chars,
chunk_max_chars: config.retrieval.chunk_max_chars,
rerank_enabled: config.retrieval.rerank,
};
@@ -146,8 +142,6 @@ mod tests {
embedding_backend: "hashed".into(),
embedding_model: None,
embedding_dimension: 128,
chunk_min_chars: 10,
chunk_max_chars: 100,
rerank_enabled: true,
};
let descriptor = Descriptor::from_parts(

View File

@@ -30,10 +30,11 @@ base64 = { workspace = true }
pdf-extract = "0.9"
lopdf = "0.32"
bytes = { workspace = true }
common = { path = "../common" }
retrieval-pipeline = { path = "../retrieval-pipeline" }
async-trait = { workspace = true }
state-machines = { workspace = true }
tokenizers = { workspace = true }
common = { path = "../common" }
retrieval-pipeline = { path = "../retrieval-pipeline" }
[features]
docker = []

View File

@@ -8,6 +8,7 @@ pub struct IngestionTuning {
pub graph_max_backoff_ms: u64,
pub chunk_min_tokens: usize,
pub chunk_max_tokens: usize,
pub chunk_overlap_tokens: usize,
pub chunk_insert_concurrency: usize,
pub entity_embedding_concurrency: usize,
}
@@ -21,8 +22,9 @@ impl Default for IngestionTuning {
graph_store_attempts: 3,
graph_initial_backoff_ms: 50,
graph_max_backoff_ms: 800,
chunk_min_tokens: 500,
chunk_max_tokens: 2_000,
chunk_min_tokens: 256,
chunk_max_tokens: 512,
chunk_overlap_tokens: 50,
chunk_insert_concurrency: 8,
entity_embedding_concurrency: 4,
}

View File

@@ -118,8 +118,12 @@ impl<'a> PipelineContext<'a> {
.await?;
let chunk_range = self.chunk_token_range();
let chunk_overlap = self.chunk_overlap_tokens();
let chunks = self.services.prepare_chunks(&content, chunk_range).await?;
let chunks = self
.services
.prepare_chunks(&content, chunk_range, chunk_overlap)
.await?;
Ok(PipelineArtifacts {
text_content: content,
@@ -132,8 +136,12 @@ impl<'a> PipelineContext<'a> {
pub async fn build_chunk_only_artifacts(&mut self) -> Result<PipelineArtifacts, AppError> {
let content = self.take_text_content()?;
let chunk_range = self.chunk_token_range();
let chunk_overlap = self.chunk_overlap_tokens();
let chunks = self.services.prepare_chunks(&content, chunk_range).await?;
let chunks = self
.services
.prepare_chunks(&content, chunk_range, chunk_overlap)
.await?;
Ok(PipelineArtifacts {
text_content: content,
@@ -146,4 +154,8 @@ impl<'a> PipelineContext<'a> {
fn chunk_token_range(&self) -> Range<usize> {
self.pipeline_config.tuning.chunk_min_tokens..self.pipeline_config.tuning.chunk_max_tokens
}
fn chunk_overlap_tokens(&self) -> usize {
self.pipeline_config.tuning.chunk_overlap_tokens
}
}

View File

@@ -1,4 +1,7 @@
use std::{ops::Range, sync::Arc};
use std::{
ops::Range,
sync::{Arc, OnceLock},
};
use anyhow::Context;
use async_openai::types::{
@@ -21,6 +24,7 @@ use common::{
utils::{config::AppConfig, embedding::EmbeddingProvider},
};
use retrieval_pipeline::{reranking::RerankerPool, retrieved_entities_to_json, RetrievedEntity};
use text_splitter::{ChunkCapacity, ChunkConfig, TextSplitter};
use super::{enrichment_result::LLMEnrichmentResult, preparation::to_text_content};
use crate::pipeline::context::{EmbeddedKnowledgeEntity, EmbeddedTextChunk};
@@ -29,7 +33,6 @@ use crate::utils::llm_instructions::{
};
const EMBEDDING_QUERY_CHAR_LIMIT: usize = 12_000;
#[async_trait]
pub trait PipelineServices: Send + Sync {
async fn prepare_text_content(
@@ -59,6 +62,7 @@ pub trait PipelineServices: Send + Sync {
&self,
content: &TextContent,
token_range: Range<usize>,
overlap_tokens: usize,
) -> Result<Vec<EmbeddedTextChunk>, AppError>;
}
@@ -238,9 +242,14 @@ impl PipelineServices for DefaultPipelineServices {
&self,
content: &TextContent,
token_range: Range<usize>,
overlap_tokens: usize,
) -> Result<Vec<EmbeddedTextChunk>, AppError> {
let chunk_candidates =
split_by_token_bounds(&content.text, token_range.start, token_range.end)?;
let chunk_candidates = prepare_chunks(
&content.text,
token_range.start,
token_range.end,
overlap_tokens,
)?;
let mut chunks = Vec::with_capacity(chunk_candidates.len());
for chunk_text in chunk_candidates {
@@ -249,8 +258,11 @@ impl PipelineServices for DefaultPipelineServices {
.embed(&chunk_text)
.await
.context("generating FastEmbed embedding for chunk")?;
let chunk_struct =
TextChunk::new(content.get_id().to_string(), chunk_text, content.user_id.clone());
let chunk_struct = TextChunk::new(
content.get_id().to_string(),
chunk_text,
content.user_id.clone(),
);
chunks.push(EmbeddedTextChunk {
chunk: chunk_struct,
embedding,
@@ -260,10 +272,11 @@ impl PipelineServices for DefaultPipelineServices {
}
}
fn split_by_token_bounds(
fn prepare_chunks(
text: &str,
min_tokens: usize,
max_tokens: usize,
overlap_tokens: usize,
) -> Result<Vec<String>, AppError> {
if min_tokens == 0 || max_tokens == 0 || min_tokens > max_tokens {
return Err(AppError::Validation(
@@ -271,34 +284,44 @@ fn split_by_token_bounds(
));
}
let tokens: Vec<&str> = text.split_whitespace().collect();
if tokens.is_empty() {
return Ok(vec![String::new()]);
if overlap_tokens >= min_tokens {
return Err(AppError::Validation(format!(
"chunk_min_tokens must be greater than the configured overlap of {overlap_tokens}"
)));
}
let mut chunks = Vec::new();
let mut buffer: Vec<&str> = Vec::new();
for (idx, token) in tokens.iter().enumerate() {
buffer.push(token);
let remaining = tokens.len().saturating_sub(idx + 1);
let at_max = buffer.len() >= max_tokens;
let at_min_and_boundary =
buffer.len() >= min_tokens && (remaining == 0 || buffer.len() + 1 > max_tokens);
if at_max || at_min_and_boundary {
let chunk_text = buffer.join(" ");
chunks.push(chunk_text);
buffer.clear();
}
}
let tokenizer = get_tokenizer()?;
if !buffer.is_empty() {
let chunk_text = buffer.join(" ");
chunks.push(chunk_text);
let chunk_capacity = ChunkCapacity::new(min_tokens)
.with_max(max_tokens)
.map_err(|e| AppError::Validation(format!("invalid chunk token bounds: {e}")))?;
let chunk_config = ChunkConfig::new(chunk_capacity)
.with_overlap(overlap_tokens)
.map_err(|e| AppError::Validation(format!("invalid chunk overlap: {e}")))?
.with_sizer(tokenizer);
let splitter = TextSplitter::new(chunk_config);
let mut chunks: Vec<String> = splitter.chunks(text).map(str::to_owned).collect();
if chunks.is_empty() {
chunks.push(String::new());
}
Ok(chunks)
}
fn get_tokenizer() -> Result<&'static tokenizers::Tokenizer, AppError> {
static TOKENIZER: OnceLock<Result<tokenizers::Tokenizer, String>> = OnceLock::new();
match TOKENIZER.get_or_init(|| {
tokenizers::Tokenizer::from_pretrained("bert-base-cased", None)
.map_err(|e| format!("failed to initialize tokenizer: {e}"))
}) {
Ok(tokenizer) => Ok(tokenizer),
Err(err) => Err(AppError::InternalError(err.clone())),
}
}
fn truncate_for_embedding(text: &str, max_chars: usize) -> String {
if text.chars().count() <= max_chars {
return text.to_string();

View File

@@ -155,6 +155,7 @@ impl PipelineServices for MockServices {
&self,
content: &TextContent,
_range: std::ops::Range<usize>,
_overlap_tokens: usize,
) -> Result<Vec<EmbeddedTextChunk>, AppError> {
self.record("chunk").await;
Ok(vec![EmbeddedTextChunk {
@@ -213,8 +214,11 @@ impl PipelineServices for FailingServices {
&self,
content: &TextContent,
token_range: std::ops::Range<usize>,
overlap_tokens: usize,
) -> Result<Vec<EmbeddedTextChunk>, AppError> {
self.inner.prepare_chunks(content, token_range).await
self.inner
.prepare_chunks(content, token_range, overlap_tokens)
.await
}
}
@@ -255,6 +259,7 @@ impl PipelineServices for ValidationServices {
&self,
_content: &TextContent,
_token_range: std::ops::Range<usize>,
_overlap_tokens: usize,
) -> Result<Vec<EmbeddedTextChunk>, AppError> {
unreachable!("prepare_chunks should not be called after validation failure")
}

View File

@@ -63,6 +63,7 @@ pub struct RetrievalTuning {
pub rerank_blend_weight: f32,
pub rerank_scores_only: bool,
pub rerank_keep_top: usize,
pub chunk_result_cap: usize,
}
impl Default for RetrievalTuning {
@@ -86,6 +87,7 @@ impl Default for RetrievalTuning {
rerank_blend_weight: 0.65,
rerank_scores_only: false,
rerank_keep_top: 8,
chunk_result_cap: 5,
}
}
}

View File

@@ -675,7 +675,13 @@ pub fn assemble_chunks(ctx: &mut PipelineContext<'_>) -> Result<(), AppError> {
ctx.config.tuning.lexical_match_weight,
);
let limit = ctx.config.tuning.chunk_vector_take.max(1);
// Limit how many chunks we return to keep context size reasonable.
let limit = ctx
.config
.tuning
.chunk_result_cap
.max(1)
.min(ctx.config.tuning.chunk_vector_take.max(1));
if chunk_values.len() > limit {
chunk_values.truncate(limit);
}

View File

@@ -29,7 +29,10 @@ impl RerankerPool {
/// Build the pool at startup.
/// `pool_size` controls max parallel reranks.
pub fn new(pool_size: usize) -> Result<Arc<Self>, AppError> {
Self::new_with_options(pool_size, RerankInitOptions::default())
Self::new_with_options(
pool_size,
RerankInitOptions::new(fastembed::RerankerModel::JINARerankerV1TurboEn),
)
}
fn new_with_options(