daemon: embeds through the endpoint pool, GPUs together and the CPU last

query-embed-on-cpu, follow-up daemon-embedder-gpu-pool: huginn-daemon takes
repeated --ollama-url (GPU hosts) and --reserve-ollama-url (CPU hosts) and
builds the same EmbedderPool as huginn-sources, through one ollama::pool
constructor. A lone --ollama-url is a pool of one.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-31 07:54:57 +01:00
commit c74dd530de
5 changed files with 47 additions and 16 deletions

View file

@ -26,7 +26,9 @@ Upstream: `https://github.com/GameCult/Huginn.git`
restart compares the mind with the collection and writes what is missing. An restart compares the mind with the collection and writes what is missing. An
unreachable Qdrant or Ollama shows in `whoami` as the index's status and is unreachable Qdrant or Ollama shows in `whoami` as the index's status and is
retried; admission never waits on it. The daemon requires `--qdrant-url`, retried; admission never waits on it. The daemon requires `--qdrant-url`,
`--ollama-url` and `--embedding-model`. A `query` carrying `semantic` is `--ollama-url` and `--embedding-model`; `--ollama-url` repeats for each GPU
host and `--reserve-ollama-url` for each CPU host, and the GPU hosts embed
together while the CPU hosts serve only when none can. A `query` carrying `semantic` is
answered through the same selection as any other: the index supplies answered through the same selection as any other: the index supplies
candidate ids and scores, and the mind keeps only the documents it holds and candidate ids and scores, and the mind keeps only the documents it holds and
the selection admits (ask for `in_force` to exclude resolved ones), ordered by the selection admits (ask for `in_force` to exclude resolved ones), ordered by

View file

@ -7,8 +7,7 @@ use std::path::PathBuf;
use std::process::ExitCode; use std::process::ExitCode;
use anyhow::{Context, Result, ensure}; use anyhow::{Context, Result, ensure};
use huginn_daemon::index::ollama::OllamaEmbedder; use huginn_daemon::index::ollama::pool;
use huginn_daemon::index::pool::EmbedderPool;
use huginn_daemon::index::qdrant::QdrantIndex; use huginn_daemon::index::qdrant::QdrantIndex;
use huginn_daemon::sources::forge::HttpForge; use huginn_daemon::sources::forge::HttpForge;
use huginn_daemon::sources::refresh; use huginn_daemon::sources::refresh;
@ -77,8 +76,7 @@ fn main() -> ExitCode {
return ExitCode::from(2); return ExitCode::from(2);
} }
}; };
let endpoint = |url: &String| (url.clone(), OllamaEmbedder::new(url, &options.embedding_model)); let mut embedder = pool(&options.ollama_urls, &options.reserve_urls, &options.embedding_model);
let mut embedder = EmbedderPool::new(options.ollama_urls.iter().map(endpoint).collect(), options.reserve_urls.iter().map(endpoint).collect());
let mut index = QdrantIndex::new(&options.qdrant_url); let mut index = QdrantIndex::new(&options.qdrant_url);
let mut forge = HttpForge::new(&options.forge_url); let mut forge = HttpForge::new(&options.forge_url);
let mut journal = |report: &huginn_daemon::sources::Report| println!("{report}"); let mut journal = |report: &huginn_daemon::sources::Report| println!("{report}");

View file

@ -73,3 +73,11 @@ impl Embedder for OllamaEmbedder {
.collect() .collect()
} }
} }
/// One pool over Ollama endpoints serving `model`: the `primaries` (GPU hosts)
/// share the work and the `reserve` (CPU hosts) serve only when none of them
/// can. The one embedder client of every Huginn process that embeds.
pub fn pool(primaries: &[String], reserve: &[String], model: &str) -> super::pool::EmbedderPool<OllamaEmbedder> {
let endpoint = |url: &String| (url.clone(), OllamaEmbedder::new(url, model));
super::pool::EmbedderPool::new(primaries.iter().map(endpoint).collect(), reserve.iter().map(endpoint).collect())
}

View file

