Skip to main content
Hybrid search runs two searches over the same text and merges their rankings into one: PostgreSQL full-text search finds the chunks that contain the words of the query, pgvector finds the chunks closest to it in meaning, and reciprocal rank fusion (RRF) combines both lists in the same SQL query. This guide builds a search API with it in FastAPI and deploys it to Sevalla as one application on a PostgreSQL database with pgvector: a job creates the schema and both indexes before each deployment, a second job embeds the documents in your repository after each deployment, and the web process answers on /search. Each search alone misses queries the other finds. In this guide’s tests, vector search didn’t return the only page that mentions BackoffLimitExceeded among its first 20 results, and full-text search returned nothing at all for my app ran out of memory and keeps restarting. Hybrid search ranked the right page first for both. The project uses the stack and the ingestion of Build RAG with pgvector, so the query can also replace the vector-only search there: see Add hybrid search to the RAG application.

At a glance

Environment variables

The internal connection also adds DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME and DB_PASSWORD, which the application doesn’t read.

How hybrid search works

Every chunk of text is stored once, with two representations and an index on each: A search runs both and merges them with reciprocal rank fusion:
  1. Full-text search returns the 20 chunks that match the query best, ranked by ts_rank_cd. The query goes through websearch_to_tsquery, so it supports the syntax of a web search engine: "exact phrase", -word to exclude a word, and or.
  2. Vector search returns the 20 chunks closest to the query’s embedding, by cosine distance.
  3. Each chunk gets a score of 1 / (60 + rank) from each list it appears in, and the scores add up. A chunk ranked first in one list scores 0.0164; a chunk ranked first in both scores 0.0328, so chunks that both searches find move to the top. When scores tie, the keyword match comes first.
RRF uses only the positions in each list, not the raw scores, so it needs no tuning to compare a text rank with a cosine distance. 60 is the constant from the original RRF paper; a smaller one gives the first few results of each list more weight.

Before you begin

  • A Sevalla company with a payment method, and a GitHub, GitLab or Bitbucket account for the repository. See Git providers.
  • Python 3.14 and uv on your computer, and Docker to run PostgreSQL with pgvector locally.
  • An OpenAI API key for embeddings. Set a monthly spending limit on the account: every search sends the query to the embeddings API.

Create the project

Create the project with uv and add its packages:
uv init writes pyproject.toml, a .python-version with 3.14, a .gitignore that keeps out .venv, and a Git repository. uv add installs FastAPI 0.162, psycopg 3.3 and the OpenAI SDK 3.41 into .venv and pins every version in uv.lock.

Add the schema

app/schema.sql
  • tsv is a generated column: PostgreSQL computes it from content on every insert and update, so the code never writes it. to_tsvector('english', ...) splits the text into words, drops stop words such as the and and, and reduces each word to its stem, so restarting matches restart. For text in another language, use that language’s configuration, such as 'german', in the column and in the query; SELECT cfgname FROM pg_ts_config; lists them.
  • chunks_tsv_idx, a GIN index, maps each stem to the chunks that contain it, so full-text search doesn’t read the whole table.
  • chunks_embedding_idx, an HNSW index with vector_cosine_ops, serves the vector search, which sorts by cosine distance (<=>).
  • Every statement can run more than once.
A short script runs the file. psycopg runs a query of more than one statement in one execute() when it has no parameters:
app/migrate.py

Embed and store the documents

These two files are the same as in Build RAG with pgvector, which explains them in detail. One function calls the embeddings API for both ingestion and search, so they always use the same model:
app/embeddings.py
The ingestion script splits each file in docs into chunks of up to 1,500 characters that overlap by up to 200, embeds the chunks 100 per request, and stores them. It skips files whose SHA-256 hash hasn’t changed since the last run and deletes the chunks of files that are gone:
app/ingest.py
Each chunk starts with its file’s path, so the path is part of both the embedding and the tsv column, and a search for a file name finds it. The application has two routes: / returns the number of chunks, and /search runs the hybrid query:
app/main.py
  • One query. semantic and keyword each rank their 20 best chunks with rank(), fused joins them with a FULL OUTER JOIN, so a chunk that only one search found keeps a score, and the final SELECT adds the text. PostgreSQL runs it in one round trip, and the HNSW and GIN indexes serve the two searches as they would alone.
  • Ranks in the response. Each result carries semantic_rank and keyword_rank, null when that search didn’t return the chunk, so you can see which search found it.
  • Token. /search calls the embeddings API on every request, so it requires Authorization: Bearer <token> with the value of SEARCH_TOKEN. A request without the header gets 401 with {"detail":"Not authenticated"}, and a wrong token gets {"detail":"Invalid token"}.
  • Input limits. q takes 1 to 500 characters and limit 1 to 20 results; anything else gets 422.
  • Connections. The pool opens when the application starts, and check_connection replaces a broken connection before a request uses it, for example after a database restart.

