Knowledge Store¶
The SPOT Knowledge Store is the platform's RAG (retrieval-augmented generation) layer. Context providers deposit tagged company documents; analyzers fetch them on demand by semantic similarity + tag expression. Neither side knows the other exists ; they share only the tag vocabulary and the document vectors.
SDK reference
Both providers and analyzers talk to the store through spot-sdk 2. The SDK overview lists what ships, the Python SDK reference covers the symbols used throughout this guide (KnowledgeClient, KnowledgeDocument, KnowledgeTag, chunk_text, content_hash, FakeKnowledgeClient), and the knowledge service's API document describes its HTTP routes if you are integrating from a language other than Python.
Two phases¶
Ingestion (out-of-band, scheduled) ; the api-gateway runs a cron-style scheduler that calls each enabled context provider's POST /internal/sync on a cadence declared in spot.yaml, with the provider's own token. The provider reads from its source system and writes KnowledgeDocuments to the store: replace_source for a full snapshot, bulk_upsert for changes.
Consumption (per-email, on-demand) ; an analyzer calls KnowledgeClient.for_analysis(request).fetch(...) whenever it needs context during analysis. The store embeds the query, filters by tag expression, returns top-K documents by cosine similarity.
Data model¶
Every document has the same shape:
class KnowledgeDocument(BaseModel):
id: str # stable id, unique among the source's documents
content: str # text payload an LLM or analyzer reads
tags: list[str] = [] # AND/OR-filterable at query time
metadata: dict = {} # structured fields (email, title, url, ...)
source: str = "" # the provider that wrote it
updated_at: datetime | None = None
expires_at: datetime | None = None # optional TTL, cleaned up hourly
score: float | None = None # populated on query results, 0.0 – 1.0
A document is identified by its source and its id. A provider chooses ids unique among its own documents: two providers may use the same id, and each has its own document under it. The source is the provider's plugin id; a provider leaves it empty and the store sets it.
A document's id appears in URL paths and in the logs (the access line of each view or deletion), so a provider never puts personal data in it, an email address or a name: it uses a stable key or a hash, and keeps such data in content or metadata.
tags is the single categorisation axis ; there is no type field. Use the shared-vocabulary constants from spot_sdk.knowledge_tags (EMPLOYEE, WIKI_PAGE, POLICY, EXECUTIVE, FINANCE, ...); custom tags like acme:jira_issue are fine.
Tag expressions¶
Queries filter on a tiny mini-language:
| Expression | Semantics |
|---|---|
employee | has tag employee |
employee+executive | has BOTH |
wiki_page\|policy | has EITHER |
employee+executive\|director | (employee AND executive) OR director |
"" or None | no tag filter |
+ (AND) binds tighter than | (OR). No parentheses. Tokens must match [a-z0-9][a-z0-9_\-:]* ; anything else is rejected, so the expression is always safe to splice into SQL.
Authentication¶
Every route of the store except /health and /readiness needs a credential:
- the internal key (
X-Internal-API-Key), for core services: whoever sends it is core, so it must not reach a plugin; - a plugin token (
Authorization: Bearer spot_pt_...) withknowledge:readfor reads (/query,/stats,/sources,/documents,GET /sources/{source}/documents/{id}) orknowledge:writefor writes (/upsert,/bulk-upsert,PUT /sources/{source}/documents,DELETE /sources/{source}/documents/{id}).
Analyzers hold knowledge:read, context providers knowledge:write. A provider writes and deletes only its own source's documents: an empty source takes its plugin id, and a write naming another source, or a deletion of another source's document, is refused with 403. A write never touches another source's document, whatever id it uses. Only the provider whose id is the source replaces a source's documents: core and any other plugin are refused with 403. Core names each document's source: a core write of a document without one answers 422. A plugin reads its token from /run/secrets/spot_plugin_token; see Context Providers for the details and for manual plugins.
Ingestion side¶
Ingestion is the provider's responsibility: each provider's sync handler builds KnowledgeDocuments and writes them with KnowledgeClient.replace_source(...), which makes them the provider's whole set in one transaction, or bulk_upsert(...). A re-sync leaves a document whose content, tags, metadata and expiry are unchanged as it is, without embedding it again: updated_at is the time a document last changed, not the time of the last sync (the provider's last_sync_at). The Context Providers guide is the canonical home for the routes a provider serves, the SDK code recipe, packaging, and the sync_schedule / manual-trigger lifecycle. This page focuses on what ends up in the store and how consumers read it back.
For long wikis or policy docs, providers split into chunks with spot_sdk.knowledge.chunk_text(text, max_chars=2000, overlap=200) and upsert each chunk with metadata["parent_id"] pointing at the source doc ; embeddings are better per-chunk and retrieval is finer-grained.
Querying from an analyzer¶
from spot_sdk import (
AnalyzeRequest,
AnalyzerVerdict,
KnowledgeClient,
PluginSettings,
Signal,
VerdictClass,
)
async def analyze(request: AnalyzeRequest, settings: PluginSettings) -> AnalyzerVerdict:
sender = request.email.from_
if sender is None:
return AnalyzerVerdict(
verdict=VerdictClass.UNKNOWN, score=0, confidence=0, summary="no sender"
)
kb = KnowledgeClient.for_analysis(request)
# "Does this email claim to be from an executive?"
hits = await kb.fetch(
tags="employee+executive|director",
text=sender.display_name or sender.address,
top_k=1,
)
if hits and hits[0].metadata.get("email") != sender.address:
return AnalyzerVerdict(
verdict=VerdictClass.SUSPICIOUS,
score=70,
confidence=80,
signals=[
Signal(
name="exec.display_name_spoof",
weight=60,
evidence=f"{sender.display_name} is {hits[0].id}",
location="header:From",
)
],
)
...
Use KnowledgeClient.for_analysis(request): it reads the URL from SPOT_KNOWLEDGE_URL and the token from /run/secrets/spot_plugin_token, and applies the stage's retrieval_limits, which the orchestrator sends in request.retrieval_limits.
A retrieved document can go into a Signal's evidence, so the analyst UI can explain why.
Retrieval limits (operator policy)¶
Workflow stages can declare caps in spot.yaml:
stages:
- name: context-checks
retrieval_limits:
max_top_k: 10 # cap top_k requested by any analyzer here
min_score_floor: 0.3 # raise the floor even if analyzer asked lower
analyzers:
- id: analyzer-llm
- id: analyzer-rules
The orchestrator sends them in each AnalyzeRequest of the stage (retrieval_limits) and the SDK enforces them transparently ; caps only, never raises what the analyzer asked for.
Provider ingestion schedule¶
Each context provider declares its own sync_schedule (cron) and sync_timeout_ms on its spot.yaml entry, and the api-gateway scheduler ticks it from there. The supported cron grammar, the manual admin-trigger endpoints, and the dashboard's "Sync now" button are all documented in the Context Providers guide.
Stats¶
GET /api/v1/knowledge/stats returns:
Embedding backend (Ollama)¶
Embeddings are computed by Ollama (configurable via the Embedder protocol if you need a different backend). The knowledge service expects it at OLLAMA_URL (default http://ollama:11434) with the model named in EMBEDDING_MODEL (default bge-m3).
Operators have two options:
- External Ollama. Run Ollama anywhere reachable from the
spot_spot-networkDocker network and setOLLAMA_URLin/opt/spot/deploy/.env. An installed analyzer-llm calls Ollama too, through the egress gateway, by host name (not an address), through the operator's proxy unlessnoProxylists it, so it must be reachable from there as well. The knowledge service callsOLLAMA_URLdirectly and ignoresHTTP_PROXY/HTTPS_PROXY, so an external Ollama must be reachable without an egress proxy. - Bundled side-car. Add
ollamatoCOMPOSE_PROFILESin/opt/spot/deploy/.env; the deployment files ship anollama+ollama-initservice pair under that profile.ollama-initruns once on first boot, pullsEMBEDDING_MODEL(and anything inOLLAMA_EXTRA_MODELS, space-separated), and exits.
Readiness¶
The knowledge service exposes two endpoints for this:
GET /health; liveness only (returns{"status": "ok"}).GET /readiness; probes Ollama with a lightweightGET /api/versionand returns:
{
"status": "ok", // or "degraded"
"embedding": {
"url": "http://ollama:11434",
"model": "bge-m3",
"reachable": true
}
}
The api-gateway proxy at GET /api/v1/knowledge/readiness (any role) combines that probe with installed-context-provider state so the dashboard can render a single banner ; see the next section.
Failure mode¶
When Ollama is unreachable, any request that touches embeddings (/upsert, /bulk-upsert, /query) returns 503 with a payload like:
{
"detail": "Embedding backend unreachable at http://ollama:11434. Ensure Ollama is running and reachable, and that the 'bge-m3' model has been pulled.",
"embedding": {
"url": "http://ollama:11434",
"model": "bge-m3",
"reachable": false
}
}
Raw httpx2.ConnectErrors are caught by a FastAPI exception handler and translated into that shape ; callers never see an opaque 500.
Web dashboard¶
The Knowledge Store is managed from the Knowledge top-level page, which every signed-in user reads; admins also delete documents. Three surfaces:
/knowledge ; browse & search¶
- Readiness banner: shown (amber) whenever any required component is missing ; knowledge service unreachable, embedding backend down, or zero enabled context providers. Each row explains how to fix the specific gap (point
OLLAMA_URLsomewhere real or enable the bundled side-car; install acontext_providerplugin). Disappears once all three components report green. - Summary cards: total docs, distinct sources, top tags (each tag chip is a filter link).
- Semantic search panel:
text+ optional tag expression +top_k+min_score. Mimics an analyzer's retrieval and lists ranked hits with cosine-similarity scores. - Browse table: paginated list with filters for tag expression, source, and a substring match on id/content. Admin users can delete a document inline (confirmation required).
/knowledge/{source}/{id} ; document inspector¶
Metadata grid, clickable tag chips (each filters back to /knowledge?tags=...), raw content, and pretty-printed JSON metadata. Admin users get a Delete button.
Context-provider integration¶
/plugins/context_provider/{id}shows sync info (last run, next run, doc count, last error) and links to the Knowledge Store pre-filtered bysource=<provider_id>./config/plugin/context_provider/{id}has a Scheduling section to edit thesync_schedulecron andsync_timeout_msfrom the dashboard. Saving there reconciles the running scheduler immediately ; no process restart.
All dashboard operations go through the api-gateway proxies at /api/v1/knowledge/* (documents list, query, delete, stats, sources); the dashboard never talks to the knowledge service directly.
Architecture (short)¶
services/knowledge; in-core microservice (port 8000). FastAPI, asyncpg, pgvector. Embedding via Ollama (pluggable via anEmbedderprotocol). Redis caches the embed lookups and the full-query results.- Postgres:
knowledge_documentstable with HNSW index onembedding vector(1024), GIN index ontags, partial index onexpires_at. Alembic migration 008 creates it; migration 017 keys it by(source, id). - api-gateway:
KnowledgeScheduler(one asyncio task per provider, calling/internal/syncwith the provider's token) and the proxies at/api/v1/knowledge/*and/api/v1/plugins/context_provider/{id}/sync. - SDK:
KnowledgeClient,KnowledgeDocument,KnowledgeTag,chunk_text,content_hash, plusFakeKnowledgeClientfor plugin unit tests.
Not in scope¶
- Multi-tenancy / per-org isolation ; global store.
- Replacing pgvector with Qdrant / Weaviate ;
KnowledgeStorekeeps the backend abstract enough to swap later. - LLM-driven adaptive retrieval ; the analyzer decides what to query.