Ceres — Harvest-First Toolkit for Open Data Portals
Ceres is centered on harvesting and synchronizing open data metadata. Embeddings, semantic search, exports, and API access are downstream capabilities layered on top of the harvested catalog.
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, EmbeddingService handles vectors, and HarvestPipeline composes both when you want a combined workflow. Metadata-only harvests require no embedding provider. Each stage is trait-based, so components can be swapped or mocked independently.
Current Product Shape
- Harvest first and keep metadata synchronized over time
- Add embeddings later only if you want semantic retrieval
- Prefer Ollama for local embedding, with Gemini and OpenAI still supported
- Support CKAN, DCAT-AP udata REST, and SPARQL-backed DCAT portals in the current client factory
- Publish reproducible Parquet snapshots for the public Open Data Index
- Expose search, export, and API workflows over the same harvested catalog
Crate Map
| Crate |
Purpose |
Key Exports |
ceres-core |
Business logic, traits, services |
HarvestService, EmbeddingService, HarvestPipeline, SearchService, ExportService, WorkerService, CircuitBreaker, traits |
ceres-client |
Portal clients and embedding providers |
CkanClient, DcatClient, GeminiClient, OpenAIClient, OllamaClient, 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, language: &str) -> 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 |
DatasetSchema |
ceres_core::schema |
Normalized resource/distribution metadata derived from preserved portal metadata |
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, profile, sparql_endpoint |
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
# Run migrations
make migrate
# Harvest metadata without embeddings
ceres harvest https://dati.comune.milano.it --metadata-only
# Or harvest a DCAT portal
ceres harvest https://data.public.lu --type dcat --metadata-only
# Or harvest a SPARQL-backed DCAT catalog
ceres harvest https://data.europa.eu --type dcat --profile sparql --metadata-only
# Optional: local embeddings through Ollama
export EMBEDDING_PROVIDER=ollama
ceres embed
# 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.5.0 (release, 2026-06-26)
- crates.io package:
ceres-search
- Harvesting and embedding are decoupled:
--metadata-only harvests without API key, embed command generates embeddings separately
- Ollama is the preferred local embedding path; Gemini and OpenAI remain available
- Current portal client factory supports CKAN and DCAT (
udata_rest default profile plus sparql profile)
- Stale dataset detection: datasets removed from portals are soft-marked (
is_stale) during full syncs
- Supports Ollama, Gemini, and OpenAI embeddings
- Parquet export publishes a portable snapshot:
all.parquet (canonical), per-portal subsets, identity.parquet, a versioned snapshot manifest (metadata.json with snapshot_id, provenance, alias-aware duplicate metadata, and SHA-256 checksums), coverage/quality reports (reports.json, report.md), and snapshot changelogs (changelog.json, changelog.md when --previous is supplied)
- v0.6.0 milestone focus: portal coverage expansion in priority order — DCAT profile cleanup, Project Open Data
data.json, Socrata, OpenDataSoft, ArcGIS Hub
- v0.7.0 milestone focus: resource-level metadata depth tracked in issue #68
- HuggingFace dataset:
AndreaBozzo/ceres-open-data-index
1---2name: ceres3description: Use when working with Ceres — a Rust harvest-first toolkit and public open data metadata index. Covers harvesting and synchronization, optional embedding and search, CKAN, DCAT udata, and SPARQL-backed DCAT portal support, Parquet snapshot manifests/reports/changelogs, Ollama or hosted providers, CLI commands, REST API endpoints, portal configuration, architecture, extending via traits, release workflow, and contributing to the Ceres codebase.4---56# Ceres — Harvest-First Toolkit for Open Data Portals78Ceres is centered on harvesting and synchronizing open data metadata. Embeddings, semantic search, exports, and API access are downstream capabilities layered on top of the harvested catalog.910**Repository:** https://github.com/AndreaBozzo/Ceres11**License:** Apache-2.0 | **Rust edition:** 2024 | **MSRV:** 1.88+1213## Pipeline1415```16Metadata: Portal URL → PortalClient (fetch) → DeltaDetector (content_hash) → DatasetStore (upsert, no embedding)17Embedding: DatasetStore (pending) → EmbeddingProvider (vector) → DatasetStore (update embedding)18Combined: HarvestPipeline = HarvestService + EmbeddingService19```2021Harvesting and embedding are decoupled: `HarvestService` handles metadata, `EmbeddingService` handles vectors, and `HarvestPipeline` composes both when you want a combined workflow. Metadata-only harvests require no embedding provider. Each stage is trait-based, so components can be swapped or mocked independently.2223## Current Product Shape2425- Harvest first and keep metadata synchronized over time26- Add embeddings later only if you want semantic retrieval27- Prefer Ollama for local embedding, with Gemini and OpenAI still supported28- Support CKAN, DCAT-AP udata REST, and SPARQL-backed DCAT portals in the current client factory29- Publish reproducible Parquet snapshots for the public Open Data Index30- Expose search, export, and API workflows over the same harvested catalog3132## Crate Map3334| Crate | Purpose | Key Exports |35|---|---|---|36| `ceres-core` | Business logic, traits, services | `HarvestService`, `EmbeddingService`, `HarvestPipeline`, `SearchService`, `ExportService`, `WorkerService`, `CircuitBreaker`, traits |37| `ceres-client` | Portal clients and embedding providers | `CkanClient`, `DcatClient`, `GeminiClient`, `OpenAIClient`, `OllamaClient`, `PortalClientFactoryEnum`, `EmbeddingProviderEnum` |38| `ceres-db` | PostgreSQL + pgvector repository | `DatasetRepository`, `HarvestJobRepository` |39| `ceres-server` | Axum REST API with Swagger UI | Routes, DTOs, bearer auth, OpenAPI/Swagger |40| `ceres-cli` | Command-line interface | `harvest`, `embed`, `search`, `export`, `stats` subcommands |4142## Core Traits (`ceres-core::traits`)4344```rust45pub trait EmbeddingProvider: Send + Sync + Clone {46 fn name(&self) -> &'static str;47 fn dimension(&self) -> usize;48 fn generate(&self, text: &str) -> impl Future<Output = Result<Vec<f32>, AppError>> + Send;49 fn max_batch_size(&self) -> usize { 1 }50 fn generate_batch(&self, texts: &[String]) -> impl Future<Output = Result<Vec<Vec<f32>>, AppError>> + Send;51}5253pub trait PortalClient: Send + Sync + Clone {54 type PortalData: Send;55 fn portal_type(&self) -> &'static str;56 fn base_url(&self) -> &str;57 fn list_dataset_ids(&self) -> impl Future<Output = Result<Vec<String>, AppError>> + Send;58 fn get_dataset(&self, id: &str) -> impl Future<Output = Result<Self::PortalData, AppError>> + Send;59 fn into_new_dataset(data: Self::PortalData, portal_url: &str, url_template: Option<&str>, language: &str) -> NewDataset;60 fn search_modified_since(&self, since: DateTime<Utc>) -> impl Future<Output = Result<Vec<Self::PortalData>, AppError>> + Send;61 fn search_all_datasets(&self) -> impl Future<Output = Result<Vec<Self::PortalData>, AppError>> + Send;62}6364pub trait PortalClientFactory: Send + Sync + Clone {65 type Client: PortalClient;66 fn create(&self, portal_url: &str, portal_type: PortalType, language: &str) -> Result<Self::Client, AppError>;67}6869pub trait DatasetStore: Send + Sync + Clone {70 fn get_by_id(&self, id: Uuid) -> impl Future<Output = Result<Option<Dataset>, AppError>> + Send;71 fn get_hashes_for_portal(&self, portal_url: &str) -> impl Future<Output = Result<HashMap<String, Option<String>>, AppError>> + Send;72 fn upsert(&self, dataset: &NewDataset) -> impl Future<Output = Result<Uuid, AppError>> + Send;73 fn batch_upsert(&self, datasets: &[NewDataset]) -> impl Future<Output = Result<Vec<Uuid>, AppError>> + Send;74 fn search(&self, query_vector: Vec<f32>, limit: usize) -> impl Future<Output = Result<Vec<SearchResult>, AppError>> + Send;75 fn list_stream<'a>(&'a self, portal_filter: Option<&'a str>, limit: Option<usize>) -> BoxStream<'a, Result<Dataset, AppError>>;76 fn get_last_sync_time(&self, portal_url: &str) -> impl Future<Output = Result<Option<DateTime<Utc>>, AppError>> + Send;77 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;78 fn health_check(&self) -> impl Future<Output = Result<(), AppError>> + Send;79 // + update_timestamp_only, batch_update_timestamps, get_duplicate_titles80 // Stale detection81 fn mark_stale_datasets(&self, portal_url: &str, sync_start: DateTime<Utc>) -> impl Future<Output = Result<u64, AppError>> + Send;82 fn mark_stale_by_exclusion(&self, portal_url: &str, seen_ids: &[String]) -> impl Future<Output = Result<u64, AppError>> + Send;83 // Pending embeddings84 fn list_pending_embeddings(&self, portal_filter: Option<&str>, limit: usize) -> impl Future<Output = Result<Vec<Dataset>, AppError>> + Send;85}86```8788## Key Types8990| Type | Module | Purpose |91|---|---|---|92| `Dataset` | `ceres_core::models` | Complete dataset row (id, original_id, source_portal, url, title, description, embedding, metadata, timestamps, content_hash, is_stale) |93| `NewDataset` | `ceres_core::models` | Insert/update DTO. Has `compute_content_hash()` for delta detection |94| `DatasetSchema` | `ceres_core::schema` | Normalized resource/distribution metadata derived from preserved portal metadata |95| `SearchResult` | `ceres_core::models` | Dataset + similarity_score (0.0-1.0) |96| `DatabaseStats` | `ceres_core::models` | total_datasets, datasets_with_embeddings, stale_datasets, total_portals, last_update |97| `HarvestJob` | `ceres_core::job` | Queued harvest job with status, retry info, portal config |98| `JobStatus` | `ceres_core::job` | Enum: Pending, Running, Completed, Failed, Cancelled |99| `SyncStats` | `ceres_core::sync` | created, updated, unchanged, failed, skipped counts |100| `SyncOutcome` | `ceres_core::sync` | Per-dataset outcome: Created, Updated, Unchanged, Failed, Skipped |101| `BatchHarvestSummary` | `ceres_core::sync` | Aggregated results from batch harvesting multiple portals |102| `PortalEntry` | `ceres_core::config` | Portal config: name, url, type, enabled, url_template, language, profile, sparql_endpoint |103| `AppError` | `ceres_core::error` | Error enum with `is_retryable()` and `should_trip_circuit()` |104| `EmbeddingStats` | `ceres_core::embedding` | embedded, failed, skipped, total counts from an embedding run |105| `HarvestPipeline` | `ceres_core::pipeline` | Composes HarvestService + EmbeddingService for combined harvest-then-embed |106| `CircuitBreaker` | `ceres_core::circuit_breaker` | Closed -> Open -> HalfOpen state machine |107108## Quick Start109110```bash111# Install112cargo install ceres-search113114# Start PostgreSQL + pgvector115docker compose up db -d116117# Configure118cp .env.example .env119120# Run migrations121make migrate122123# Harvest metadata without embeddings124ceres harvest https://dati.comune.milano.it --metadata-only125126# Or harvest a DCAT portal127ceres harvest https://data.public.lu --type dcat --metadata-only128129# Or harvest a SPARQL-backed DCAT catalog130ceres harvest https://data.europa.eu --type dcat --profile sparql --metadata-only131132# Optional: local embeddings through Ollama133export EMBEDDING_PROVIDER=ollama134ceres embed135136# Search137ceres search "trasporto pubblico" --limit 5138139# Export140ceres export --format jsonl > datasets.jsonl141142# Stats143ceres stats144```145146## Reference Guides147148| Topic | File | When to Read |149|---|---|---|150| Architecture deep-dive | `references/architecture.md` | Understanding crate graph, services, error handling, database schema |151| CLI & REST API | `references/cli-and-server.md` | Running CLI commands, calling API endpoints, env vars, deployment |152| Harvesting system | `references/harvesting.md` | Two-tier optimization, delta detection, streaming, circuit breaker |153| Extending Ceres | `references/extending.md` | Implementing custom EmbeddingProvider, PortalClient, or DatasetStore |154| Contributing | `references/contributing.md` | Dev setup, testing, CI, code style |155156## Version Notes157158- **Current version:** 0.5.0 ([release](https://github.com/AndreaBozzo/Ceres/releases/tag/v0.5.0), 2026-06-26)159- **crates.io package:** `ceres-search`160- Harvesting and embedding are decoupled: `--metadata-only` harvests without API key, `embed` command generates embeddings separately161- Ollama is the preferred local embedding path; Gemini and OpenAI remain available162- Current portal client factory supports CKAN and DCAT (`udata_rest` default profile plus `sparql` profile)163- Stale dataset detection: datasets removed from portals are soft-marked (`is_stale`) during full syncs164- Supports Ollama, Gemini, and OpenAI embeddings165- Parquet export publishes a portable snapshot: `all.parquet` (canonical), per-portal subsets, `identity.parquet`, a versioned snapshot manifest (`metadata.json` with `snapshot_id`, provenance, alias-aware duplicate metadata, and SHA-256 checksums), coverage/quality reports (`reports.json`, `report.md`), and snapshot changelogs (`changelog.json`, `changelog.md` when `--previous` is supplied)166- v0.6.0 milestone focus: portal coverage expansion in priority order — DCAT profile cleanup, Project Open Data `data.json`, Socrata, OpenDataSoft, ArcGIS Hub167- v0.7.0 milestone focus: resource-level metadata depth tracked in issue #68168- HuggingFace dataset: `AndreaBozzo/ceres-open-data-index`