Add the documents

Put the files to search in a docs folder. To follow this guide with the same documents, download the Applications and Databases sections of Sevalla’s documentation, which the site publishes as Markdown:
The folder gets 39 files, about 480 KB, which split into 341 chunks.

Run it on your computer

Start PostgreSQL with pgvector in Docker:
Create a .env file with the database URL, your OpenAI API key and a token of your choice. .gitignore keeps it out of the repository:
.env
Create the schema, ingest the documents and start the development server:
The ingestion prints a line per document, then Ingested 341 chunks from 39 documents. In a second terminal, search for an error name that appears on one page:
Only full-text search found applications/processes.md, the page that explains the error: vector search ranked other troubleshooting pages first and left it out of its 20 results. Hybrid search puts it first. Each result also has a content field with the chunk’s text, which the jq filter leaves out. Press Ctrl+C to stop the server. docker rm -f docsearch-db removes the database container.

Prepare the project for Sevalla

The project already has what Ingot needs: Commit everything and push it to your Git provider:

Deploy to Sevalla

These steps build with Ingot. To build from your own Dockerfile instead, see Deploy with a Dockerfile.
1

Create the database

In the sidebar, click Databases, then click Create database. Under Database, choose PostgreSQL and the newest version, and select Enable pgvector extension. Enter docsearch-db as the Display name, choose a Data center and the Size DB1, then click Create database. You can turn the extension on only when you create a database.
2

Create the application

In the sidebar, click Applications, then click Create > Application. Under Source, click Private repository, then choose the Git provider, the Repository and the Branch. Under Location, choose the same Data center as the database. Keep the Size at H1, then click Create application.
3

Connect the database

Click the Networking tab. Under Connected services, click Add internal connection. Choose docsearch-db as the Service, turn on Add environment variables to the application, and click Add internal connection. Sevalla adds DB_URL and the other connection variables to the application.
4

Add the API key and the token

Click the Environment variables tab, then click Add environment variables. Add OPENAI_API_KEY with your key and SEARCH_TOKEN with the output of openssl rand -hex 32. Under Availability, keep Available during runtime selected and clear Available during buildtime, then click Add environment variables.
5

Create the two jobs

Click the Processes tab, then click Create process. Choose Job, enter Migrate as the Name and python -m app.migrate as the Custom start command, keep the Start policy at Before deployment and the Resource at H1, then click Create process. Create a second job named Ingest with the command python -m app.ingest and the Start policy After successful deployment.
6

Add the readiness probe

On the Processes tab, click ⋯ on the Web process, then click Update process. Under Health checks, turn on Readiness probe, set the Path to / and keep the Port at 8080. Click Update process. The probe keeps the previous instance serving until the new one answers, which / does once the database connection pool is open.
7

Deploy

Click the Overview tab, then click Deploy now under Recent deployments. Check the Branch and click Deploy.
The Migrate job runs before the new version starts, so the columns and indexes exist when the web process starts. The Ingest job runs after the web process has deployed. See Jobs.

Verify the deployment

  1. On the Deployments tab, click the deployment. Its build log shows Ingot’s build plan:
    Build log
    After the build, the log shows Migrate has been successful, Web has been deployed successfully, then Ingest has been successful.
  2. On the Logs tab, set Process to Ingest. The job printed a line per document, then Ingested 341 chunks from 39 documents.
  3. Click the domain under the application’s name, for example docsearch-x7k2m.sevalla.app. The response is {"chunks":341}.

Check from a terminal

The first two checks use the API, with API, H, APP_ID and DEPLOYMENT_ID set as in Deploy with the API. The searches read the token from your shell: run export SEARCH_TOKEN= followed by the value you set on Sevalla.
  1. The jobs’ results in the build log, from Get application deployment logs (getApplicationDeploymentLogsV3). gsub removes the terminal color codes:
  2. The ingestion’s last line, from Get application runtime logs (getApplicationRuntimeLogsV3), which searches the last hour; .[0] prints one line when several deployments ran:
  3. The application, with your domain:
  4. /search without the token:
  5. A search for an exact term, which full-text search finds and vector search misses:
  6. A question in your own words, which no chunk matches word for word, so only vector search finds it:

Compare the three searches

These ten queries ran against the deployed application’s database on Oct 30, 2026. Each cell is the position of the first chunk from the page that answers the query; “No match” means full-text search returned nothing, and “Not in top 20” means the page wasn’t among vector search’s 20 results.
  • Identifiers (the first six queries) are where vector search is weakest: an embedding model has seen fuzzystrmatch or BackoffLimitExceeded rarely or never, so their vectors land near unrelated text. Full-text search matches the exact word.
  • Questions (the last four) are where full-text search fails: websearch_to_tsquery requires every word of the query, after stop words, in the same chunk, and no chunk contains all of app, ran, memory, keeps and restarting. Vector search matches the meaning.
  • In every query, hybrid search ranked the page at least as high as the better of the two searches.
To test your own documents the same way, collect queries your users send with the page that should answer each, and compare semantic_rank and keyword_rank in the responses.

Measured latency and size

Measured on Oct 30, 2026, with this guide’s 341 chunks, the web process on H1 and the database on DB1: The fusion adds little to the two searches: the hybrid query takes about as long as both together. The embeddings request dominates, so a hybrid search costs the same embeddings call as a vector search. For how the HNSW index behaves with tens of thousands of chunks, see Scale the vector index. To weight one search more than the other, multiply its term in fused, for example 2 * coalesce(1.0 / (%(rrf_k)s + keyword.rank), 0) to favor exact matches in a catalog of part numbers.

Add hybrid search to the RAG application

The application in Build RAG with pgvector has the same documents and chunks tables without the tsv column. Add these two statements to the end of its app/schema.sql:
app/schema.sql
The next deployment’s Migrate job adds the column, and PostgreSQL computes it for every chunk already stored, so nothing needs to be embedded again. On later runs, the ALTER TABLE statement changes nothing and prints NOTICE: column "tsv" of relation "chunks" already exists, skipping. Then replace SEARCH in that application’s app/main.py with this guide’s query, passing the question as q, and select content and score in place of distance.

Deploy with a Dockerfile

Ingot needs no Dockerfile. Write your own when you want full control of the image: the base image, the system packages and every layer. Add a Dockerfile and a .dockerignore to the root of the repository:
Dockerfile
.dockerignore
The .dockerignore keeps your local .env and its key out of the image. Commit and push both files. Ingot doesn’t use the Dockerfile, so you can keep it in a repository that builds with Ingot, but it adds the .dockerignore patterns to its own: its build plan shows an Ignored row with 6 patterns from .dockerignore. Then switch the application to the Dockerfile build strategy:
1

Choose the Dockerfile build strategy

On the application’s Settings tab, in the Build strategy section, click Update build strategy. Click Dockerfile, keep the Dockerfile path Dockerfile and the Docker context ., then click Update build strategy.
2

Deploy

Click the Overview tab, then click Deploy now under Recent deployments. Check the Branch and click Deploy.
The build log shows 📦 Building and pushing docker image using Dockerfile... where Ingot’s build plan was, then Migrate has been successful and Ingest has been successful; the ingestion skips every document because none changed. The domain still returns {"chunks":341}, and the searches answer as before. In the test, a later deployment that changed only README.md reused the uv sync layer (CACHED in the build log). See Dockerfile. To switch with the API, send the same settings to Update application (updateApplicationV3), with API, H and APP_ID set as in Deploy with the API, then trigger and wait for a deployment as in that section:
CLI: sevalla apps update $APP_ID --data '{"build_type": "dockerfile", "dockerfile_path": "Dockerfile", "docker_context": "."}'. MCP: sevalla_api_update with updateApplicationV3 and the same body.

Deploy changes

With Auto-deploy on, every push to the branch starts a deployment; otherwise, click Deploy now for each change. Each deployment builds the image, runs Migrate, replaces the web process’s instances, then runs Ingest, which embeds only the files that changed. After you add a package with uv add, commit pyproject.toml and uv.lock together. See Deployments.

Add a custom domain

To serve the API from your own domain, such as search.example.com, add the domain on the application’s Domains tab and create the DNS records that Sevalla shows. Custom domains need a web process larger than H1, so scale the web process first. See Custom domains.

Troubleshoot

For more causes, see Troubleshoot builds and deployments.

Deploy with the API