@ -7,13 +7,13 @@ use std::sync::atomic::AtomicBool;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use huginn_daemon::index::Backoff; use huginn_daemon::index::Backoff;
use huginn_daemon::index::ollama::OllamaEmbedder; use huginn_daemon::index::ollama::pool;
use huginn_daemon::index::qdrant::QdrantIndex; use huginn_daemon::index::qdrant::QdrantIndex;
use huginn_daemon::serve::{ServeOptions, parse_options, run, startup}; use huginn_daemon::serve::{ServeOptions, parse_options, run, startup};
fn main() -> Result<()> { fn main() -> Result<()> {
let options = parse_options(std::env::args().skip(1))?; let options = parse_options(std::env::args().skip(1))?;
let embedder = OllamaEmbedder::new(&options.ollama_url, &options.embedding_model); let embedder = pool(&options.ollama_urls, &options.reserve_ollama_urls, &options.embedding_model);
let index = QdrantIndex::new(&options.qdrant_url); let index = QdrantIndex::new(&options.qdrant_url);
let (mut daemon, mut hub, registry) = match startup(&options, embedder, index, Backoff::default()) { let (mut daemon, mut hub, registry) = match startup(&options, embedder, index, Backoff::default()) {
Ok(started) => started, Ok(started) => started,

View file

@ -101,7 +101,7 @@ impl Default for ServeOptions {
} }
} }
/// The six things the operator supplies. Nothing here reads the environment: /// The things the operator supplies. Nothing here reads the environment:
/// how Idunn supplies `--bind` is Cut 14's. The index has no off switch: an /// how Idunn supplies `--bind` is Cut 14's. The index has no off switch: an
/// unreachable Qdrant or Ollama is a degraded projection, reported by /// unreachable Qdrant or Ollama is a degraded projection, reported by
/// `whoami`, not a configuration. /// `whoami`, not a configuration.
@ -111,23 +111,34 @@ pub struct Options {
pub instance: Slug, pub instance: Slug,
pub bind: SocketAddr, pub bind: SocketAddr,
pub qdrant_url: String, pub qdrant_url: String,
pub ollama_url: String, /// The GPU hosts: all take embedding at once. One is the plain case.
pub ollama_urls: Vec<String>,
/// The CPU host: takes embedding only while no GPU host can.
pub reserve_ollama_urls: Vec<String>,
pub embedding_model: String, pub embedding_model: String,
} }
/// Exactly `--state-root`, `--instance`, `--bind`, `--qdrant-url`, /// Exactly `--state-root`, `--instance`, `--bind`, `--qdrant-url`,
/// `--ollama-url` and `--embedding-model`, each required, each once. The two /// `--ollama-url` and `--embedding-model`, each required, each once, except
/// URLs are plain `http://`: this build carries no TLS. /// `--ollama-url`, which repeats for each GPU host, and the optional
/// `--reserve-ollama-url`, which repeats for each CPU host. The URLs are plain
/// `http://`: this build carries no TLS.
pub fn parse_options(args: impl Iterator<Item = String>) -> Result<Options> { pub fn parse_options(args: impl Iterator<Item = String>) -> Result<Options> {
let mut args = args; let mut args = args;
let mut values: BTreeMap<String, String> = BTreeMap::new(); let mut values: BTreeMap<String, String> = BTreeMap::new();
let (mut ollama_urls, mut reserve_ollama_urls) = (Vec::new(), Vec::new());
while let Some(name) = args.next() { while let Some(name) = args.next() {
let name = name.strip_prefix("--").with_context(|| format!("expected --option, got {name:?}"))?; let name = name.strip_prefix("--").with_context(|| format!("expected --option, got {name:?}"))?;
ensure!( ensure!(
matches!(name, "state-root" | "instance" | "bind" | "qdrant-url" | "ollama-url" | "embedding-model"), matches!(name, "state-root" | "instance" | "bind" | "qdrant-url" | "ollama-url" | "reserve-ollama-url" | "embedding-model"),
"unsupported Huginn option --{name}" "unsupported Huginn option --{name}"
); );
let value = args.next().with_context(|| format!("missing value for --{name}"))?; let value = args.next().with_context(|| format!("missing value for --{name}"))?;
if name == "ollama-url" || name == "reserve-ollama-url" {
ensure!(value.starts_with("http://"), "--{name} must be a plain http:// address, got {value:?}");
if name == "ollama-url" { ollama_urls.push(value) } else { reserve_ollama_urls.push(value) }
continue;
}
ensure!(values.insert(name.to_owned(), value).is_none(), "duplicate Huginn option --{name}"); ensure!(values.insert(name.to_owned(), value).is_none(), "duplicate Huginn option --{name}");
} }
let take = |name: &str| -> Result<String> { let take = |name: &str| -> Result<String> {
@ -148,7 +159,11 @@ pub fn parse_options(args: impl Iterator<Item = String>) -> Result<Options> {
instance: Slug(take("instance")?), instance: Slug(take("instance")?),
bind: bind.parse().with_context(|| format!("--bind must be an ip:port, got {bind:?}"))?, bind: bind.parse().with_context(|| format!("--bind must be an ip:port, got {bind:?}"))?,
qdrant_url: url("qdrant-url")?, qdrant_url: url("qdrant-url")?,
ollama_url: url("ollama-url")?, ollama_urls: {
ensure!(!ollama_urls.is_empty(), "--ollama-url is required; repeat it for each GPU host");
ollama_urls
},
reserve_ollama_urls,
embedding_model, embedding_model,
}) })
} }
@ -1347,7 +1362,8 @@ mod tests {
instance: slug(INSTANCE), instance: slug(INSTANCE),
bind: port, bind: port,
qdrant_url: "http://127.0.0.1:1".into(), qdrant_url: "http://127.0.0.1:1".into(),
ollama_url: "http://127.0.0.1:1".into(), ollama_urls: vec!["http://127.0.0.1:1".into()],
reserve_ollama_urls: Vec::new(),
embedding_model: "a-model".into(), embedding_model: "a-model".into(),
}; };
let started = |options: &Options| { let started = |options: &Options| {
@ -1369,7 +1385,7 @@ mod tests {
} }
#[test] #[test]
fn options_are_exactly_the_six_the_operator_supplies() { fn options_are_the_ones_the_operator_supplies() {
let root = if cfg!(windows) { r"C:\state" } else { "/state" }; let root = if cfg!(windows) { r"C:\state" } else { "/state" };
let args = |values: &[&str]| values.iter().map(|value| value.to_string()).collect::<Vec<_>>().into_iter(); let args = |values: &[&str]| values.iter().map(|value| value.to_string()).collect::<Vec<_>>().into_iter();
let good = [ let good = [
@ -1383,7 +1399,8 @@ mod tests {
instance: slug("yggdrasil"), instance: slug("yggdrasil"),
bind: "127.0.0.1:17872".parse().unwrap(), bind: "127.0.0.1:17872".parse().unwrap(),
qdrant_url: "http://127.0.0.1:6333".into(), qdrant_url: "http://127.0.0.1:6333".into(),
ollama_url: "http://10.77.0.4:11434".into(), ollama_urls: vec!["http://10.77.0.4:11434".into()],
reserve_ollama_urls: Vec::new(),
embedding_model: "qwen3-embedding:0.6b".into(), embedding_model: "qwen3-embedding:0.6b".into(),
} }
); );
@ -1411,5 +1428,11 @@ mod tests {
] { ] {
assert!(parse_options(args(&bad)).is_err(), "{bad:?}"); assert!(parse_options(args(&bad)).is_err(), "{bad:?}");
} }
// The GPU hosts and the CPU host repeat; a lone --ollama-url is the plain case.
let pooled = [good.to_vec(), vec!["--ollama-url", "http://10.77.0.2:11434", "--reserve-ollama-url", "http://127.0.0.1:11434"]].concat();
let parsed = parse_options(args(&pooled)).unwrap();
assert_eq!(parsed.ollama_urls, ["http://10.77.0.4:11434", "http://10.77.0.2:11434"]);
assert_eq!(parsed.reserve_ollama_urls, ["http://127.0.0.1:11434"]);
assert!(parse_options(args(&[good.to_vec(), vec!["--reserve-ollama-url", "127.0.0.1:11434"]].concat())).is_err());
} }
} }