Phases 16.2 + L2 + 17 VRAM gate + MySQL + 18 Lance hybrid milestone
Five threads of work landing as one milestone — all individually
verified end-to-end against real data, full release build clean,
46 unit tests pass.
## Phase 16.2 / 16.5 — autotune agent + ingest triggers
`vectord::agent` is a long-running tokio task that watches the trial
journal and autonomously proposes + runs new HNSW configs. Distinct
from `autotune::run_autotune` (synchronous one-shot grid). Triggered
on POST /vectors/agent/enqueue/{idx} or by the periodic wake; ingest
paths now push DatasetAppended events when an index's source dataset
gets re-ingested. Rate-limited (max_trials_per_hour) and cooldown-
gated so it can't saturate Ollama under live load.
The proposer is ε-greedy around the current champion: with prob 0.25
sample random from full bounds, otherwise perturb champion ± small
delta on both axes. Dedup against history. Deterministic — RNG seeded
from history.len() so the same journal state proposes the same next
config (helps offline replay debugging).
`[agent]` config section in lakehouse.toml; opt-in via enabled=true.
## Federation Layer 2 — runtime bucket lifecycle + per-index scoping
`BucketRegistry.buckets` moved to `std::sync::RwLock<HashMap>` so
buckets can be added/removed after startup. POST /storage/buckets
provisions at runtime; DELETE /storage/buckets/{name} unregisters
(refuses primary/rescue with 403). Local-backend buckets get their
root directory auto-created.
`IndexMeta.bucket` (default "primary" via serde) records each index's
home bucket. `TrialJournal` and `PromotionRegistry` now hold
Arc<BucketRegistry> + IndexRegistry; they resolve target store per-
index via IndexMeta.bucket. PromotionRegistry::list_all scans every
bucket and dedups by index_name. Pre-federation indexes keep working
unchanged — they just default to primary.
`ModelProfile.bucket: Option<String>` declares per-profile artifact
home. POST /vectors/profile/{id}/activate auto-provisions the
profile's bucket under storage.profile_root if not yet registered.
EvalSets stay primary-only for now — noted gap, low-risk to extend
later with the same resolver pattern.
## Phase 17 — VRAM-aware two-profile gate
Sidecar gains POST /admin/unload (Ollama keep_alive=0 trick — forces
immediate VRAM release), POST /admin/preload (keep_alive=5m with
empty prompt, takes the slot warm), and GET /admin/vram (combines
nvidia-smi snapshot with Ollama /api/ps). Exposed via aibridge as
unload_model / preload_model / vram_snapshot.
`VectorState.active_profile` is the GPU-slot singleton —
Arc<RwLock<Option<ActiveProfileSlot>>>. activate_profile checks for
a previous profile with a different ollama_name and unloads it
before preloading the new one; same-model reactivations skip the
unload (Ollama no-ops). New routes: POST /vectors/profile/{id}/
deactivate (unload + clear slot), GET /vectors/profile/active.
Verified live: staffing-recruiter (qwen2.5) → docs-assistant
(mistral) swap freed qwen2.5 from VRAM and loaded mistral. nomic-
embed-text persists across swaps because both profiles use it —
free optimization that fell out of the design. Scoped search
correctly 403s cross-profile in both directions.
## MySQL streaming connector
`crates/ingestd/src/my_stream.rs` mirrors pg_stream.rs for MySQL.
Pure-rust `mysql_async` driver (default-features=false to avoid C
deps). Same OFFSET pagination, same Parquet-streaming write shape.
Type mapping per ADR-010: int/bigint → Int32/Int64, decimal/float
→ Float64, tinyint(1)/bool → Boolean, everything else → Utf8 with
fallback parsers for date/time/json/uuid via Display.
POST /ingest/mysql parallel to /ingest/db. Same PII auto-detection,
same lineage capture (source_system="mysql"), same agent-trigger
hook. `redact_dsn` generalized — was hardcoded to "postgresql://"
length, now works for any scheme://user:pass@host/path URL (latent
PII leak fix for MySQL DSNs).
Verified live against MariaDB on localhost: 10 rows × 9 columns of
test data round-tripped through datatypes int/varchar/decimal/
tinyint/datetime/text. PII detection auto-flagged name + email.
Aggregation queries through DataFusion match the source values
exactly.
## Phase 18 — Hybrid Parquet+HNSW ⊕ Lance backend (ADR-019)
`vectord-lance` is a new firewall crate. Lance pulls Arrow 57 and
DataFusion 52 — incompatible with the rest of the workspace's
Arrow 55 / DataFusion 47. The firewall isolates that dep tree:
public API uses only std types (Vec<f32>, Vec<String>, Hit, Row,
*Stats), so no Arrow types cross the crate boundary and nothing
propagates to vectord. The ADR-019 path that didn't ship until now.
`vectord::lance_backend::LanceRegistry` lazy-creates a
LanceVectorStore per index, resolving bucket → URI via the
conventional local-bucket layout. `IndexMeta.vector_backend` and
`ModelProfile.vector_backend` carry the choice (default Parquet so
existing indexes unchanged).
Six routes under /vectors/lance/*:
- migrate/{idx}: convert binary-blob Parquet → Lance FixedSizeList
- index/{idx}: build IVF_PQ
- search/{idx}: vector search (embed via sidecar)
- doc/{idx}/{doc_id}: random row fetch
- append/{idx}: native fragment append
- stats/{idx}: row count + index presence
Verified live on the real resumes_100k_v2 corpus (100K × 768d):
- Migrate: 0.57s
- Build IVF_PQ index: 16.2s (matches ADR-019 bench; 14× faster than
HNSW's 230s for the same data)
- Search end-to-end (Ollama embed + Lance scan): 23-53ms
- Random doc_id fetch: 5-7ms (filter scan; faster than Parquet's
~35ms full-file scan, slower than the bench's 311us positional
take — would close that gap with a scalar btree on doc_id)
- Append 100 rows: 3.3ms / +320KB on disk vs Parquet's required
full ~330MB rewrite — the structural win
- Index survives append; both backends coexist cleanly
## Known follow-ups not in this milestone
- ModelProfile.vector_backend doesn't yet auto-route /vectors/profile/
{id}/search to Lance; callers go through /vectors/lance/* directly
- Scalar btree on doc_id (closes the 5-7ms → ~300us gap)
- vectord-lance built default-features=false → no S3 yet
- IVF_PQ recall not measured (ADR-019 caveat) — needs a Lance-aware
variant of the eval harness
- Watcher-path ingest doesn't push agent triggers (HTTP paths do)
- EvalSets still primary-only (federation gap)
- No PATCH endpoint to move an existing index between buckets
- The pre-existing storaged::append_log doctest fails to compile
(malformed `{prefix}/` parses as code fence) — pre-existing bug,
left for a focused fix
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,8 @@ use std::sync::Arc;
|
||||
|
||||
use aibridge::client::{AiClient, EmbedRequest};
|
||||
use catalogd::registry::Registry as CatalogRegistry;
|
||||
use crate::{autotune, chunker, embedding_cache, harness, hnsw, index_registry, jobs, promotion, rag, refresh, search, store, supervisor, trial};
|
||||
use storaged::registry::BucketRegistry;
|
||||
use crate::{agent, autotune, chunker, embedding_cache, harness, hnsw, index_registry, jobs, lance_backend, promotion, rag, refresh, search, store, supervisor, trial};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct VectorState {
|
||||
@@ -27,6 +28,33 @@ pub struct VectorState {
|
||||
pub catalog: CatalogRegistry,
|
||||
/// Phase 16: promoted HNSW configs. Activation + autotune read/write here.
|
||||
pub promotion_registry: promotion::PromotionRegistry,
|
||||
/// Phase 16.2: handle to the background autotune agent. Always
|
||||
/// present — if the agent is disabled in config, the handle drops
|
||||
/// incoming triggers silently.
|
||||
pub agent_handle: agent::AgentHandle,
|
||||
/// Phase B (federation layer 2): bucket registry for per-profile
|
||||
/// bucket auto-provisioning on activation.
|
||||
pub bucket_registry: Arc<BucketRegistry>,
|
||||
/// Phase C (two-profile VRAM gate): tracks which profile is currently
|
||||
/// "active" on the GPU. Singleton — one profile at a time holds its
|
||||
/// model in VRAM. Swapping profiles with different ollama_name unloads
|
||||
/// the previous one (keep_alive=0) before preloading the new one.
|
||||
///
|
||||
/// `None` = no profile has been activated this session; any first
|
||||
/// activation just preloads and takes the slot.
|
||||
pub active_profile: Arc<tokio::sync::RwLock<Option<ActiveProfileSlot>>>,
|
||||
/// ADR-019 hybrid: handles to Lance datasets keyed by index name.
|
||||
/// Lazy-created on first /vectors/lance/* call.
|
||||
pub lance: lance_backend::LanceRegistry,
|
||||
}
|
||||
|
||||
/// What the active-profile singleton records. Narrow — we don't need the
|
||||
/// full ModelProfile here, just enough to know what to unload on swap.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ActiveProfileSlot {
|
||||
pub profile_id: String,
|
||||
pub ollama_name: String,
|
||||
pub activated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
pub fn router(state: VectorState) -> Router {
|
||||
@@ -59,12 +87,26 @@ pub fn router(state: VectorState) -> Router {
|
||||
// Phase 17: profile activation — pre-load caches + HNSW for this
|
||||
// model's bound data. First search after activate is warm.
|
||||
.route("/profile/{id}/activate", post(activate_profile))
|
||||
.route("/profile/{id}/deactivate", post(deactivate_profile))
|
||||
.route("/profile/{id}/search", post(profile_scoped_search))
|
||||
// Phase 17 VRAM gate: which profile currently owns the GPU?
|
||||
.route("/profile/active", get(get_active_profile))
|
||||
// Phase 16: promotion + autotune
|
||||
.route("/hnsw/promote/{index}/{trial_id}", post(promote_trial))
|
||||
.route("/hnsw/rollback/{index}", post(rollback_promotion))
|
||||
.route("/hnsw/promoted/{index}", get(get_promoted))
|
||||
.route("/hnsw/autotune", post(run_autotune_endpoint))
|
||||
// Phase 16.2: background autotune agent
|
||||
.route("/agent/status", get(agent_status))
|
||||
.route("/agent/stop", post(agent_stop))
|
||||
.route("/agent/enqueue/{index_name}", post(agent_enqueue))
|
||||
// ADR-019: Lance hybrid backend
|
||||
.route("/lance/migrate/{index_name}", post(lance_migrate))
|
||||
.route("/lance/index/{index_name}", post(lance_build_index))
|
||||
.route("/lance/search/{index_name}", post(lance_search))
|
||||
.route("/lance/doc/{index_name}/{doc_id}", get(lance_get_doc))
|
||||
.route("/lance/append/{index_name}", post(lance_append))
|
||||
.route("/lance/stats/{index_name}", get(lance_stats))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
@@ -81,6 +123,11 @@ struct CreateIndexRequest {
|
||||
documents: Vec<DocInput>,
|
||||
chunk_size: Option<usize>,
|
||||
overlap: Option<usize>,
|
||||
/// Federation layer 2: optional bucket to hold this index's trial
|
||||
/// journal + promotion file. Defaults to "primary" — pre-existing
|
||||
/// clients that don't know about federation keep working unchanged.
|
||||
#[serde(default)]
|
||||
bucket: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -117,6 +164,7 @@ async fn create_index(
|
||||
let n_docs = req.documents.len();
|
||||
let n_chunks = chunks.len();
|
||||
let index_name = req.index_name.clone();
|
||||
let bucket = req.bucket.clone().unwrap_or_else(|| "primary".to_string());
|
||||
|
||||
// Create job and return immediately
|
||||
let job_id = state.job_tracker.create(&index_name, n_chunks).await;
|
||||
@@ -157,6 +205,8 @@ async fn create_index(
|
||||
created_at: chrono::Utc::now(),
|
||||
build_time_secs: elapsed,
|
||||
chunks_per_sec: rate,
|
||||
bucket: bucket.clone(),
|
||||
vector_backend: shared::types::VectorBackend::Parquet,
|
||||
};
|
||||
let _ = registry.register(meta).await;
|
||||
|
||||
@@ -753,6 +803,11 @@ struct ActivateReport {
|
||||
failures: Vec<String>,
|
||||
total_vectors: usize,
|
||||
duration_secs: f32,
|
||||
/// Phase C: did we successfully preload the Ollama model?
|
||||
model_preloaded: bool,
|
||||
/// Phase C: which profile previously held the GPU slot, if any.
|
||||
/// Useful for observability of the swap.
|
||||
previous_profile: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -788,6 +843,64 @@ async fn activate_profile(
|
||||
let mut failures = Vec::new();
|
||||
let mut total_vectors = 0usize;
|
||||
|
||||
// Phase 17 / C: VRAM-aware swap. If another profile currently holds
|
||||
// the GPU and uses a DIFFERENT Ollama model than the one being
|
||||
// activated, unload it first (keep_alive=0). Same-model activations
|
||||
// skip the unload — no point churning a model that's already loaded.
|
||||
let previous_slot = {
|
||||
let guard = state.active_profile.read().await;
|
||||
guard.clone()
|
||||
};
|
||||
if let Some(prev) = &previous_slot {
|
||||
if prev.ollama_name != profile.ollama_name {
|
||||
match state.ai_client.unload_model(&prev.ollama_name).await {
|
||||
Ok(_) => tracing::info!(
|
||||
"profile swap: unloaded '{}' ({} -> {})",
|
||||
prev.ollama_name, prev.profile_id, profile.id,
|
||||
),
|
||||
Err(e) => failures.push(format!(
|
||||
"unload previous model '{}': {e}", prev.ollama_name,
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Federation layer 2: if this profile declares its own bucket and
|
||||
// that bucket isn't registered yet, auto-provision it under the
|
||||
// configured profile_root. This is the moment a "dormant" profile
|
||||
// becomes live — its bucket exists and is readable/writable.
|
||||
if let Some(bucket_name) = profile.bucket.clone() {
|
||||
if !state.bucket_registry.contains(&bucket_name) {
|
||||
let root = format!(
|
||||
"{}/{}",
|
||||
state.bucket_registry.profile_root().trim_end_matches('/'),
|
||||
bucket_name.replace(':', "_"),
|
||||
);
|
||||
let bc = shared::config::BucketConfig {
|
||||
name: bucket_name.clone(),
|
||||
backend: "local".to_string(),
|
||||
root: Some(root.clone()),
|
||||
bucket: None,
|
||||
region: None,
|
||||
endpoint: None,
|
||||
secret_ref: None,
|
||||
};
|
||||
match state.bucket_registry.add_bucket(bc).await {
|
||||
Ok(info) => {
|
||||
tracing::info!(
|
||||
"profile '{}' activated bucket '{}' (root={}, reachable={})",
|
||||
profile.id, bucket_name, root, info.reachable,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
failures.push(format!(
|
||||
"auto-provision bucket '{}': {}", bucket_name, e,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let all_indexes = state.index_registry.list(None, None).await;
|
||||
|
||||
for binding in &profile.bound_datasets {
|
||||
@@ -847,6 +960,30 @@ async fn activate_profile(
|
||||
}
|
||||
}
|
||||
|
||||
// Preload the new profile's Ollama model proactively. Same-model
|
||||
// re-activations are cheap (Ollama no-ops if already loaded).
|
||||
let mut model_preloaded = false;
|
||||
match state.ai_client.preload_model(&profile.ollama_name).await {
|
||||
Ok(_) => {
|
||||
model_preloaded = true;
|
||||
tracing::info!("profile '{}' preloaded ollama model '{}'",
|
||||
profile.id, profile.ollama_name);
|
||||
}
|
||||
Err(e) => failures.push(format!(
|
||||
"preload ollama model '{}': {e}", profile.ollama_name,
|
||||
)),
|
||||
}
|
||||
|
||||
// Take the GPU slot.
|
||||
{
|
||||
let mut guard = state.active_profile.write().await;
|
||||
*guard = Some(ActiveProfileSlot {
|
||||
profile_id: profile.id.clone(),
|
||||
ollama_name: profile.ollama_name.clone(),
|
||||
activated_at: chrono::Utc::now(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Json(ActivateReport {
|
||||
profile_id: profile.id,
|
||||
ollama_name: profile.ollama_name,
|
||||
@@ -854,9 +991,52 @@ async fn activate_profile(
|
||||
failures,
|
||||
total_vectors,
|
||||
duration_secs: t0.elapsed().as_secs_f32(),
|
||||
model_preloaded,
|
||||
previous_profile: previous_slot.map(|s| s.profile_id),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Unload this profile's model and clear the active slot. No-op if the
|
||||
/// caller isn't the currently-active profile.
|
||||
async fn deactivate_profile(
|
||||
State(state): State<VectorState>,
|
||||
Path(profile_id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let profile = match state.catalog.get_profile(&profile_id).await {
|
||||
Some(p) => p,
|
||||
None => return Err((StatusCode::NOT_FOUND, format!("profile not found: {profile_id}"))),
|
||||
};
|
||||
|
||||
let was_active = {
|
||||
let mut guard = state.active_profile.write().await;
|
||||
match guard.as_ref() {
|
||||
Some(s) if s.profile_id == profile_id => {
|
||||
let prev = s.clone();
|
||||
*guard = None;
|
||||
Some(prev)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
||||
// Regardless of whether it held the slot, we can still try to unload —
|
||||
// the operator's intent is "get this model out of VRAM."
|
||||
let unload_result = state.ai_client.unload_model(&profile.ollama_name).await;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"profile_id": profile.id,
|
||||
"ollama_name": profile.ollama_name,
|
||||
"was_active": was_active.is_some(),
|
||||
"unloaded": unload_result.is_ok(),
|
||||
"unload_error": unload_result.err(),
|
||||
})))
|
||||
}
|
||||
|
||||
async fn get_active_profile(State(state): State<VectorState>) -> impl IntoResponse {
|
||||
let slot = state.active_profile.read().await.clone();
|
||||
Json(slot)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ProfileSearchRequest {
|
||||
index_name: String,
|
||||
@@ -1013,3 +1193,232 @@ async fn run_autotune_endpoint(
|
||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Phase 16.2: autotune agent endpoints ---
|
||||
|
||||
async fn agent_status(State(state): State<VectorState>) -> impl IntoResponse {
|
||||
Json(state.agent_handle.status().await)
|
||||
}
|
||||
|
||||
async fn agent_stop(State(state): State<VectorState>) -> impl IntoResponse {
|
||||
let stopped = state.agent_handle.stop().await;
|
||||
Json(serde_json::json!({ "stopped": stopped }))
|
||||
}
|
||||
|
||||
async fn agent_enqueue(
|
||||
State(state): State<VectorState>,
|
||||
Path(index_name): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let event = agent::TriggerEvent::manual(index_name);
|
||||
match state.agent_handle.enqueue(event).await {
|
||||
Ok(()) => Ok(Json(serde_json::json!({ "enqueued": true }))),
|
||||
Err(e) => Err((StatusCode::SERVICE_UNAVAILABLE, e)),
|
||||
}
|
||||
}
|
||||
|
||||
// --- ADR-019: Lance hybrid backend HTTP surface ---
|
||||
//
|
||||
// Lance routes operate on the same `index_name` as the Parquet/HNSW path,
|
||||
// but materialize the data as a Lance dataset on disk under
|
||||
// `{bucket_root}/lance/{index_name}/`. The two backends are independent:
|
||||
// you can have an index in both formats simultaneously. `IndexMeta.vector_backend`
|
||||
// records which one is canonical for that index.
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LanceMigrateRequest {
|
||||
/// Optional bucket override. Defaults to whatever the existing
|
||||
/// IndexMeta says, or "primary" for indexes that don't exist yet.
|
||||
#[serde(default)]
|
||||
bucket: Option<String>,
|
||||
}
|
||||
|
||||
/// Read the existing Parquet vector file for `index_name` from object
|
||||
/// storage, hand the bytes to vectord-lance, return migration stats.
|
||||
/// The original Parquet file is left intact — both backends coexist
|
||||
/// after migration.
|
||||
async fn lance_migrate(
|
||||
State(state): State<VectorState>,
|
||||
Path(index_name): Path<String>,
|
||||
Json(req): Json<LanceMigrateRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let meta = state.index_registry.get(&index_name).await
|
||||
.ok_or((StatusCode::NOT_FOUND, format!("index not found: {index_name}")))?;
|
||||
let bucket = req.bucket.unwrap_or(meta.bucket.clone());
|
||||
|
||||
// Pull the Parquet bytes via storaged::ops — same path as the
|
||||
// existing embedding loader uses.
|
||||
let store = state.bucket_registry.get(&bucket)
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||
let bytes = storaged::ops::get(&store, &meta.storage_key).await
|
||||
.map_err(|e| (StatusCode::NOT_FOUND, format!("read parquet: {e}")))?;
|
||||
|
||||
let lance_store = state.lance.store_for_new(&index_name, &bucket).await
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||
|
||||
let stats = lance_store.migrate_from_parquet_bytes(&bytes).await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
|
||||
|
||||
tracing::info!(
|
||||
"lance migrate '{}': {} rows, {}d, {} bytes on disk, {:.2}s",
|
||||
index_name, stats.rows_written, stats.dimensions,
|
||||
stats.disk_bytes, stats.duration_secs,
|
||||
);
|
||||
|
||||
Ok::<_, (StatusCode, String)>(Json(serde_json::json!({
|
||||
"index_name": index_name,
|
||||
"bucket": bucket,
|
||||
"lance_path": lance_store.path(),
|
||||
"stats": stats,
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LanceIndexRequest {
|
||||
#[serde(default = "default_partitions")]
|
||||
num_partitions: u32,
|
||||
#[serde(default = "default_bits")]
|
||||
num_bits: u32,
|
||||
#[serde(default = "default_subvectors")]
|
||||
num_sub_vectors: u32,
|
||||
}
|
||||
|
||||
fn default_partitions() -> u32 { 316 } // ≈√100K — sane for the reference dataset
|
||||
fn default_bits() -> u32 { 8 }
|
||||
fn default_subvectors() -> u32 { 48 } // 768/48 = 16 dims per subvector
|
||||
|
||||
/// Build the IVF_PQ index on the Lance dataset.
|
||||
async fn lance_build_index(
|
||||
State(state): State<VectorState>,
|
||||
Path(index_name): Path<String>,
|
||||
Json(req): Json<LanceIndexRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let lance_store = state.lance.store_for(&index_name).await
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||
match lance_store.build_index(req.num_partitions, req.num_bits, req.num_sub_vectors).await {
|
||||
Ok(stats) => Ok(Json(stats)),
|
||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LanceSearchRequest {
|
||||
/// Plain text query — embedded server-side for symmetry with the
|
||||
/// existing /vectors/search path.
|
||||
query: String,
|
||||
#[serde(default = "default_top_k")]
|
||||
top_k: usize,
|
||||
}
|
||||
|
||||
fn default_top_k() -> usize { 5 }
|
||||
|
||||
/// Vector search against a Lance dataset. Embeds the query text via the
|
||||
/// sidecar then calls Lance's nearest-neighbor scanner.
|
||||
async fn lance_search(
|
||||
State(state): State<VectorState>,
|
||||
Path(index_name): Path<String>,
|
||||
Json(req): Json<LanceSearchRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let embed_resp = state.ai_client
|
||||
.embed(EmbedRequest { texts: vec![req.query.clone()], model: None })
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("embed: {e}")))?;
|
||||
if embed_resp.embeddings.is_empty() {
|
||||
return Err((StatusCode::BAD_GATEWAY, "no embedding returned".into()));
|
||||
}
|
||||
let qv: Vec<f32> = embed_resp.embeddings[0].iter().map(|&x| x as f32).collect();
|
||||
|
||||
let lance_store = state.lance.store_for(&index_name).await
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||
|
||||
let t0 = std::time::Instant::now();
|
||||
let hits = lance_store.search(&qv, req.top_k).await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"index_name": index_name,
|
||||
"query": req.query,
|
||||
"method": "lance_ivf_pq",
|
||||
"latency_us": t0.elapsed().as_micros() as u64,
|
||||
"results": hits,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Random-access fetch by doc_id — the O(1) lookup that's basically
|
||||
/// impossible in our Parquet path without scanning the whole file.
|
||||
async fn lance_get_doc(
|
||||
State(state): State<VectorState>,
|
||||
Path((index_name, doc_id)): Path<(String, String)>,
|
||||
) -> impl IntoResponse {
|
||||
let lance_store = state.lance.store_for(&index_name).await
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||
let t0 = std::time::Instant::now();
|
||||
match lance_store.get_by_doc_id(&doc_id).await {
|
||||
Ok(Some(row)) => Ok(Json(serde_json::json!({
|
||||
"index_name": index_name,
|
||||
"doc_id": doc_id,
|
||||
"latency_us": t0.elapsed().as_micros() as u64,
|
||||
"row": row,
|
||||
}))),
|
||||
Ok(None) => Err((StatusCode::NOT_FOUND, format!("doc_id not found: {doc_id}"))),
|
||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LanceAppendRequest {
|
||||
/// Optional source tag — set on every appended row.
|
||||
#[serde(default)]
|
||||
source: Option<String>,
|
||||
rows: Vec<LanceAppendRow>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LanceAppendRow {
|
||||
doc_id: String,
|
||||
#[serde(default)]
|
||||
chunk_idx: Option<i32>,
|
||||
chunk_text: String,
|
||||
/// Pre-computed embedding. Caller is responsible for ensuring it
|
||||
/// matches the dataset's dimensions and embedding model.
|
||||
vector: Vec<f32>,
|
||||
}
|
||||
|
||||
async fn lance_append(
|
||||
State(state): State<VectorState>,
|
||||
Path(index_name): Path<String>,
|
||||
Json(req): Json<LanceAppendRequest>,
|
||||
) -> impl IntoResponse {
|
||||
if req.rows.is_empty() {
|
||||
return Err((StatusCode::BAD_REQUEST, "rows array is empty".into()));
|
||||
}
|
||||
let lance_store = state.lance.store_for(&index_name).await
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||
|
||||
let mut doc_ids = Vec::with_capacity(req.rows.len());
|
||||
let mut chunk_idxs = Vec::with_capacity(req.rows.len());
|
||||
let mut chunk_texts = Vec::with_capacity(req.rows.len());
|
||||
let mut vectors = Vec::with_capacity(req.rows.len());
|
||||
for r in req.rows {
|
||||
doc_ids.push(r.doc_id);
|
||||
chunk_idxs.push(r.chunk_idx.unwrap_or(0));
|
||||
chunk_texts.push(r.chunk_text);
|
||||
vectors.push(r.vector);
|
||||
}
|
||||
|
||||
match lance_store.append(req.source, doc_ids, chunk_idxs, chunk_texts, vectors).await {
|
||||
Ok(stats) => Ok(Json(stats)),
|
||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn lance_stats(
|
||||
State(state): State<VectorState>,
|
||||
Path(index_name): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let lance_store = state.lance.store_for(&index_name).await
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||
match lance_store.stats().await {
|
||||
Ok(s) => Ok(Json(s)),
|
||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user