A Fair Made Twice is a digital humanities and curatorial experiment that revisits the 1867 Paris Exposition not as a fixed historical event, but as something constructed twice. The first construction took place in 1867, when objects, nations, technologies, and cultures were arranged into an official spectacle of progress, order, and imperial display. The second construction happens now, through a digital system that gathers, reorganizes, and reinterprets the traces left behind by catalogues, reports, expert commentary, visual records, and personal experiences.
Rather than presenting the exposition as a complete or neutral archive, the project moves through three overlapping worlds:
- The Official World, where objects are named, classified, and authorized.
- The Staged World, where exhibition design, expert commentary, and institutional interpretation produce meaning.
- The Lived World, where visitors encounter the fair through memory, movement, fatigue, wonder, confusion, and omission.
Across these worlds, the same object may appear differently: celebrated in one space, analyzed in another, forgotten or misread in a third.
By making these shifts visible, A Fair Made Twice does not attempt to recover a single truth of the 1867 Paris Exposition. Instead, it asks how exhibitions become history, how archives preserve some voices while silencing others, and how every act of digital reconstruction is also an act of choice. The project invites viewers to see the world's fair not simply as a past event to be revisited, but as a layered, unfinished construction, one that continues to be made, unmade, and remade through the ways we look at it today.
This repository implements the local semantic pipeline and browser demo behind A Fair Made Twice. It turns long-form museum source texts into discourse-aware, view-specific exhibit representations for retrieval, extraction, clustering, visualization, audio narration, and downstream curatorial systems. It uses Docker Qdrant for vector search, local/Ollama or Gemini-compatible LLM calls for generation and extraction, MinerU for PDF-to-Markdown parsing, and cached local outputs for reproducible visualization.
The pipeline keeps three discourse channels separate and maps them into the three curatorial worlds:
official-> The Official Worldinstitutional-> The Staged Worldpersonal-> The Lived World
It then builds five semantic views for each world:
technicalcategoryexhibitionperceptionoverall
The final semantic space is therefore view x world, for example:
technical_officialtechnical_stagedperception_livedoverall_official
This separation is the conceptual center of the project. The system does not simply ask what an exhibit is. It asks how the same exhibit is recorded by official authority, framed by exhibition systems, and felt or ignored by visitors.
The current repository also contains a text/commentary/ folder. In this project it is treated as part of the personal/visitor discourse channel and mapped into The Lived World.
Source texts may be multilingual, but the semantic layer is normalized into English.
- Metadata can come from CSV or Excel.
- All original metadata columns are preserved in
raw_metadata. - Normalized exhibit fields are created for system use.
- Metadata is translated into concise English through local Ollama for query building, hover text, and embedding text.
- Extracted
valuefields are always requested in English. - Retrieval queries are always English.
- View texts used for embeddings are English only.
- Evidence stays in the original source language and is never translated.
This split is important: the system keeps provenance faithful while making semantic comparison consistent across multilingual corpora.
PDFs are not assumed to contain clean English text layers, and the project now supports a MinerU-first parsing path.
- MinerU parses each PDF into Markdown.
- The Markdown is cached locally in
outputs/cache/mineru_markdown/. - Chunking runs on the cached Markdown, not on the original PDF.
- Chunk embeddings are then stored in a dedicated Qdrant collection.
- The collection is reused unless the parser mode, chunk settings, or source cache version changes.
This means the expensive path becomes:
PDF -> Markdown cache -> chunks -> Qdrant
In normal full runs, each stage is reused:
- document markdown cache is reused if the
.mdalready exists chunks.jsonlis reused for full runs- the Qdrant collection is reused if it already contains vectors
After the first pass, changing top_k, query logic, or extraction prompts should not require rebuilding the whole corpus.
The system does not merge embeddings across views or worlds.
- Technical language, exhibition framing, and visitor perception encode different semantics.
- The Official, Staged, and Lived Worlds often emphasize different evidence and different absences.
- Merging them into one vector would hide curatorial differences, suppress discourse distance, and blur omission patterns.
Instead, each view x world pair gets its own text and its own embedding so that curators can compare:
- how official records, staged interpretation, and lived perception diverge
- where one world is silent
- which exhibits cluster similarly in one world but not another
Each exhibit preserves the normalized fields below and all original metadata columns:
{
"exhibit_id": "101",
"archive_id": "0",
"card_id": "101",
"title": "Last Days of Napoleon by Vincenzo Vela",
"country": "Italian Fine Arts Section",
"location": "Palais",
"medium": "Sculpture",
"collection": "Francois Brunet",
"geolocated": "Confident",
"raw_metadata": {
"...": "all original columns are preserved here"
}
}exhibit_id defaults to card_id and falls back to archive_id or a row id when needed.
Technical view:
- Manufacturing Process
- Structural Feature
- Material
Category view:
- Category Context
- Functional Role
- Comparative Context
Exhibition view:
- Exhibition Context
- National Context
- Discursive Role
Perception view:
- Audience Impression
- Evaluation
- Popularity
- Sensory Description
Every extracted field contains a concise English value, original-language evidence, and a confidence score.
The checked-in paris.yml Dify workflow is parsed and converted into a Python pipeline with the same core sequence:
- metadata-driven query generation
- top-k retrieval
- iterative per-chunk JSON extraction
Cloud dependencies are replaced with local services by default:
- Dify retrieval becomes Qdrant vector search
- Dify LLM nodes become Ollama calls
- iterative extraction becomes chunk-by-chunk validated Python execution
Optional fallback:
- Gemini API can be enabled for query generation and/or extraction when local inference is too slow.
- Google also offers hosted access to Gemma through the Gemini API, including
gemma-3-27b-it.
src/
ingestion/
retrieval/
extraction/
aggregation/
analysis/
visualization/
llm/
storage/
cli/
docker/
docker-compose.yml
outputs/
README.md
python -m venv .venv
.\.venv\Scripts\activate
pip install -e .docker compose -f docker/docker-compose.yml up -dRecommended:
docker exec -it museum-ollama ollama pull qwen3:8bSmaller fallback:
docker exec -it museum-ollama ollama pull gemma3:4bEnd-to-end run:
python -m src.cli.main runUseful options:
python -m src.cli.main run --parser-provider mineru --qdrant-collection museum_exhibit_chunks_mineru
python -m src.cli.main run --ollama-model qwen3:8b --top-k 6
python -m src.cli.main run --top-k 10 --retrieval-candidate-k 20 --enable-rerank
python -m src.cli.main parse-dify --workflow-path paris.ymlHosted Gemini / Gemma fallback:
$env:GEMINI_API_KEY="your_key_here"
python -m src.cli.main run --llm-provider gemini --query-provider gemini --ollama-model gemma-3-27b-it --query-model gemma-3-27b-itEnvironment variables can also override defaults:
QDRANT_URLQDRANT_COLLECTIONOLLAMA_URLOLLAMA_MODELLLM_PROVIDERQUERY_PROVIDERPARSER_PROVIDERQUERY_MODELGEMINI_API_KEYGEMINI_BASE_URLMINERU_API_TOKENMINERU_BASE_URLMINERU_MODEL_VERSIONEMBEDDING_MODELRERANKER_MODELRETRIEVAL_CANDIDATE_KENABLE_RERANKRERANK_BATCH_SIZEMUSEUM_METADATA_PATHMUSEUM_TEXTS_PATHMUSEUM_OUTPUTS_PATH
The retrieval stack is:
- one shared English query plan per exhibit
- dense vector retrieval in Qdrant for each discourse channel
- optional cross-encoder reranking on the merged dense candidates
top_k controls the final number of chunks kept for extraction.
retrieval_candidate_k controls how many dense candidates are gathered before reranking.
Example:
python -m src.cli.main run --top-k 10 --retrieval-candidate-k 20 --enable-rerankThis means:
- retrieve up to 20 dense candidates in each discourse channel
- rerank them with the cross-encoder
- keep the best 10 for extraction
Hybrid dense+sparse retrieval is not enabled yet in this repository.
The pipeline writes:
outputs/chunks.jsonloutputs/retrieval_results.jsonloutputs/extraction_results.jsonloutputs/exhibit_profiles.jsonloutputs/exhibit_embeddings_official.jsonloutputs/exhibit_embeddings_personal.jsonloutputs/exhibit_embeddings_institutional.jsonloutputs/umap_coordinates.jsonloutputs/similarity_matrix.csvoutputs/exhibit_map.htmloutputs/discourse_diff.csv
These outputs are machine-readable and suitable for downstream 3D spatial systems, sonification or audio scripting, and custom curatorial interfaces.
Open outputs/exhibit_map.html in a browser after a run.
- Use the
Viewcontrol to switch between technical, category, exhibition, perception, and overall projections. - Use the world controls to switch between The Official World, The Staged World, and The Lived World.
- Hover over a point to see the English metadata layer and the extracted English field values.
- Distances represent similarity only within the currently selected
view x worldslice.
If two exhibits cluster together in perception_lived but not in technical_official, that suggests public reception is aligning differently from official description.
outputs/discourse_diff.csv is the curatorial comparison table.
It reports:
discourse_distance: vector distance between official and visitor embeddings for the same exhibit and viewofficial_field_countvisitor_field_countonly_in_officialonly_in_visitor
Use it to find:
- exhibits that are institutionally described but weakly perceived by visitors
- visitor impressions absent from official reporting
- strong technical overlap but large perception distance
- discourse gaps where one side contributes no evidence at all
- Chunking uses token windows of 400 with 60-token overlap by default.
- Qdrant collections are auto-created.
- Extraction uses Pydantic validation and retries.
- Missing metadata is allowed.
- Raw metadata schema can expand without code changes because all source columns are preserved.
- add incremental caching for translated metadata and extraction responses
- export 3D coordinates or scene-ready JSON for immersive viewers
- add audio narration templates from discourse-aware field summaries
- expose a small local API on top of the generated profile files