Ceres — Semantic Search Engine for Open Data Portals
Ceres harvests metadata from CKAN open data portals and indexes them with vector embeddings, enabling semantic search across fragmented data sources.
Repository: https://github.com/AndreaBozzo/Ceres
License: Apache-2.0 | Rust edition: 2024 | MSRV: 1.88+
Pipeline
Metadata: Portal URL → PortalClient (fetch) → DeltaDetector (content_hash) → DatasetStore (upsert, no embedding)
Embedding: DatasetStore (pending) → EmbeddingProvider (vector) → DatasetStore (update embedding)
Combined: HarvestPipeline = HarvestService + EmbeddingService
Harvesting and embedding are decoupled: HarvestService handles metadata (no API key needed with --metadata-only), EmbeddingService handles vectors, and HarvestPipeline composes both. Each stage is a trait, so every component can be swapped or mocked independently.
Crate Map
| Crate |
Purpose |
Key Exports |
ceres-core |
Business logic, traits, services |
HarvestService, EmbeddingService, HarvestPipeline, SearchService, ExportService, WorkerService, CircuitBreaker, traits |
ceres-client |
CKAN API client, Gemini/OpenAI clients |
CkanClient, GeminiClient, OpenAIClient, PortalClientFactoryEnum, EmbeddingProviderEnum |
ceres-db |
PostgreSQL + pgvector repository |
DatasetRepository, HarvestJobRepository |
ceres-server |
Axum REST API with Swagger UI |
Routes, DTOs, bearer auth, OpenAPI/Swagger |
ceres-cli |
Command-line interface |
harvest, embed, search, export, stats subcommands |
Core Traits (ceres-core::traits)
pub trait EmbeddingProvider: Send + Sync + Clone {
fn name(&self) -> &'static str;
fn dimension(&self) -> usize;
fn generate(&self, text: &str) -> impl Future<Output = Result<Vec<f32>, AppError>> + Send;
fn max_batch_size(&self) -> usize { 1 }
fn generate_batch(&self, texts: &[String]) -> impl Future<Output = Result<Vec<Vec<f32>>, AppError>> + Send;
}
pub trait PortalClient: Send + Sync + Clone {
type PortalData: Send;
fn portal_type(&self) -> &'static str;
fn base_url(&self) -> &str;
fn list_dataset_ids(&self) -> impl Future<Output = Result<Vec<String>, AppError>> + Send;
fn get_dataset(&self, id: &str) -> impl Future<Output = Result<Self::PortalData, AppError>> + Send;
fn into_new_dataset(data: Self::PortalData, portal_url: &str, url_template: Option<&str>, language: &str) -> NewDataset;
fn search_modified_since(&self, since: DateTime<Utc>) -> impl Future<Output = Result<Vec<Self::PortalData>, AppError>> + Send;
fn search_all_datasets(&self) -> impl Future<Output = Result<Vec<Self::PortalData>, AppError>> + Send;
}
pub trait PortalClientFactory: Send + Sync + Clone {
type Client: PortalClient;
fn create(&self, portal_url: &str, portal_type: PortalType) -> Result<Self::Client, AppError>;
}
pub trait DatasetStore: Send + Sync + Clone {
fn get_by_id(&self, id: Uuid) -> impl Future<Output = Result<Option<Dataset>, AppError>> + Send;
fn get_hashes_for_portal(&self, portal_url: &str) -> impl Future<Output = Result<HashMap<String, Option<String>>, AppError>> + Send;
fn upsert(&self, dataset: &NewDataset) -> impl Future<Output = Result<Uuid, AppError>> + Send;
fn batch_upsert(&self, datasets: &[NewDataset]) -> impl Future<Output = Result<Vec<Uuid>, AppError>> + Send;
fn search(&self, query_vector: Vec<f32>, limit: usize) -> impl Future<Output = Result<Vec<SearchResult>, AppError>> + Send;
fn list_stream<'a>(&'a self, portal_filter: Option<&'a str>, limit: Option<usize>) -> BoxStream<'a, Result<Dataset, AppError>>;
fn get_last_sync_time(&self, portal_url: &str) -> impl Future<Output = Result<Option<DateTime<Utc>>, AppError>> + Send;
fn record_sync_status(&self, portal_url: &str, sync_time: DateTime<Utc>, sync_mode: &str, sync_status: &str, datasets_synced: i32) -> impl Future<Output = Result<(), AppError>> + Send;
fn health_check(&self) -> impl Future<Output = Result<(), AppError>> + Send;
// + update_timestamp_only, batch_update_timestamps, get_duplicate_titles
// Stale detection
fn mark_stale_datasets(&self, portal_url: &str, sync_start: DateTime<Utc>) -> impl Future<Output = Result<u64, AppError>> + Send;
fn mark_stale_by_exclusion(&self, portal_url: &str, seen_ids: &[String]) -> impl Future<Output = Result<u64, AppError>> + Send;
// Pending embeddings
fn list_pending_embeddings(&self, portal_filter: Option<&str>, limit: usize) -> impl Future<Output = Result<Vec<Dataset>, AppError>> + Send;
}
Key Types
| Type |
Module |
Purpose |
Dataset |
ceres_core::models |
Complete dataset row (id, original_id, source_portal, url, title, description, embedding, metadata, timestamps, content_hash, is_stale) |
NewDataset |
ceres_core::models |
Insert/update DTO. Has compute_content_hash() for delta detection |
SearchResult |
ceres_core::models |
Dataset + similarity_score (0.0-1.0) |
DatabaseStats |
ceres_core::models |
total_datasets, datasets_with_embeddings, stale_datasets, total_portals, last_update |
HarvestJob |
ceres_core::job |
Queued harvest job with status, retry info, portal config |
JobStatus |
ceres_core::job |
Enum: Pending, Running, Completed, Failed, Cancelled |
SyncStats |
ceres_core::sync |
created, updated, unchanged, failed, skipped counts |
SyncOutcome |
ceres_core::sync |
Per-dataset outcome: Created, Updated, Unchanged, Failed, Skipped |
BatchHarvestSummary |
ceres_core::sync |
Aggregated results from batch harvesting multiple portals |
PortalEntry |
ceres_core::config |
Portal config: name, url, type, enabled, url_template, language |
AppError |
ceres_core::error |
Error enum with is_retryable() and should_trip_circuit() |
EmbeddingStats |
ceres_core::embedding |
embedded, failed, skipped, total counts from an embedding run |
HarvestPipeline |
ceres_core::pipeline |
Composes HarvestService + EmbeddingService for combined harvest-then-embed |
CircuitBreaker |
ceres_core::circuit_breaker |
Closed -> Open -> HalfOpen state machine |
Quick Start
# Install
cargo install ceres-search
# Start PostgreSQL + pgvector
docker compose up db -d
# Configure
cp .env.example .env # Edit with your Gemini/OpenAI API key
# Run migrations
make migrate
# Harvest a portal
ceres harvest https://dati.comune.milano.it
# Harvest all configured portals
ceres harvest
# Search
ceres search "trasporto pubblico" --limit 5
# Export
ceres export --format jsonl > datasets.jsonl
# Stats
ceres stats
Reference Guides
| Topic |
File |
When to Read |
| Architecture deep-dive |
references/architecture.md |
Understanding crate graph, services, error handling, database schema |
| CLI & REST API |
references/cli-and-server.md |
Running CLI commands, calling API endpoints, env vars, deployment |
| Harvesting system |
references/harvesting.md |
Two-tier optimization, delta detection, streaming, circuit breaker |
| Extending Ceres |
references/extending.md |
Implementing custom EmbeddingProvider, PortalClient, or DatasetStore |
| Contributing |
references/contributing.md |
Dev setup, testing, CI, code style |
Version Notes
- Current version: 0.3.1
- crates.io package:
ceres-search
- Harvesting and embedding are decoupled:
--metadata-only harvests without API key, embed command generates embeddings separately
- Stale dataset detection: datasets removed from portals are soft-marked (
is_stale) during full syncs
- Supports Gemini (768d,
gemini-embedding-001) and OpenAI (1536d/3072d, text-embedding-3-small/large) embeddings
- 25+ pre-configured CKAN portals (354k+ datasets)
- HuggingFace dataset:
AndreaBozzo/ceres-open-data-index
1---2name: ceres3description: Use when working with Ceres — a Rust semantic search engine for open data portals that harvests CKAN metadata and indexes it with vector embeddings (pgvector). Covers CLI commands (harvest, search, export, stats), REST API endpoints, portal configuration (portals.toml), embedding providers (Gemini, OpenAI), architecture, extending via traits, and contributing to the Ceres codebase.4---5
6# Ceres — Semantic Search Engine for Open Data Portals
7
8Ceres harvests metadata from CKAN open data portals and indexes them with vector embeddings, enabling semantic search across fragmented data sources.
9
10**Repository:** https://github.com/AndreaBozzo/Ceres
11**License:** Apache-2.0 | **Rust edition:** 2024 | **MSRV:** 1.88+
12
13## Pipeline
14
15```
16Metadata: Portal URL → PortalClient (fetch) → DeltaDetector (content_hash) → DatasetStore (upsert, no embedding)
17Embedding: DatasetStore (pending) → EmbeddingProvider (vector) → DatasetStore (update embedding)
18Combined: HarvestPipeline = HarvestService + EmbeddingService
19```
20
21Harvesting and embedding are decoupled: `HarvestService` handles metadata (no API key needed with `--metadata-only`), `EmbeddingService` handles vectors, and `HarvestPipeline` composes both. Each stage is a trait, so every component can be swapped or mocked independently.
22
23## Crate Map
24
25| Crate | Purpose | Key Exports |
26|---|---|---|
27| `ceres-core` | Business logic, traits, services | `HarvestService`, `EmbeddingService`, `HarvestPipeline`, `SearchService`, `ExportService`, `WorkerService`, `CircuitBreaker`, traits |
28| `ceres-client` | CKAN API client, Gemini/OpenAI clients | `CkanClient`, `GeminiClient`, `OpenAIClient`, `PortalClientFactoryEnum`, `EmbeddingProviderEnum` |
29| `ceres-db` | PostgreSQL + pgvector repository | `DatasetRepository`, `HarvestJobRepository` |
30| `ceres-server` | Axum REST API with Swagger UI | Routes, DTOs, bearer auth, OpenAPI/Swagger |
31| `ceres-cli` | Command-line interface | `harvest`, `embed`, `search`, `export`, `stats` subcommands |
32
33## Core Traits (`ceres-core::traits`)
34
35```rust
36pub trait EmbeddingProvider: Send + Sync + Clone {
37 fn name(&self) -> &'static str;
38 fn dimension(&self) -> usize;
39 fn generate(&self, text: &str) -> impl Future<Output = Result<Vec<f32>, AppError>> + Send;
40 fn max_batch_size(&self) -> usize { 1 }
41 fn generate_batch(&self, texts: &[String]) -> impl Future<Output = Result<Vec<Vec<f32>>, AppError>> + Send;
42}
43
44pub trait PortalClient: Send + Sync + Clone {
45 type PortalData: Send;
46 fn portal_type(&self) -> &'static str;
47 fn base_url(&self) -> &str;
48 fn list_dataset_ids(&self) -> impl Future<Output = Result<Vec<String>, AppError>> + Send;
49 fn get_dataset(&self, id: &str) -> impl Future<Output = Result<Self::PortalData, AppError>> + Send;
50 fn into_new_dataset(data: Self::PortalData, portal_url: &str, url_template: Option<&str>, language: &str) -> NewDataset;
51 fn search_modified_since(&self, since: DateTime<Utc>) -> impl Future<Output = Result<Vec<Self::PortalData>, AppError>> + Send;
52 fn search_all_datasets(&self) -> impl Future<Output = Result<Vec<Self::PortalData>, AppError>> + Send;
53}
54
55pub trait PortalClientFactory: Send + Sync + Clone {
56 type Client: PortalClient;
57 fn create(&self, portal_url: &str, portal_type: PortalType) -> Result<Self::Client, AppError>;
58}
59
60pub trait DatasetStore: Send + Sync + Clone {
61 fn get_by_id(&self, id: Uuid) -> impl Future<Output = Result<Option<Dataset>, AppError>> + Send;
62 fn get_hashes_for_portal(&self, portal_url: &str) -> impl Future<Output = Result<HashMap<String, Option<String>>, AppError>> + Send;
63 fn upsert(&self, dataset: &NewDataset) -> impl Future<Output = Result<Uuid, AppError>> + Send;
64 fn batch_upsert(&self, datasets: &[NewDataset]) -> impl Future<Output = Result<Vec<Uuid>, AppError>> + Send;
65 fn search(&self, query_vector: Vec<f32>, limit: usize) -> impl Future<Output = Result<Vec<SearchResult>, AppError>> + Send;
66 fn list_stream<'a>(&'a self, portal_filter: Option<&'a str>, limit: Option<usize>) -> BoxStream<'a, Result<Dataset, AppError>>;
67 fn get_last_sync_time(&self, portal_url: &str) -> impl Future<Output = Result<Option<DateTime<Utc>>, AppError>> + Send;
68 fn record_sync_status(&self, portal_url: &str, sync_time: DateTime<Utc>, sync_mode: &str, sync_status: &str, datasets_synced: i32) -> impl Future<Output = Result<(), AppError>> + Send;
69 fn health_check(&self) -> impl Future<Output = Result<(), AppError>> + Send;
70 // + update_timestamp_only, batch_update_timestamps, get_duplicate_titles
71 // Stale detection
72 fn mark_stale_datasets(&self, portal_url: &str, sync_start: DateTime<Utc>) -> impl Future<Output = Result<u64, AppError>> + Send;
73 fn mark_stale_by_exclusion(&self, portal_url: &str, seen_ids: &[String]) -> impl Future<Output = Result<u64, AppError>> + Send;
74 // Pending embeddings
75 fn list_pending_embeddings(&self, portal_filter: Option<&str>, limit: usize) -> impl Future<Output = Result<Vec<Dataset>, AppError>> + Send;
76}
77```
78
79## Key Types
80
81| Type | Module | Purpose |
82|---|---|---|
83| `Dataset` | `ceres_core::models` | Complete dataset row (id, original_id, source_portal, url, title, description, embedding, metadata, timestamps, content_hash, is_stale) |
84| `NewDataset` | `ceres_core::models` | Insert/update DTO. Has `compute_content_hash()` for delta detection |
85| `SearchResult` | `ceres_core::models` | Dataset + similarity_score (0.0-1.0) |
86| `DatabaseStats` | `ceres_core::models` | total_datasets, datasets_with_embeddings, stale_datasets, total_portals, last_update |
87| `HarvestJob` | `ceres_core::job` | Queued harvest job with status, retry info, portal config |
88| `JobStatus` | `ceres_core::job` | Enum: Pending, Running, Completed, Failed, Cancelled |
89| `SyncStats` | `ceres_core::sync` | created, updated, unchanged, failed, skipped counts |
90| `SyncOutcome` | `ceres_core::sync` | Per-dataset outcome: Created, Updated, Unchanged, Failed, Skipped |
91| `BatchHarvestSummary` | `ceres_core::sync` | Aggregated results from batch harvesting multiple portals |
92| `PortalEntry` | `ceres_core::config` | Portal config: name, url, type, enabled, url_template, language |
93| `AppError` | `ceres_core::error` | Error enum with `is_retryable()` and `should_trip_circuit()` |
94| `EmbeddingStats` | `ceres_core::embedding` | embedded, failed, skipped, total counts from an embedding run |
95| `HarvestPipeline` | `ceres_core::pipeline` | Composes HarvestService + EmbeddingService for combined harvest-then-embed |
96| `CircuitBreaker` | `ceres_core::circuit_breaker` | Closed -> Open -> HalfOpen state machine |
97
98## Quick Start
99
100```bash
101# Install
102cargo install ceres-search
103
104# Start PostgreSQL + pgvector
105docker compose up db -d
106
107# Configure
108cp .env.example .env # Edit with your Gemini/OpenAI API key
109
110# Run migrations
111make migrate
112
113# Harvest a portal
114ceres harvest https://dati.comune.milano.it
115
116# Harvest all configured portals
117ceres harvest
118
119# Search
120ceres search "trasporto pubblico" --limit 5
121
122# Export
123ceres export --format jsonl > datasets.jsonl
124
125# Stats
126ceres stats
127```
128
129## Reference Guides
130
131| Topic | File | When to Read |
132|---|---|---|
133| Architecture deep-dive | `references/architecture.md` | Understanding crate graph, services, error handling, database schema |
134| CLI & REST API | `references/cli-and-server.md` | Running CLI commands, calling API endpoints, env vars, deployment |
135| Harvesting system | `references/harvesting.md` | Two-tier optimization, delta detection, streaming, circuit breaker |
136| Extending Ceres | `references/extending.md` | Implementing custom EmbeddingProvider, PortalClient, or DatasetStore |
137| Contributing | `references/contributing.md` | Dev setup, testing, CI, code style |
138
139## Version Notes
140
141- **Current version:** 0.3.1
142- **crates.io package:** `ceres-search`
143- Harvesting and embedding are decoupled: `--metadata-only` harvests without API key, `embed` command generates embeddings separately
144- Stale dataset detection: datasets removed from portals are soft-marked (`is_stale`) during full syncs
145- Supports Gemini (768d, `gemini-embedding-001`) and OpenAI (1536d/3072d, `text-embedding-3-small`/`large`) embeddings
146- 25+ pre-configured CKAN portals (354k+ datasets)
147- HuggingFace dataset: `AndreaBozzo/ceres-open-data-index`