/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
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:- Full-text search returns the 20 chunks that match the query best, ranked by
ts_rank_cd. The query goes throughwebsearch_to_tsquery, so it supports the syntax of a web search engine:"exact phrase",-wordto exclude a word, andor. - Vector search returns the 20 chunks closest to the query’s embedding, by cosine distance.
- 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 scores0.0164; a chunk ranked first in both scores0.0328, so chunks that both searches find move to the top. When scores tie, the keyword match comes first.
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
tsvis a generated column: PostgreSQL computes it fromcontenton every insert and update, so the code never writes it.to_tsvector('english', ...)splits the text into words, drops stop words such astheandand, and reduces each word to its stem, sorestartingmatchesrestart. 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 withvector_cosine_ops, serves the vector search, which sorts by cosine distance (<=>).- Every statement can run more than once.
execute() when it has no parameters:
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: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:
tsv column, and a search for a file name finds it.
Search
The application has two routes:/ returns the number of chunks, and /search runs the hybrid query:
- One query.
semanticandkeywordeach rank their 20 best chunks withrank(),fusedjoins them with aFULL OUTER JOIN, so a chunk that only one search found keeps a score, and the finalSELECTadds 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_rankandkeyword_rank,nullwhen that search didn’t return the chunk, so you can see which search found it. - Token.
/searchcalls the embeddings API on every request, so it requiresAuthorization: Bearer <token>with the value ofSEARCH_TOKEN. A request without the header gets401with{"detail":"Not authenticated"}, and a wrong token gets{"detail":"Invalid token"}. - Input limits.
qtakes 1 to 500 characters andlimit1 to 20 results; anything else gets422. - Connections. The pool opens when the application starts, and
check_connectionreplaces a broken connection before a request uses it, for example after a database restart.
Add the documents
Put the files to search in adocs 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:
Run it on your computer
Start PostgreSQL with pgvector in Docker:.env file with the database URL, your OpenAI API key and a token of your choice. .gitignore keeps it out of the repository:
Ingested 341 chunks from 39 documents. In a second terminal, search for an error name that appears on one page:
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:Deploy to Sevalla
These steps build with Ingot. To build from your own Dockerfile instead, see Deploy with a Dockerfile.Create the database
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.Create the application
Connect the database
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.Add the API key and the token
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.Create the two jobs
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.Add the readiness probe
/ 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.Deploy
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
-
On the Deployments tab, click the deployment. Its build log shows Ingot’s build plan:
After the build, the log showsBuild log
Migrate has been successful,Web has been deployed successfully, thenIngest has been successful. -
On the Logs tab, set Process to
Ingest. The job printed a line per document, thenIngested 341 chunks from 39 documents. -
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, withAPI, 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.
-
The jobs’ results in the build log, from Get application deployment logs (
getApplicationDeploymentLogsV3).gsubremoves the terminal color codes: -
The ingestion’s last line, from Get application runtime logs (
getApplicationRuntimeLogsV3), which searches the last hour;.[0]prints one line when several deployments ran: -
The application, with your domain:
-
/searchwithout the token: -
A search for an exact term, which full-text search finds and vector search misses:
-
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
fuzzystrmatchorBackoffLimitExceededrarely 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_tsqueryrequires every word of the query, after stop words, in the same chunk, and no chunk contains all ofapp,ran,memory,keepsandrestarting. Vector search matches the meaning. - In every query, hybrid search ranked the page at least as high as the better of the two searches.
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:Tune the search
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 samedocuments and chunks tables without the tsv column. Add these two statements to the end of its app/schema.sql:
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 aDockerfile and a .dockerignore to the root of the repository:
.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:
Choose the Dockerfile build strategy
Dockerfile and the Docker context ., then click Update build strategy.Deploy
📦 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:
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, runsMigrate, 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 assearch.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
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
400and a message that starts withCannot access repository. OPENAI_API_KEYset in the shell, pluscurl,jqandopenssl.
Choose the location
listClustersV3) returns every location with its region ID in location. Pick one by region ID, here Frankfurt, Germany:sevalla resources clusters. MCP: get_platform_options.Look up the sizes and the PostgreSQL version
listDatabaseResourceTypesV3), List process resource types (listProcessResourceTypesV3) and List database versions (listDatabaseVersionsV3):sevalla resources db-types, sevalla resources process-types and sevalla resources db-versions. MCP: get_platform_options.Create the 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):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.Create the application
createApplicationV3) creates it with a web process and doesn’t deploy it:sevalla apps create --name docsearch --cluster $CLUSTER_ID --source privateGit --git-type github --repository $REPO_URL --branch $BRANCH. MCP: create_application with deploy: false.Size the web process and add the readiness probe
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: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.Connect the database
createApplicationInternalConnectionV3). Unlike the dashboard, the API doesn’t add the connection’s environment variables; the next step adds them:sevalla apps internal-connections create $APP_ID --target-id $DB_ID --target-type database. MCP: sevalla_api_create with createApplicationInternalConnectionV3 and the same body.Add the environment variables
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: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.Create the two jobs
createProcessV3) needs the size, a scaling_strategy and, for a job, its job_start_policy: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.Deploy
triggerDeploymentV3):sevalla apps deployments trigger $APP_ID --branch $BRANCH. MCP: trigger_deployment.Wait for the deployment
getDeploymentV3) until the status is final. It reaches success after the Ingest job, about 3 minutes after the trigger in the test:failed, read the whole build log with Get application deployment logs (getApplicationDeploymentLogsV3); without limit, it returns the latest 1,000 lines: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.Find the domain
listApplicationDomainsV3) returns the sevalla.app domain as the primary one:sevalla apps domains list --app-id $APP_ID. MCP: get_application.https://$APP_HOST as the domain.