We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
From the blog
Hybrid Search in Elixir: Combining pgvector and Postgres Full-Text Search
By Liam Killingback ·
Hybrid Search in Elixir: Combining pgvector and Postgres Full-Text Search
Semantic search feels like magic right up until a user types an order number.
You built a pgvector pipeline, embeddings are flowing, and “how do I cancel my plan” happily
finds the paragraph titled “Ending your subscription” even though the two share almost no
words. Then someone searches for ERR_TOKEN_4021 and the top result is a friendly page about
password resets. The embedding model never saw that error code during training, so it has no
idea what it means. It returns something vaguely technical and shrugs.
Keyword search has the opposite failure. It nails ERR_TOKEN_4021 and completely misses
“cancel my plan” because the document says “terminate” instead of “cancel”.
Hybrid search in Elixir fixes both by running the two searches side by side and merging the
results. This post shows how to build it on plain Postgres with pgvector and tsvector, fuse
the two ranked lists with Reciprocal Rank Fusion, and wire the whole thing into a LiveView.
No search cluster, no extra service, no Python sidecar. If you have not set up pgvector yet,
start with our guide to
RAG in Elixir with pgvector and Postgres,
then come back here to make it actually usable.
Why hybrid search beats either half
The two retrieval methods fail in different, complementary places.
Dense vector search (pgvector) encodes meaning. It handles paraphrase, synonyms and
questions phrased nothing like the source text. It is weak on rare tokens: product SKUs, error
codes, person names, version numbers, internal acronyms. Those get averaged into a 1536
dimensional soup where nothing distinguishes 4021 from 4022.
Sparse keyword search (Postgres full-text) encodes exact lexical overlap. It is unbeatable on rare tokens and worthless when the query and the document use different vocabulary.
Real user queries are a mix. Someone searching your docs types “invoice PDF not generating for org 8812” in one session and “why is billing broken” in the next. A single retriever gets one of those right. Hybrid gets both, which matters enormously if these results feed an LLM: bad retrieval is the single largest source of wrong answers in a RAG app, and no amount of prompt engineering fixes a context window full of irrelevant chunks.
The honest caveat up front: hybrid search costs you two index scans per query instead of one, plus fusion work. On a corpus of a few million chunks that is still single-digit milliseconds of Postgres time. It is not free, and if your corpus is pure prose with no identifiers in it, plain vector search may genuinely be enough. Measure before you add complexity.
The schema: one table, two indexes
Everything lives in one table. Postgres stores the embedding and the lexical index side by side, which is the whole reason this stays simple.
defmodule MyApp.Repo.Migrations.CreateDocumentChunks do
use Ecto.Migration
def up do
execute "CREATE EXTENSION IF NOT EXISTS vector"
create table(:document_chunks) do
add :document_id, references(:documents, on_delete: :delete_all), null: false
add :content, :text, null: false
add :embedding, :vector, size: 1536
timestamps(type: :utc_datetime)
end
create index(:document_chunks, [:document_id])
# A generated column means Postgres keeps the lexical index in sync for us.
# No triggers, no application code, no chance of drift.
execute """
ALTER TABLE document_chunks
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (to_tsvector('english', content)) STORED
"""
execute "CREATE INDEX document_chunks_search_vector_idx ON document_chunks USING GIN (search_vector)"
execute """
CREATE INDEX document_chunks_embedding_idx
ON document_chunks
USING hnsw (embedding vector_cosine_ops)
"""
end
def down do
drop table(:document_chunks)
execute "DROP EXTENSION IF EXISTS vector"
end
end
Two details worth pausing on.
The generated tsvector column is the part people usually get wrong. The alternative is a
trigger or an after_insert hook in your context, and both eventually drift when someone
updates a row in a migration or a console session. GENERATED ALWAYS AS ... STORED makes drift
structurally impossible.
The hnsw index uses vector_cosine_ops because OpenAI embeddings are normalised and cosine
distance is the right metric for them. If you switch to a model that is not normalised, or you
use inner product, change the operator class to match. An index built for the wrong operator is
silently ignored by the planner, and your query stays correct but crawls.
The Ecto schema is unremarkable:
defmodule MyApp.Search.DocumentChunk do
use Ecto.Schema
schema "document_chunks" do
field :content, :string
field :embedding, Pgvector.Ecto.Vector
belongs_to :document, MyApp.Documents.Document
timestamps(type: :utc_datetime)
end
end
Add {:pgvector, "~> 0.3"} to your deps, and add Postgrex.Types setup as described in the
pgvector-elixir readme so the vector type round-trips properly.
Half one: keyword candidates
Postgres full-text search is genuinely good, and most Phoenix developers only ever use ilike
because nobody told them otherwise.
defmodule MyApp.Search do
import Ecto.Query
import Pgvector.Ecto.Query
alias MyApp.Repo
alias MyApp.Search.DocumentChunk
def keyword_candidates(query_text, limit \\ 40) do
from(c in DocumentChunk,
where: fragment("search_vector @@ websearch_to_tsquery('english', ?)", ^query_text),
order_by: [
desc:
fragment(
"ts_rank_cd(search_vector, websearch_to_tsquery('english', ?))",
^query_text
)
],
limit: ^limit,
select: %{id: c.id, content: c.content, document_id: c.document_id}
)
|> Repo.all()
end
end
Use websearch_to_tsquery rather than plainto_tsquery or to_tsquery. It accepts the syntax
users already know from Google (quoted phrases, or, a leading - to exclude) and, critically,
it never raises on malformed input. to_tsquery will happily crash your LiveView when someone
types a stray &.
ts_rank_cd is the cover-density ranker: it rewards documents where the query terms appear
close together, which is usually what you want for chunk-level retrieval.
Half two: semantic candidates
The vector side needs an embedding of the query, which means an API call on the hot path.
defmodule MyApp.Search.Embeddings do
@model "text-embedding-3-small"
def embed(text) do
Req.post("https://api.openai.com/v1/embeddings",
auth: {:bearer, System.fetch_env!("OPENAI_API_KEY")},
json: %{model: @model, input: text},
receive_timeout: 20_000,
retry: :transient
)
|> case do
{:ok, %Req.Response{status: 200, body: %{"data" => [%{"embedding" => embedding} | _]}}} ->
{:ok, embedding}
{:ok, %Req.Response{status: status, body: body}} ->
{:error, {:api_error, status, body}}
{:error, reason} ->
{:error, reason}
end
end
end
Then the query itself:
def semantic_candidates(embedding, limit \\ 40) do
vector = Pgvector.new(embedding)
from(c in DocumentChunk,
order_by: cosine_distance(c.embedding, ^vector),
limit: ^limit,
select: %{id: c.id, content: c.content, document_id: c.document_id}
)
|> Repo.all()
end
cosine_distance/2 comes from Pgvector.Ecto.Query and compiles down to the <=> operator, so
the HNSW index is used.
One practical note: embedding the query costs roughly 20 to 60 milliseconds and real money at scale. Cache it. A tiny ETS cache keyed on the normalised query string removes the API call for repeat searches, which on a typical support-docs corpus is a large share of traffic. If you are already metering AI calls per customer, the same hot path is a natural place to count them.
Fusing the two lists with Reciprocal Rank Fusion
Now you have two ranked lists and a problem: their scores are not comparable. ts_rank_cd
returns something like 0.08, cosine distance returns something like 0.31, and normalising
them against each other requires knowing the score distribution of your corpus, which changes
every time you reindex.
Reciprocal Rank Fusion sidesteps this entirely by throwing the scores away and using only the
ranks. Each document scores weight / (k + rank), summed across every list it appears in. The
constant k (60 is the value from the original paper and a fine default) damps the influence of
the very top positions so that a document ranked second in both lists can beat a document ranked
first in one and absent from the other.
defmodule MyApp.Search.RRF do
@default_k 60
@doc """
Fuses ranked lists into a single ordering.
Takes `{rows, weight}` tuples where each `rows` list is already sorted best-first
and every row has an `:id`.
"""
def fuse(weighted_lists, opts \\ []) do
k = Keyword.get(opts, :k, @default_k)
weighted_lists
|> Enum.flat_map(fn {rows, weight} ->
rows
|> Enum.with_index(1)
|> Enum.map(fn {row, rank} -> {row.id, weight / (k + rank), row} end)
end)
|> Enum.group_by(fn {id, _score, _row} -> id end)
|> Enum.map(fn {_id, entries} ->
score = entries |> Enum.map(fn {_id, score, _row} -> score end) |> Enum.sum()
{_id, _score, row} = hd(entries)
Map.put(row, :score, score)
end)
|> Enum.sort_by(& &1.score, :desc)
end
end
And the public search function that ties it together:
def hybrid_search(query_text, opts \\ []) do
limit = Keyword.get(opts, :limit, 10)
candidates = Keyword.get(opts, :candidates, 40)
keyword_weight = Keyword.get(opts, :keyword_weight, 1.0)
semantic_weight = Keyword.get(opts, :semantic_weight, 1.0)
keyword_task = Task.async(fn -> keyword_candidates(query_text, candidates) end)
semantic_task =
Task.async(fn ->
case MyApp.Search.Embeddings.embed(query_text) do
{:ok, embedding} -> semantic_candidates(embedding, candidates)
{:error, _reason} -> []
end
end)
keyword_rows = Task.await(keyword_task, 10_000)
semantic_rows = Task.await(semantic_task, 25_000)
[{keyword_rows, keyword_weight}, {semantic_rows, semantic_weight}]
|> MyApp.Search.RRF.fuse()
|> Enum.take(limit)
end
The two halves run concurrently, which is the whole point of doing this on the BEAM: the total latency is the slower of the two, not the sum. And notice that a failed embedding call degrades to keyword-only results instead of returning an error page. When OpenAI has a bad afternoon, your search box still works.
Pull candidates from a config value. Fetching 40 per side and returning 10 is a reasonable
starting point; if your users complain that obviously relevant results are missing, raise the
candidate pool before you touch the weights.
Doing the fusion in SQL instead
If you would rather not move 80 rows into the BEAM per query, Postgres can fuse for you with two CTEs:
WITH semantic AS (
SELECT id, RANK() OVER (ORDER BY embedding <=> $1) AS rank
FROM document_chunks
ORDER BY embedding <=> $1
LIMIT 40
),
keyword AS (
SELECT id,
RANK() OVER (
ORDER BY ts_rank_cd(search_vector, websearch_to_tsquery('english', $2)) DESC
) AS rank
FROM document_chunks
WHERE search_vector @@ websearch_to_tsquery('english', $2)
LIMIT 40
)
SELECT c.id,
c.content,
COALESCE(1.0 / (60 + semantic.rank), 0.0) +
COALESCE(1.0 / (60 + keyword.rank), 0.0) AS score
FROM document_chunks c
LEFT JOIN semantic ON semantic.id = c.id
LEFT JOIN keyword ON keyword.id = c.id
WHERE semantic.id IS NOT NULL OR keyword.id IS NOT NULL
ORDER BY score DESC
LIMIT 10;
This is faster and it is one round trip, but it loses the concurrency (the embedding still has to be computed first) and it is harder to unit test. Start with the Elixir version, move to SQL if profiling says the transfer cost matters. In practice, for most apps, it does not.
Wiring it into LiveView
Search is exactly the case start_async was built for: the user keeps typing while the query
runs, and stale results must not overwrite fresh ones.
defmodule MyAppWeb.SearchLive do
use MyAppWeb, :live_view
def mount(_params, _session, socket) do
{:ok, assign(socket, query: "", results: [], loading?: false)}
end
def handle_event("search", %{"q" => q}, socket) when byte_size(q) > 2 do
socket =
socket
|> assign(query: q, loading?: true)
|> cancel_async(:search)
|> start_async(:search, fn -> MyApp.Search.hybrid_search(q) end)
{:noreply, socket}
end
def handle_event("search", %{"q" => q}, socket) do
{:noreply, assign(socket, query: q, results: [], loading?: false)}
end
def handle_async(:search, {:ok, results}, socket) do
{:noreply, assign(socket, results: results, loading?: false)}
end
def handle_async(:search, {:exit, reason}, socket) do
require Logger
Logger.error("hybrid search failed: #{inspect(reason)}")
{:noreply,
socket
|> assign(loading?: false)
|> put_flash(:error, "Search is having trouble. Please try again.")}
end
end
cancel_async/2 before each new start_async/3 is what keeps the results consistent. Without
it, a slow query for “err” can land after a fast query for “err_token_4021” and show the wrong
list. Debounce the input in the template with phx-debounce="300" so you are not embedding
every keystroke.
Building a document search product on top of this? Both pdfai and phx_ai_document ship the ingestion, chunking and pgvector wiring already done, so you can spend your time on ranking quality rather than on plumbing.
Testing that it actually works
The single highest-value test is the one that encodes why you built hybrid search at all: an exact identifier must win.
defmodule MyApp.SearchTest do
use MyApp.DataCase, async: true
import MyApp.SearchFixtures
describe "hybrid_search/2" do
test "an exact error code outranks semantically similar prose" do
target = chunk_fixture(content: "ERR_TOKEN_4021 means the refresh token expired.")
_decoy = chunk_fixture(content: "Authentication problems are usually caused by tokens.")
assert [%{id: id} | _] = MyApp.Search.hybrid_search("ERR_TOKEN_4021")
assert id == target.id
end
test "falls back to keyword results when embedding fails" do
chunk_fixture(content: "ERR_TOKEN_4021 means the refresh token expired.")
expect_embedding_error()
assert [_ | _] = MyApp.Search.hybrid_search("ERR_TOKEN_4021")
end
end
end
Stub the embedding call with Req.Test rather than hitting the API in CI. Beyond unit tests,
keep a small file of maybe 30 real queries with their expected top result and run it as a
recall check whenever you change chunking, weights or the embedding model. It is not a
sophisticated eval harness, and it will still catch the majority of ranking regressions.
Tuning, and where this approach runs out
A few things worth knowing before this hits production.
Weights are a blunt instrument. If your corpus is heavy on identifiers (logs, tickets, API
docs) push keyword_weight to something like 1.5. If it is conversational prose, favour the
semantic side. Change one weight at a time and check it against your query file.
k matters less than you think. Values between 20 and 100 barely move the ordering. Leave
it at 60 unless you have a measurement telling you otherwise.
Language configuration is not cosmetic. to_tsvector('english', ...) applies English
stemming and stop words. If your content is multilingual, either store a per-row language
column and use it in the generated column, or use the simple configuration and accept worse
stemming.
Chunking dominates everything. No fusion strategy rescues chunks that split a sentence in half or bundle six unrelated topics together. If results are bad, look at your chunks before you look at your ranking. Our post on extracting structured data from PDFs with AI in Elixir covers getting clean text out in the first place, which is where most of the quality is won or lost.
Reranking is the next step, not a replacement. Once hybrid retrieval gives you a solid top 20, a cross-encoder or a cheap LLM pass that reorders those 20 will add real precision. It also adds latency and cost per query, so add it only after retrieval is good. Fusing garbage more carefully still gives you garbage.
Know when to leave Postgres. At tens of millions of chunks with heavy concurrent write traffic, HNSW index maintenance starts to hurt and a dedicated vector database earns its keep. Below that, and that covers almost every SaaS, Postgres doing both jobs in one query is simpler, cheaper and easier to reason about than operating a second datastore.
Wrapping up
Hybrid search in Elixir is not a research project. It is one table, two indexes, two queries that run concurrently, and about thirty lines of fusion code. You get the recall of embeddings and the precision of keyword matching without adding a single service to your deployment, and the BEAM makes running the two halves in parallel a one-liner rather than an async refactor.
If you are feeding these results into a model, the retrieval quality you just gained is worth more than any prompt tweak. Pair it with the patterns in our guide to building an AI chatbot with Phoenix LiveView and you have a genuinely solid RAG stack on infrastructure you already run.
And if you would rather start from a working app than a blank mix phx.new, the
Builder Pass gives you every PhxTemplates starter,
including the AI and document templates, for one lifetime price. The AI-native, use-case-specific
Phoenix starter, without the month of plumbing.