The same deployment as Deploy to Sevalla, as calls to the Sevalla API that a script or an AI agent runs in order. Each step names the matching CLI command and Sevalla MCP server tool. The sequence needs:
  • An API key with the Company developer role, or the capabilities to create applications and databases, in SEVALLA_API_KEY.
  • The repository pushed to GitHub, and a GitHub account with access to it connected to Sevalla. The API has no call that reports the connection: if no connected account can read the repository, the step that creates the application fails with 400 and a message that starts with Cannot access repository.
  • OPENAI_API_KEY set in the shell, plus curl, jq and openssl.
Set the base URL, the authorization header and the repository:
1

Choose the location

List clusters (listClustersV3) returns every location with its region ID in location. Pick one by region ID, here Frankfurt, Germany:
CLI: sevalla resources clusters. MCP: get_platform_options.
2

Look up the sizes and the PostgreSQL version

List database resource types (listDatabaseResourceTypesV3), List process resource types (listProcessResourceTypesV3) and List database versions (listDatabaseVersionsV3):
CLI: sevalla resources db-types, sevalla resources process-types and sevalla resources db-versions. MCP: get_platform_options.
3

Create the database

Create database (createDatabaseV3) with extensions.enable_vector, the API’s name for Enable pgvector extension. You choose the password, and the API never returns it, so keep it in a variable. Then wait for ready with Get database (getDatabaseV3):
CLI: not available, because sevalla databases create has no option for the extension. MCP: create_database with extensions: {"vector": true}; it generates a password it never returns, so pass connect_to_application_id and env_var_name: "DB_URL" once the application exists, and leave the DB_* variables out of the environment variables step.
4

Create the application

Create application (createApplicationV3) creates it with a web process and doesn’t deploy it:
CLI: sevalla apps create --name docsearch --cluster $CLUSTER_ID --source privateGit --git-type github --repository $REPO_URL --branch $BRANCH. MCP: create_application with deploy: false.
5

Size the web process and add the readiness probe

The API creates the web process at S1, not the dashboard’s H1. List processes (listProcessesV3) finds it, and Update process (updateProcessV3) sets H1 and the readiness probe. Send timeoutSeconds: a probe set through the API without it waits 120 seconds, not the dashboard’s 1:
CLI: sevalla apps processes update $WEB_ID --app-id $APP_ID --resource-type $H1_ID; the CLI has no option for the probe. MCP: scale_process with size_id for the size, and sevalla_api_update with updateProcessV3 and the same body for the probe.
6

Connect the database

Create internal connection (createApplicationInternalConnectionV3). Unlike the dashboard, the API doesn’t add the connection’s environment variables; the next step adds them:
CLI: sevalla apps internal-connections create $APP_ID --target-id $DB_ID --target-type database. MCP: sevalla_api_create with createApplicationInternalConnectionV3 and the same body.
7

Add the environment variables

Create environment variable (createEnvironmentVariableV3) adds one variable per call. The loop adds the connection’s variables in the dashboard’s names and format, then the API key and a new SEARCH_TOKEN, all for runtime only:
It prints each key as it’s added. SEARCH_TOKEN stays in your shell for the checks.CLI: sevalla apps env-vars create $APP_ID --key SEARCH_TOKEN --value $SEARCH_TOKEN, once per variable; CLI 2.1.0 makes each variable available during build and runtime, with no option for runtime only. MCP: set_environment_variables with runtime: true and buildtime: false for each variable.
8

Create the two jobs

Create process (createProcessV3) needs the size, a scaling_strategy and, for a job, its job_start_policy:
CLI: sevalla apps processes create $APP_ID --name Migrate --type job --resource-type $H1_ID --command "python -m app.migrate" --job-start-policy beforeDeployment, and the same for Ingest with afterSuccessDeployment. MCP: sevalla_api_create with createProcessV3 and the same body.
9

Deploy

Trigger deployment (triggerDeploymentV3):
CLI: sevalla apps deployments trigger $APP_ID --branch $BRANCH. MCP: trigger_deployment.
10

Wait for the deployment

Get deployment (getDeploymentV3) until the status is final. It reaches success after the Ingest job, about 3 minutes after the trigger in the test:
On failed, read the whole build log with Get application deployment logs (getApplicationDeploymentLogsV3); without limit, it returns the latest 1,000 lines:
CLI: sevalla apps deployments get $DEPLOYMENT_ID --app-id $APP_ID and sevalla apps deployments logs $DEPLOYMENT_ID --app-id $APP_ID. MCP: get_deployment and get_deployment_logs.
11

Find the domain

List application domains (listApplicationDomainsV3) returns the sevalla.app domain as the primary one:
CLI: sevalla apps domains list --app-id $APP_ID. MCP: get_application.
Then run the checks in Check from a terminal, with https://$APP_HOST as the domain.