Fess is an open-source Enterprise Search Server. This
Docker environment runs Fess with a CLIP-powered multimodal search plugin, so a
plain text query — in English or Japanese — returns a visual gallery: CLIP-matched
images blended with BM25-matched pages, PDFs, and Office documents, each shown by its
thumbnail. The bundled mosaic static theme renders that gallery (masonry grid,
lightbox, per-result "Keyword / Visual / Blend" badges) instead of a classic result list.
Read this first: this stack pins the
-nobleFess image variant (ghcr.io/codelibs/fess:15.7.0-noble), not the default Alpine image. Without it, image/PDF/Office thumbnails silently fail to render and the gallery shows broken tiles. See The-nobleimage below.
All five services run on a single Docker Compose network, multimodal_net:
┌─────────────────────────────┐
browser ────────────────▶│ fess01 │ http://localhost:8080
(search UI, admin UI) │ ghcr.io/codelibs/fess │
│ :15.7.0-noble │
│ + fess-webapp-multimodal │
│ + mosaic gallery theme │
└──────┬────────────┬──────────┘
index/search │ │ text & image embeddings
(BM25 + kNN) │ │ (query time + crawl time)
▼ ▼
┌───────────────────┐ ┌───────────────────────┐
│ search01 │ │ clip_server │
│ fess-opensearch │ │ custom open_clip server │
│ :3.7.0 │ │ CLIP model (~1.6 GB │
│ 127.0.0.1:9200 │ │ download on first boot)│
│ (loopback only) │ └───────────────────────┘
└─────────┬────────────┘
▲
│ checks/creates the content_vector mapping
│
┌───────────────────┐
│ init-fess-index │ one-shot: runs once fess01 is
│ (alpine + curl/jq) │ healthy, then exits
└───────────────────┘
fess01 also web-crawls the demo corpus (a manual, documented step):
┌───────────────────┐
fess01 ──crawl──▶ │ content │ http://content/ (nginx serving
│ (nginx:alpine) │ ./data/content read-only; not
└───────────────────┘ published to the host)
search01— OpenSearch 3.7 (ghcr.io/codelibs/fess-opensearch:3.7.0), rolescluster_manager,data,ingest,ml. Stores documents and their CLIP vectors and serves both the BM25 and kNN branches of every query.clip_server— a custom FastAPI + open_clip server (docker/clip-server/) that loads the configured OpenCLIP XLM-RoBERTa model natively and servesPOST /post(Jina-compatible), returning L2-normalized text/image embeddings on demand forfess01. Thefess-webapp-multimodalplugin talks to it unchanged athttp://clip_server:51000. The model is MIT-licensed. First boot downloads ~1.6 GB into./data/clip_server/cacheand requires network access at runtime. It runs as a non-root user whose UID/GID default to1000and are set to the host user bybin/setup.sh(overridable viaCLIP_UID/CLIP_GIDin.env).content— a tinynginx:alpineserver exposing./data/content(read-only) ashttp://content/on the internal network only, so Fess's crawler and thumbnail generator fetch real HTTP responses (thumbnails render properly) instead of hitting thefile://dead-end.fess01— Fess15.7.0-noblewith thefess-webapp-multimodalplugin (installed viaFESS_PLUGINS, not a local jar) and themosaictheme. Combines thedefault(BM25) andmulti_modal(CLIP) searchers via hybrid rank fusion —rank.fusion.searchersis intentionally left unset so neither replaces the other.init-fess-index— a one-shot Alpine container that waits forfess01to be healthy, then bakes thecontent_vectorkNN field mapping into the document index (the plugin can only inject that mapping when Fess creates an index, and Fess core auto-creates the index before the plugin registers its rewrite rules — see the comments incompose.yamlandbin/init-fess-index.sh). It is idempotent: it only checks whethercontent_vectoralready exists, so restarts are cheap.
Docker and Git.
git clone https://github.com/codelibs/docker-multimodalsearch.git
cd docker-multimodalsearch
cp .env.example .env.env holds every tunable (image tags, FESS_PLUGINS, the CLIP model, the theme, heap
sizes, FESS_ADMIN_PASSWORD, DOMAIN, ...). Defaults work out of the box; edit it now
if you need to change something.
bash bin/setup.shThis host-side script (no Docker/Fess/OpenSearch calls) is safe to re-run and:
- creates the bind-mount data directories under
./data; - seeds
./data/fess/opt/fess/system.propertiesfrom its tracked template on first run only (setstheme.default=${THEME_NAME}; the live file is git-ignored so Fess can rewrite it later via Admin > General without conflicting withgit pull); - syncs the
${THEME_NAME:-mosaic}theme from thefess-themesrepo into./data/fess/usr/share/fess/app/themes/<THEME_NAME>(mosaic by default); - drops any stale multimodal plugin jar from a previous run (plugins are loaded via
FESS_PLUGINS, never downloaded by this script); - writes
CLIP_UID/CLIP_GID(your host user's UID/GID) into.envif absent, so the non-rootclip_servercontainer can write its bind-mounted model cache.
About the theme sync — read if mosaic is not yet on the fess-themes main
branch: bin/setup.sh resolves the theme source in this order:
- If
FESS_THEMES_DIRis set in.env, the theme is copied from${FESS_THEMES_DIR}/themes/${THEME_NAME}in a localfess-themescheckout — useful for theme development, or to pick up a branch that has not been merged yet. - Otherwise, it shallow-clones
FESS_THEMES_REPO(defaulthttps://github.com/codelibs/fess-themes.git) at refFESS_THEMES_REF(defaultmain) and copiesthemes/${THEME_NAME}from there.
If the mosaic gallery theme has not yet been merged to fess-themes main, either:
- point
FESS_THEMES_DIRat a localfess-themescheckout that already has it (e.g. a sibling clone on its development branch), or - set
FESS_THEMES_REFin.envto that development branch name before runningbin/setup.sh.
Either way, re-run bash bin/setup.sh after changing FESS_THEMES_DIR/FESS_THEMES_REF
to re-sync, then restart fess01 if it was already running (see
Troubleshooting — Fess caches theme bytes).
docker compose up -dWatch it come up:
docker compose ps
docker compose logs -f clip_server fess01 init-fess-indexNotes on first boot:
docker compose up -dbuilds theclip_serverimage locally (adds ~a minute) before pulling other images. The image is built once and cached; subsequent starts pull it from Docker's local cache and are faster.clip_serverdownloads the configured CLIP model (~1.6 GB for the default model) the first time it starts, caching it in./data/clip_server/cache; this can take a few minutes.fess01only depends onclip_serverhaving started (not healthy), so Fess itself comes up quickly, but multimodal search will not return visual matches until the model finishes downloading.init-fess-indexwaits forfess01's healthcheck (/api/v2/health), then triggers a one-time Admin > Maintenance reindex so the document index carries thecontent_vectormapping. Confirm it finished withExited (0):docker compose ps -a
bash bin/fetch-sample-images.shPopulates ./data/content/ with a small CC0/CC-BY/CC-BY-SA/public-domain image set
(animals, vehicles, food, nature, buildings), a few HTML pages, and a sample PDF —
served by the content nginx service at http://content/. Safe to re-run; already
downloaded files are left alone.
There is no crawl-automation container — crawling is a deliberate, one-time step you
run from the Fess Admin UI (init-fess-index only prepares the index mapping, it does
not crawl):
- Sign in at
http://localhost:8080/admin/(defaultadmin/admin— you'll be asked to set a new password on first sign-in with the default). - Admin > Crawler > Web > Create New: set a Name (e.g.
content) and URLs tohttp://content/, then Create. - Admin > System > Scheduler > Default Crawler > Start Now.
- Watch progress under Admin > System > Crawling Info until it finishes.
Open http://localhost:8080/ and try a query. Because the default model is
multilingual, try both:
- an English query, e.g.
mountain sunset - a Japanese query, e.g.
山の夕日(or犬for "dog")
Both should return a visual gallery mixing CLIP-matched images with any matching crawled pages/PDF.
docker compose downThis stack pins:
FESS_IMAGE=ghcr.io/codelibs/fess:15.7.0-noble
The default (Alpine-based) ghcr.io/codelibs/fess:15.7.0 image ships no
ImageMagick, poppler, or LibreOffice. Without that tooling, Fess can still generate
HTML thumbnails, but image, PDF, and Office document thumbnails silently fail to
render — the gallery would show blank/broken tiles for most of the demo corpus. The
-noble (Ubuntu Noble) variant bundles all three, so image/PDF/Office thumbnails all
render correctly. Keep the -noble variant (or install that tooling yourself in a
custom image) — it is the single change that makes a thumbnail-first gallery viable.
Default model, set in .env:
CLIP_MODEL_NAME=xlm-roberta-base-ViT-B-32::laion5b-s13b-b90k
MULTIMODAL_DIMENSION=512
xlm-roberta-base-ViT-B-32::laion5b-s13b-b90k is multilingual, 512-dimensional, and
CPU-feasible. For maximum retrieval quality (at the cost of a larger download and more
RAM/GPU), swap to xlm-roberta-large-ViT-H-14::frozen_laion5b_s13b_b90k, which is
1024-dimensional. The H/14 model is significantly heavier to run — a GPU is
recommended; set CLIP_DEVICE=auto so clip_server uses one if available and falls
back to CPU otherwise.
CLIP_MODEL_NAME and MULTIMODAL_DIMENSION must always be changed together — the
dimension is baked both into the CLIP encoder and into the content_vector kNN field
mapping. To swap models:
- In
.env, set both in lockstep, e.g.:CLIP_MODEL_NAME=xlm-roberta-large-ViT-H-14::frozen_laion5b_s13b_b90k MULTIMODAL_DIMENSION=1024 CLIP_DEVICE=auto - Rebuild and recreate
clip_serverto load the new model, then recreatefess01to pick up the newMULTIMODAL_DIMENSIONinFESS_JAVA_OPTS:docker compose up -d --build clip_server docker compose up -d --force-recreate fess01
- Recreate the index mapping.
init-fess-indexalready ran once during the initial setup and only checks whether thecontent_vectorfield exists — it has no way to detect that its dimension changed, so it will not re-trigger automatically. Go to Admin > Maintenance in the Fess admin UI and run Reindex (with alias replacement) to recreate the index with thecontent_vectorfield at the new dimension. This rebuilds the mapping only — existing documents' old-dimension vectors do not carry over (see the next step), so expect the crawled documents to need re-embedding. - Re-crawl (Admin > System > Scheduler > Default Crawler > Start Now). This is
what actually repopulates the vectors: a reindex only copies existing documents
between indices — it does not recompute embeddings, and copying an existing 512-dim
vector into the new 1024-dim field fails outright, so the old vectors cannot be
carried forward at all. Re-crawling re-embeds every document against the running
clip_server, producing correctly-sized vectors.
You can also tune CLIP_MIN_SCORE (default 0.5) in .env, which sets the minimum
similarity score a CLIP match must reach to be returned; its ideal cutoff shifts with
the model, so re-check it after any swap.
Any change to the clip_server image or CLIP_MODEL_NAME re-embeds the vector space —
even when MULTIMODAL_DIMENSION stays the same. The new server's embeddings are
not numerically comparable to the old one's, so vectors already indexed by the previous
server are inconsistent with the query vectors the new server produces; kNN matches
degrade instead of failing loudly, which makes this easy to miss.
After upgrading clip_server or its model:
- Delete the crawled documents and re-crawl rather than reindexing. A Fess
reindex (Admin > Maintenance > Reindex) only copies documents from one index to
another — it does not recompute embeddings — so it cannot fix vectors produced by
the old server. Re-crawling (Admin > System > Scheduler > Default Crawler >
Start Now) re-embeds every document against the currently running
clip_server. - Re-check
CLIP_MIN_SCOREin.env— its ideal cutoff shifts with the model. - It's safe to clear the old
./data/clip_server/cachecontents. That directory is bind-mounted to the server's model cache; the new server uses a different cache layout, so stale bytes from the previous model are never reused.
The default theme is mosaic (THEME_NAME=mosaic in .env), a purpose-built gallery
UI: a masonry grid of thumbnails, an image lightbox, and a "Keyword / Visual / Blend"
badge on each result showing whether it was matched by BM25, CLIP, or both. It is
authored and versioned in the separate
fess-themes repository — not committed
into this repo — and synced into ./data/fess/usr/share/fess/app/themes/mosaic by
bin/setup.sh (see step 2 above for the FESS_THEMES_DIR /
FESS_THEMES_REF options).
To switch to a different theme, set THEME_NAME in .env, then either edit
theme.default in the live ./data/fess/opt/fess/system.properties (or via
Admin > General), or delete that file and re-run bash bin/setup.sh to reseed it
from the template.
compose-production.yaml adds an https-portal TLS reverse proxy in front of
fess01 and a larger OpenSearch heap (OPENSEARCH_HEAP_PROD, default 3g). It is
not part of the base stack; start it as an overlay:
docker compose -f compose.yaml -f compose-production.yaml up -dSet DOMAIN in .env (default multimodal.codelibs.org). For a custom domain, also
copy data/https-portal/conf/multimodal.codelibs.org.ssl.conf.erb to
data/https-portal/conf/<your-domain>.ssl.conf.erb — https-portal matches its vhost
template by file name.
- Thumbnails are missing right after a crawl. Thumbnail generation is
asynchronous (a background Fess job runs roughly once a minute); give it up to
about a minute after the crawl finishes before expecting every tile to be filled in.
The
mosaictheme retries a missing thumbnail a few times with backoff before showing a fallback icon. clip_servertakes a while to become useful on first boot. The CLIP model (~1.6 GB for the default model) downloads on first start and is cached in./data/clip_server/cache; subsequent starts are fast.- Can't reach OpenSearch at
http://localhost:9200. It's published as127.0.0.1:9200:9200— loopback only, by design (the search engine runs with security disabled). It's reachable from other containers onmultimodal_netashttp://search01:9200. - The theme doesn't change after re-running
bin/setup.sh. Fess'sStaticThemeRespondercaches the theme's bytes in memory; a resync alone doesn't invalidate that cache. Restartfess01after syncing a new/updated theme:docker compose restart fess01
init-fess-indexnever finishes / times out. It waits up toMAX_WAITseconds (default900, i.e. 15 minutes) forfess01to become healthy and for thecontent_vectormapping to appear, then exits with an error. Checkdocker compose logs init-fess-indexanddocker compose logs fess01; once the underlying issue is fixed, re-run it withdocker compose up -d init-fess-index(it won't restart automatically —restart: "no").- Full-resolution lightbox images don't load from the browser. The demo
contentservice is only reachable insidemultimodal_net(http://content/), not from the host. The gallery still works — tiles and the lightbox fall back to Fess's own same-origin/thumbnail/endpoint — but a crawled image's original URL (http://content/...) won't open directly in a host browser tab.
None currently. Multilingual text queries (including Japanese) are supported: the
custom clip_server (see Architecture) produces well-differentiated
embeddings across languages, so text→image relevance is not limited to English.
What works: crawling and indexing, image (CLIP) vector generation, kNN wiring, thumbnails, gallery UI, keyword (BM25) search, per-result searcher badges, and multilingual (including Japanese) text→image relevance.
bin/fetch-sample-images.sh seeds a small (~35 image) demo set. For a larger, more
varied gallery, use FiftyOne to pull a bigger sample
from Open Images V7 and drop it into the crawlable content directory:
pip install fiftyone
fiftyone zoo datasets load open-images-v7 --split validation --kwargs max_samples=1000 -d ./data/fiftyone-exportThen copy the exported images into ./data/content/images/ and add links to them
from ./data/content/index.html before running the crawl in
step 5. The crawler discovers documents by following links
from http://content/, so images in ./data/content/images/ alone (without links)
will not be indexed — you must make them linkable from index.html or another
crawlable page.
For additional support or information, please visit the Fess documentation.