Phase 17: Model profiles + scoped search — the LLM-brain keystone

Implements PRD invariant 9 ("every reader gets its own profile") and
completes the multi-model substrate vision. Local models (or agents)
bind to a named set of datasets; activation pre-loads their vector
indexes into memory; search enforces scope.

Schema (shared::types):
- ModelProfile { id, ollama_name, description, bound_datasets,
                 hnsw_config, embed_model, created_at, created_by }
- ProfileHnswConfig mirrors vectord::trial::HnswConfig to avoid a
  cross-crate dep cycle. Default (ec=80, es=30) matches the Phase 15
  trial winner.
- bound_datasets can reference raw dataset names OR AiView names
  (both register as DataFusion tables with the same name, so mixing
  raw tables and PII-redacted views composes naturally)

Catalog (catalogd::registry):
- put_profile validates id is a slug (alphanumeric + -_ only) and
  every binding resolves to an existing dataset or view
- Persistence at _catalog/profiles/{id}.json, loaded on rebuild
- get_profile / list_profiles / delete_profile

HTTP endpoints:
- POST /catalog/profiles  (create/update)
- GET  /catalog/profiles  (list)
- GET/DELETE /catalog/profiles/{id}
- POST /vectors/profile/{id}/activate  (HNSW hot-load)
- POST /vectors/profile/{id}/search    (scope-enforced)

Activation (vectord::service::activate_profile):
- For each bound dataset, find vector indexes with matching source
- Pre-load embeddings into EmbeddingCache
- Build HNSW with profile's config
- Report warmed indexes + per-binding failures + duration
- Failures on individual bindings don't abort — "substrate keeps
  working" per ADR-017

Scoped search (vectord::service::profile_scoped_search):
- Look up profile, verify index.source ∈ profile.bound_datasets
- Returns 403 with allowed bindings list if out-of-scope
- Uses HNSW if index is warm, brute-force cosine otherwise (graceful
  degradation — no "must activate first" friction)

Bug fix surfaced during testing: vectord::refresh::try_update_index_meta
was a no-op for first-time indexes, so threat_intel_v1 and
kb_team_runs_v1 (both built via refresh after Phase C shipped) didn't
show up in the index registry. Now it auto-infers the source from the
index name convention (`{source}_vN`) and registers new metadata with
reasonable defaults.

End-to-end verified:
- Created security-analyst profile bound to [threat_intel]
- POST /vectors/profile/security-analyst/activate → warmed
  threat_intel_v1 (54 vectors) in 156ms, HNSW built
- Within-scope search: method=hnsw, returned relevant IP indicators
- Out-of-scope: tried to search resumes_100k_v2 (source=candidates)
  → 403 "profile 'security-analyst' is not bound to 'candidates' —
    allowed bindings: [\"threat_intel\"]"
- staffing-recruiter profile created bound to candidates + placements;
  search without activation fell through to brute_force (graceful)

Deferred (Phase 17 followups):
- VRAM-aware activation (unload-then-load via Ollama keep_alive=0)
  — Ollama already handles this; we don't need to reinvent
- Model-identity in audit trail — Phase 13 has role-based audit;
  adding model_id is ~20 LOC when we want it
- Profile bucket pre-load (profile:user bucket mount) — Phase 17.5

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-04-16 10:09:43 -05:00
co-authored by Claude Opus 4.6
parent d87f2ccac6
commit a293502265
6 changed files with 437 additions and 7 deletions
+36 -5
View File
@@ -167,6 +167,11 @@ pub async fn refresh_index(
if new_docs == 0 {
tracing::info!("refresh '{}': no new docs to embed", dataset_name);
// Even on no-op, make sure index metadata is registered so
// downstream discovery (profile activation, A/B search) sees
// indexes built by earlier refresh calls that predated the
// auto-register behavior.
let _ = try_update_index_meta(index_registry, &req.index_name, existing.len()).await;
registry.clear_embeddings_stale(dataset_name).await?;
return Ok(RefreshResult {
index_name: req.index_name.clone(),
@@ -263,8 +268,10 @@ pub async fn refresh_index(
})
}
/// Best-effort refresh of index registry metadata. If the index exists,
/// bump the chunk_count; if not, this is a no-op.
/// Best-effort refresh of index registry metadata.
/// - If the index already exists, bump the chunk_count.
/// - If it's brand new (first-time build via refresh), REGISTER it so
/// downstream discovery (profile activation, A/B search) can find it.
async fn try_update_index_meta(
index_registry: &IndexRegistry,
index_name: &str,
@@ -272,8 +279,32 @@ async fn try_update_index_meta(
) -> Result<(), String> {
if let Some(mut meta) = index_registry.get(index_name).await {
meta.chunk_count = chunk_count;
index_registry.register(meta).await
} else {
Ok(())
return index_registry.register(meta).await;
}
// First-time registration — infer reasonable defaults. Convention:
// index name `{source}_v1` or `{source}_vN` implies a source dataset
// named by stripping the `_vN` suffix. Otherwise use the index name
// as the source and let the caller patch later.
let source = match index_name.rsplit_once('_') {
Some((base, suffix)) if suffix.starts_with('v') && suffix[1..].chars().all(|c| c.is_ascii_digit()) => {
base.to_string()
}
_ => index_name.to_string(),
};
let meta = crate::index_registry::IndexMeta {
index_name: index_name.to_string(),
source,
model_name: "nomic-embed-text".to_string(),
model_version: "latest".to_string(),
dimensions: 768,
chunk_count,
doc_count: chunk_count,
chunk_size: 500,
overlap: 50,
storage_key: format!("vectors/{index_name}.parquet"),
created_at: chrono::Utc::now(),
build_time_secs: 0.0,
chunks_per_sec: 0.0,
};
index_registry.register(meta).await
}