Semantic search and embeddings¶
Specivo has two ways to find things. Keyword search matches words. Semantic search matches meaning, and it needs an embedding model running on your server. This page explains what that model does, how to switch it on, how to keep it working, and what to do when it does not.
Everything here is optional. Without an embedding model, Specivo works as usual and keyword search keeps finding things by their words.
What embeddings are for¶
An embedding is a list of numbers (384 of them, for the default model) that stands for the meaning of a piece of text. Texts that mean similar things get similar numbers, even if they share no words. Specivo stores one for each chunk of content and compares your search words against them.
Embeddings are used for search, and only for search. Nothing else in Specivo, such as notifications, "related issues" or the board, uses them.
Keyword, semantic and hybrid¶
| Mode | How it finds things | Good for | Needs a model |
|---|---|---|---|
| Keyword | PostgreSQL full-text search: your words, reduced to their stems in the project's search language | Exact names, error messages, ids | No |
| Semantic | Closeness of meaning to your words | "auth flow" finding an issue about the "login sequence" | Yes |
| Hybrid | Runs both and merges the two rankings, so a result both agree on rises to the top | Everything; the default for people | Yes, for the semantic half |
Issue keys such as ACME-42 are matched directly and put first in every mode, with or without a
model.
Where each mode is offered:
- The web search page offers Hybrid (the default) and Keyword, to people without an account too, over the public projects they may read. Embedding a query costs CPU, so searches are limited per client address instead; see Search rate limits.
- The REST API (
GET /api/v1/search/) takesmode=keyword(the default),semanticorhybrid. - AI agents over MCP use
hybridunless they ask for another mode. See What agents can do.
What you lose without a model¶
Nothing breaks and nothing errors. Hybrid search quietly becomes keyword search, and semantic search returns only the issues whose key you typed. People still find work by its words, but not by its meaning: a search for "auth flow" will not surface the issue titled "login sequence".
What gets indexed¶
Content is cut into chunks, and each chunk gets an embedding:
| Content | Chunks |
|---|---|
| Issues | One chunk: the subject followed by the description |
| Wiki pages | One chunk per section (split at #, ## and ### headings, code blocks kept whole), each with the page title in front. Tiny sections are merged and sections over about 1,500 characters are split |
| Comments | One chunk per comment, if it has at least SEARCH_MIN_COMMENT_LENGTH characters (default 20). Comments written through an API key or by a service account are skipped by default |
| Attachments | One chunk of the filename and description, plus the extracted text and AI description when an attachment carries them |
The model reads at most 512 tokens of a chunk (roughly the first 2,000 characters); the rest of a long chunk does not influence its embedding.
Related settings, all in .env:
| Variable | Default | Effect |
|---|---|---|
SEARCH_INDEX_COMMENTS |
true |
false stops comments from being indexed |
SEARCH_MIN_COMMENT_LENGTH |
20 |
Shorter comments are not indexed |
SEARCH_EXCLUDE_BOT_COMMENTS |
true |
Comments made through an API key or by a service account are not indexed |
Vector index¶
Embeddings are stored in PostgreSQL with pgvector, and an HNSW index on them (created by
migration 0038) lets semantic and hybrid search find the nearest chunks without comparing the query
with every stored embedding. It covers 384-number vectors, the size multilingual-e5-small produces.
Search reads the nearest chunks from the index first and only then removes what the reader may not see: other projects, private issues, the trash. When too few of those chunks are visible, it reads a wider set from the index, and in the end compares every embedding in the projects the reader can see. A visible match is never dropped because content the reader cannot see ranks ahead of it. The cost is that someone who can see only a small part of a large instance gets slower semantic queries than an administrator.
The index needs pgvector 0.8 or later, for its iterative index scans; the bundled database image has it. On an older pgvector, search compares every embedding and logs a warning once per process.
| Variable | Default | Effect |
|---|---|---|
SEARCH_HNSW_EF_SEARCH |
100 |
Candidates pgvector keeps per index scan (hnsw.ef_search). Higher finds the true nearest chunks more reliably, at some speed |
SEARCH_SEMANTIC_OVERFETCH |
4 |
Nearest chunks read per wanted result before invisible ones are removed. Raise it if many readers see only a few projects |
Index lookups are approximate: on a large instance a close match can rank a place or two
differently than an exact comparison would. Raising SEARCH_HNSW_EF_SEARCH narrows that.
Minimum similarity¶
Every query has nearest chunks, even one that has nothing to do with your content. So semantic search only counts a chunk as a hit when its similarity to the query reaches a minimum. Chunks below it are left out of the results and out of the result count, and a query with no real match shows the normal "no results" page.
Similarity is the cosine similarity of the two embeddings, from -1 to 1; it is also the score the
REST API returns for each result in semantic mode. How high related and unrelated texts score
depends on the model, so the minimum comes from the model unless you set one:
| Model | Default minimum |
|---|---|
multilingual-e5-small and the rest of the E5 family |
0.82 |
| Any other model | None: every nearest chunk counts |
The E5 models score every pair of texts high, between about 0.7 and 0.9. Measured on issue and wiki text, a search for something a chunk actually describes scored 0.81 to 0.89 (0.87 typically), and searches unrelated to the content (a recipe, sports, random letters, or a lone word like "trash") reached at most 0.81. The default sits just above that.
In hybrid search the minimum applies to the semantic half only. Every keyword match stays; a result found by meaning alone needs to reach the minimum.
| Variable | Default | Effect |
|---|---|---|
SEARCH_SEMANTIC_MIN_SIMILARITY |
unset (the model's default) | The minimum cosine similarity, from -1 to 1. Lower it if semantic search misses loosely worded matches, raise it if unrelated results still appear. -1 turns the cut-off off |
To tune it for your content, set it to -1 for a moment and restart the api container. Then
search through the REST API (GET /api/v1/search/?q=...&mode=semantic) for a few things you know
are there and a few that should find nothing, and compare the score of the top results. Set the
variable between the two groups and restart again.
The default model¶
| Name | multilingual-e5-small (from the intfloat/multilingual-e5-small repository on Hugging Face, MIT license), pinned to revision fd1525a9fd15 |
| Format | ONNX, run on the CPU with ONNX Runtime. No GPU, no external service, no API key |
| Size | About 487 MB to download: the model file is about 470 MB, the tokenizer about 17 MB |
| Vector size | 384 numbers per chunk |
| Languages | About 100 |
| Similarity | Cosine, on length-normalized vectors |
The model is a text-in, numbers-out network. Specivo adds the passage: and query: prefixes that
this model family expects, to content and to search text respectively. You do not need to do that.
Where it lives¶
The model is not inside the Docker image, not in the release bundle and not downloaded on
first use. You put its files in a models folder inside your data directory:
SPECIVO_DATA_DIR defaults to ./specivo-data, which Docker Compose mounts at /app/data in the
api, celery-worker and celery-embeddings containers, so the files are found at
/app/data/models/. They are also covered by your normal backup of the data
directory.
Those two files are all Specivo reads, and all the download command fetches.
There is no on/off setting. The files being present is the switch. Specivo looks for them each time it needs an embedding, so you do not have to restart after adding them. A fresh install already has the model registered in the database (the entrypoint does this), so adding the files is all it takes.
CPU and memory¶
- The model is loaded into memory the first time it is used by a process, and it stays there. Budget
at least the size of the model file for each process that embeds: the
apicontainer, which embeds search queries, and thecelery-embeddingscontainer, which embeds content. Embedding jobs go to a queue of their own (embeddings) that onlycelery-embeddingsreads, and it runs one process by default, so content embedding keeps one copy of the model in memory however many cores you have. The generalcelery-workernever loads the model. Measure on your host; a small team fits the 2 CPU / 2 to 4 GB guidance in the overview. - To embed faster on a big host, set
EMBEDDING_WORKER_CONCURRENCYin.env(default1); each extra process costs another copy of the model. To run embedding in the general worker instead of a separate container, setEMBEDDING_QUEUE=celeryand stopcelery-embeddings. Restart the stack after changing either. - Inference uses 2 CPU threads by default. Set
EMBEDDING_ONNX_THREADSin.envto change that: a higher number makes indexing faster and uses more CPU, and0lets the model use every core. Restart the stack afterwards. - Embedding a search query is a single short text, which is cheap. Indexing a large instance is the heavy part (see Index existing content).
Turn it on¶
-
Download the model into the data directory, from inside the running
apicontainer:make download-modelruns the same command, from a checkout or from the release bundle. It needs theapicontainer to reachhuggingface.co(setHTTPS_PROXYfor the container if you use a proxy) and writes to/app/data/models, which is<SPECIVO_DATA_DIR>/modelson the host. Each file comes from a pinned revision and is checked against its SHA-256 before it replaces anything; a file that fails the check is discarded and the command exits with an error. Files already present with the right checksum are skipped, so it is safe to run again.--forcedownloads them anyway. When the host is offline, the command stops with the list of files, their URLs and checksums.scripts/download-model.sh(in the repository and the release bundle) does the same on a host without Docker, for example to prepare the files for an air-gapped server:bash scripts/download-model.sh /path/to/models. It needscurlandsha256sumorshasum. -
Index the content you already have (below). New content is picked up as described under How content is indexed.
-
Check that it works (below).
For a host without internet, download on a connected machine and copy the models folder across, as
described in Air-gapped install.
How content is indexed¶
Indexing happens in two steps, and only the second needs the model.
- Chunks, at once. When an issue, wiki page, comment or attachment is created, edited or deleted, its chunks are written, replaced or removed in the same database transaction as the change. Keyword search reads them straight away. This needs no model and no worker, and it is cheap enough not to slow saving down.
- Embeddings, a few seconds later. After the change is saved, Specivo queues a job for the
celery-embeddingsworker, which embeds the new chunks. Semantic and hybrid search find the content once that job has run, usually within seconds. Quick edits in a row are usually embedded once, from the latest text.
This covers every way content changes: the web UI, the REST API, agents over MCP, recurring issues, moving an issue to another project (its chunks, comments and attachments move with it) and commit messages that reference an issue. An import indexes everything it brought in at the end of the run.
| Event | Keyword search | Semantic search |
|---|---|---|
| An issue, wiki page, comment or attachment is created or edited | Immediately | Once the worker has embedded it |
| One is deleted, or a wiki page is moved to the trash | Gone immediately | Gone immediately |
| A wiki page is restored from the trash | Immediately | Once the worker has embedded it |
| A comment is made private | Hidden from people who cannot see private notes immediately | Same |
Things to know:
- The
celery-embeddingsworker must be running for semantic indexing. The standard Compose file starts it. Without it, keyword search still finds everything, but new and edited content waits for the worker (or the backfill below) before semantic search finds it. The queued jobs are kept in Redis and run when the worker comes back. - Without the model files nothing is queued. Chunks are still written, so keyword search is complete. When you add the files later, run the backfill once to embed what was written in the meantime. The embedding worker logs at startup whether semantic indexing is on.
- If Redis cannot be reached when a change is saved, the change is still saved and keyword-searchable;
the
apilogs a warning, and the backfill embeds the content later. - Indexing problems are logged at warning level, with the kind of content and its id. They never make the save itself fail.
- The comment settings above are applied as comments are written and edited. Changing them later affects comments written or edited afterwards; run the backfill to apply them to older comments too.
Index existing content¶
The backfill goes through every issue, wiki page, comment and attachment, brings its chunks in line with the content and the current settings, and embeds the chunks that have no embedding yet. It indexes every attachment, with or without a description, whether it hangs off an issue, a comment or a wiki page. It removes the chunks of comments the comment settings now exclude, and of items that no longer exist. It is safe to run again whenever you like: chunks whose text has not changed keep their embeddings. Run it after you add the model files, and after you change a comment setting. Day to day there is no need to run it, because new and edited content is indexed as it is saved.
make backfill-embeddings runs the same command (make backfill-embeddings ARGS=--reembed passes
options). It runs in the foreground, prints progress, and ends with what it did:
Chunked 1840 sources into 2312 chunks; removed 3 stale sources.
Embedded 2312 chunks with multilingual-e5-small; 2312 of 2312 chunks now have an embedding.
Work is saved in small batches, so stopping it with Ctrl+C loses very little, and you can start it
again. The time it takes grows with the number of chunks and your CPU. Run a large first backfill
when the server is quiet, and raise EMBEDDING_ONNX_THREADS for the duration if you have cores to
spare.
| Option | Effect |
|---|---|
--reembed |
Recompute the embedding of every chunk, not only of chunks that have none. Use it after replacing the model files. Old embeddings are replaced one by one, so semantic search keeps working meanwhile |
--require-model |
Stop with exit status 2, without doing anything, when no model can run. Useful in scripts that expect semantic search to be on |
The exit status is 0 when the run succeeded, with or without a model, and 1 when some items could not be indexed (the log names each one).
Without the model it writes keyword chunks only
If the model files are missing, the backfill still writes every chunk, which is all keyword search needs, and embeds nothing. It says so at the end with a warning: Only keyword-search chunks were written; 0 chunks were embedded and semantic search stays off. Add the files and run it again for semantic search.
An administrator can also start a backfill for one registered model in the background with
POST /api/v1/admin/embedding-models/{id}/backfill/. It runs on the celery-embeddings worker and
embeds the chunks that have none for that model; add ?reembed=true to recompute every embedding,
for example after replacing the model files. It does not rebuild chunk text (that is kept current
as content is saved, and the command above rebuilds it). It answers 409 when the model cannot run
here, for example because its files are missing. It needs an administrator account; there is no
button for it in the web UI.
Check that it works¶
-
Are the files there?
You should see
multilingual-e5-small.onnxandtokenizer.json. -
Does the log agree? The embedding worker says at startup whether semantic indexing is on, and each process logs the line below when it first loads the model. When the model is missing, the
apilogs a warning each time it needs to embed a search query. -
Is anything indexed? Compare chunks with embeddings. Once the worker has caught up they should be close to equal.
docker compose exec db psql -U specivo specivo -c \ "SELECT (SELECT count(*) FROM search_chunks) AS chunks, (SELECT count(*) FROM chunk_embeddings) AS embeddings;"(Use your
POSTGRES_USERandPOSTGRES_DBif you changed them.) Many chunks and zero embeddings means the chunks were built while the model files were missing, or thecelery-embeddingsworker is not running. Check the worker, add the files, and run the backfill again. -
Does a meaning-based search work? Search for a paraphrase of something you know is in an issue, using an API key:
curl -H "Authorization: Bearer spv_your_api_key_here" \ "https://your-specivo-host/api/v1/search/?q=sign-in+flow&mode=semantic"Results that do not contain your words mean semantic search is working.
Changing the model¶
Only the bundled local model, multilingual-e5-small, is supported. Hosted embedding providers
and bring-your-own-key (BYOK) setups are not available, and switching to a different model is not
supported either. There is no setting for it and no screen for it.
- Models are rows in the
embedding_modelstable, and search uses the one marked as default. The seed command registers the local model (it runs each time theapicontainer starts). The administrator REST API under/api/v1/admin/embedding-models/can list models, delete one and start a backfill, but it cannot register a model. - The database allows at most one default model. Upgrading to 0.9.0 (migration 0039) clears the extra defaults if an older release left more than one, keeping the local model.
- If the default row names any other provider, for example one registered by an older release,
Specivo treats it like a model whose files are missing: it logs one warning, embeds nothing, and
hybrid search returns keyword results. Delete that row and restart the
apicontainer so the seed registers the local model again, then run the backfill. - Vectors from different models cannot be compared, and the vector index only
covers the 384 numbers of
multilingual-e5-small. Moving to another model will need a full rebuild of the index, and this page will describe it if that becomes supported.
Troubleshooting¶
Semantic or hybrid search finds nothing beyond keyword results.
Work down the list in Check that it works: the files in
/app/data/models, the log line, and the chunks and embeddings counts. The usual causes are no
model files, files in the wrong folder (it must be the models folder inside the folder mounted
as /app/data, and SPECIVO_DATA_DIR in .env must point at the same place for all containers), a
stopped celery-embeddings worker, or a backfill that has not run since the files were added.
If those are fine, the semantic matches may score below the minimum similarity:
check their score in the REST API and lower SEARCH_SEMANTIC_MIN_SIMILARITY if needed.
Semantic search returns results that have nothing to do with the query.
Results below the minimum similarity are left out, but a short or very
generic query can still reach it. Raise SEARCH_SEMANTIC_MIN_SIMILARITY a little (for example to
0.84) and restart the api container.
A result I know exists is missing, or looks out of date.
In keyword mode, check that you can open the item: search only shows what you are allowed to see,
and comments shorter than SEARCH_MIN_COMMENT_LENGTH or written by a bot are not indexed. In
semantic mode, the celery-embeddings worker may not have embedded the latest text yet; check that
it is running and look for warnings in its log. Content from before 0.9.0 that was never backfilled may lack
embeddings; run the backfill. See How content is indexed.
The model download fails.
Cannot reach huggingface.co means the api container has no route to Hugging Face. Behind a
proxy, set HTTPS_PROXY for the container; behind a firewall, allow that host; otherwise download on
another machine with scripts/download-model.sh and copy the files over (see
Air-gapped install). The error lists both files with their
URLs and checksums. Checksum mismatch means the file that arrived is not the pinned one, for
example because a proxy altered it; it was discarded and nothing was replaced. A failed download
leaves no partial file, and running the command again skips the files that are already complete.
Indexing is slow.
Embedding is one chunk at a time on the CPU. Raise EMBEDDING_ONNX_THREADS while you backfill, and
give the api and celery-embeddings containers more cores, or raise
EMBEDDING_WORKER_CONCURRENCY if you have memory for another copy of the model. Saving is not slowed down: content is
embedded by the worker after it is saved, so a burst of changes only means semantic search catches up
a little later.
Searching is slow on a large instance. Semantic search reads the nearest chunks from the vector index, so its cost grows slowly with the number of chunks. Check that the index exists and is valid:
docker compose exec db psql -U specivo -d specivo -c \
"SELECT indisvalid FROM pg_index WHERE indexrelid = to_regclass('ix_chunk_embeddings_hnsw_384')"
t is what you want. No row means it was not built, because the pgvector extension is older than
0.5 (the upgrade log says so). f means it is broken; rebuild it without blocking search with
REINDEX INDEX CONCURRENTLY ix_chunk_embeddings_hnsw_384 in the same psql. Check the pgvector
version too (SELECT extversion FROM pg_extension WHERE extname = 'vector'): before 0.8 the index
is not used. With the index in place, a reader who can see only a few projects of a large instance is the
slow case, because search widens its candidate set until it has enough results they may see; a
higher SEARCH_SEMANTIC_OVERFETCH gets there in fewer steps. Hybrid search also runs a keyword
search, which is often the larger part of its time.
The api or celery-embeddings container uses a lot of memory.
The model is loaded into each process the first time it embeds something and stays loaded. This is
expected: the model file alone is about 470 MB. celery-embeddings runs one process by default; if
you raised EMBEDDING_WORKER_CONCURRENCY, lower it. If memory is still tight, give the containers
more, or leave the model files out and use keyword search. The general celery-worker does not load
the model; if it does, check that EMBEDDING_QUEUE is not set to celery.