The post has been translated automatically. Original language: Russian
Using PostgreSQL can significantly reduce infrastructure costs, especially at product launch. A typical MVP search stack looks like this: PostgreSQL for basic data, Elasticsearch for full-text search, Pinecone or Weaviate for vector search. These are three separate services that need to be raised, paid for, monitored and synchronized with each other — and all this before the first paying customer appeared.
PostgreSQL closes the entire task with a single engine: pg_search is for full—text search, pgvector is for vector search. Below is an example of a hybrid search on Rails, where exact word matching and semantic meaning search live in the same database.
Before moving on to the code, let's briefly describe what we are working with.:
- pgvector is a Postgres extension that adds the vector data type, distance operators, and indexes (HNSW, IVFFlat) to quickly find the nearest vectors directly in the database.
- pg_search is a gem (library) that wraps the built—in Postgres full-text search (tsvector/tsquery) into a convenient ActiveRecord DSL. Searches by matching words, taking into account ranking and typos.
- neighbor — Ruby wrapper over pgvector operators, gives the nearest_neighbors method instead of the usual SQL query.
Why do I need hybrid search?
pg_search and pgvector catch different scenarios, and one misses the other regularly.:
- pg_search finds a document if the user has entered a word that literally occurs there. The "Solid Queue" query will find an article about the Solid Queue. But the query "how to handle background tasks in Rails" will not find anything if these words are not in the text, although there is a meaning there.
- pgvector finds by semantic proximity, not by word coincidence. The same ""background tasks"" query will find articles about Sidekiq and Solid Queue, even if the word "tasks" is not found there.
A hybrid search combines both results: an exact match catches one mechanism, a semantic one catches the other. Separately, everyone misses their half of the cases.
How does embedding get into the database and into the query
An important point that is easy to miss: both the document and the search query must go through the same embedding model before they can be compared.
- When saving a document, its text is sent to the embedding model (for example, via RubyLLM), which returns a vector of N numbers. This vector is placed in a column of the vector type.
- When a search query arrives, its text is run through the same model in the same way, getting a vector of the same dimension.
- ""Query vector" can only be compared with "document vectors" after that — you can only search for similar meanings in the same vector space.
If you use different embedding models for documents and for a query, the comparison will become meaningless, and the vectors will not be comparable.
Code: Rails + RubyLLM + pgvector + neighbor + pg_search
Model
```ruby
class Document < ApplicationRecord
include PgSearch::Model
has_neighbors :embedding, dimensions: 1536, distance: "cosine"
pg_search_scope :search_by_title_or_content,
against: [:title, :content],
using: { tsearch: { prefix: true, dictionary: "russian" } }
# each time a document is saved, a vector of its contents is generated
before_save :generate_embedding, if: :content_changed?
private
def generate_embedding
self.embedding = RubyLLM.embed(content).vectors.first
end
end
```Methodgenerate_embedding is called before saving the document (via before_save) and does one thing: it takes text from the content field, sends it to the embedding model via RubyLLM.embed(content), and gets back a vector of numbers (.vectors.first) and writes it to the embedding field of the same object.
Hybrid Search
```ruby
class HybridSearch
def initialize(query)
@query = query
end
def call
# turning a text query into a vector using the same embedding model,
# what was used for documents — otherwise the comparison does not make sense
embedding = RubyLLM.embed(@query).vectors.first
# semantic search: finds documents by semantic proximity,
# even if the query words are not found in the text literally
vector_results = Document
.nearest_neighbors(:embedding, embedding, distance: "cosine")
.limit(5)
.to_a
# Full-text search: Finds documents by exact word match
fulltext_results = Document
.search_by_title_or_content(@query)
.limit(5)
.to_a
# combine both lists and remove duplicates, leaving the first 5 results
(vector_results + fulltext_results).uniq(&:id).first(5)
end
end
```Calling the method
```ruby
HybridSearch.new("how to process tasks in the background without blocking").call
# will find articles about Sidekiq/Solid Queue through semantics,
# although there is not a single word from the query in them
``This is a simple union without weighting (union + dedup). For more accurate ranking, Reciprocal Rank Fusion is used — they take into account the position of the document in each of the two lists, rather than simply gluing them together.
An example based on real data
We will add several documents to the database:
``ruby
documents = [
{ title: "Rails 8 has made Solid Queue the default",
content: """"Rails 8 includes Solid Queue as the default backend for Active Jobs, replacing Sidekiq for simple cases."" },
{ title: ""Sidekiq remains the standard for complex jobs",
content: "For heavy background tasks with retry logic and monitoring, Sidekiq is still preferable to embedded Rails solutions." },
{ title: "pgvector 0.7 added binary quantization",
content: ""The new version of pgvector supports binary quantization, reducing memory consumption by the HNSW index." },
{ title: "Postgres 17 has improved VACUUM",
content: ""Postgres 17 has redesigned the VACUUM algorithm, reducing the I/O load for large tables."" },
{ title: "RubyLLM makes it easier to work with embeddings",
content: "The RubyLLM library provides a single interface for generating embeddings via OpenAI, Anthropic, and local models." }
]
documents.each { |d| Document.create!(title: d[:title], content: d[:content]) }
```Query without matching words: "how to process tasks in the background without blocking"
```ruby
Document.search_by_title_or_content("how to process tasks in the background without blocking")
# => [] — there are literally no words from the query in any document
embedding = RubyLLM.embed("how to process tasks in the background without blocking").vectors.first
Document.nearest_neighbors(:embedding, embedding, distance: "cosine").limit(3)
# => 1. Sidekiq remains the standard for complex jobs — distance: 0.16
#2. Rails 8 made Solid Queue the default — distance: 0.21
# 3. RubyLLM makes it easier to work with embeddings — distance: 0.52
HybridSearch.new("how to process tasks in the background without blocking").call
#=> [Sidekiq remains the standard..., Rails 8 has made Solid Queue the default]
``pg_search returned an empty list, the entire result came from pgvector — the semantics found the meaning of the "task in the background" without a single matching word.
Query with the exact term: "VACUUM Postgres 17"
```ruby
HybridSearch.new("VACUUM Postgres 17").call
# => 1. Postgres 17 improved VACUUM ← matched both lists, top
# 2. pgvector 0.7 added binary quantization " tightened only by semantics
``
pg_search immediately found an exact match here and keeps it at the top, and pgvector added a document semantically close to the topic of "Postgres performance" as the second result, even though the word VACUUM does not occur there.
| Request | pg_search | pgvector | The result of the hybrid |
| "tasks in the background without blocking" | empty | 2 relevant ones | takes it from pgvector |
| "VACUUM Postgres 17" | An exact match | coincidence + thematic noise | pg_search keeps the top, pgvector adds context |
When the user remembers the exact term, pg_search wins; when he formulates the query in his own words, pgvector wins. A simple union (uniq + first(N)) without weighting in some cases may skip a document that is semantically "nearby", but does not answer the question directly — here it is worth switching to RRF with weights instead of flat gluing lists.
Why is PostgresSQL good for MVP
The entire example above works in one database, without a single additional service. This is the main argument in favor of PostgreSQL at the launch of the product: pgvector is a semantic search without a separate vector database, pg_search is a full-text search without Elasticsearch, and JSONB closes document-like storage without MongoDB nearby — a flexible data structure without migration for every schema change. Less infrastructure means less operational overhead and faster iteration, and you can migrate to specialized solutions later when there is a real workload and clear metrics, not "just in case" in advance.
Использование PostgreSQL может значительно снизить затраты на инфраструктуру — особенно на старте продукта. Типичный стек для поиска на MVP выглядит так: PostgreSQL для основных данных, Elasticsearch для полнотекстового поиска, Pinecone или Weaviate для векторного. Это три отдельных сервиса, которые нужно поднять, оплатить, мониторить и синхронизировать между собой — и всё это до того, как появился первый платящий клиент.
PostgreSQL закрывает всю задачу одним движком: pg_search — для полнотекстового поиска, pgvector — для векторного. Ниже — пример гибридного поиска на Rails, где точное совпадение по словам и семантический поиск по смыслу живут в одной базе.
Прежде чем переходить к коду, коротко о том, с чем работаем:
- pgvector — расширение Postgres, которое добавляет тип данных vector, операторы расстояния и индексы (HNSW, IVFFlat) для быстрого поиска ближайших векторов прямо в базе.
- pg_search — gem (библиотека), оборачивающий встроенный полнотекстовый поиск Postgres (tsvector/tsquery) в удобный ActiveRecord DSL. Ищет по совпадению слов, с учётом ранжирования и опечаток.
- neighbor — Ruby-обёртка над операторами pgvector, даёт метод nearest_neighbors вместо обычного SQL запроса.
Зачем нужен гибридный поиск
pg_search и pgvector ловят разные сценарии, и один без другого регулярно промахивается:
- pg_search находит документ, если пользователь ввёл слово, которое там буквально встречается. Запрос "Solid Queue" найдёт статью про Solid Queue. Но запрос "как обрабатывать фоновые задачи в Rails" — не найдёт ничего, если в тексте нет этих слов, хотя смысл там есть.
- pgvector находит по смысловой близости, а не по совпадению слов. Тот же запрос про "фоновые задачи" найдёт статьи про Sidekiq и Solid Queue, даже если слово "задачи" там не встречается.
Гибридный поиск объединяет оба результата: точное совпадение ловит один механизм, смысловое — другой. Порознь каждый упускает свою половину кейсов.
Как эмбеддинг попадает в базу и в запрос
Важный момент, который легко упустить: и документ, и поисковый запрос должны пройти через одну и ту же embedding-модель, прежде чем их можно сравнивать.
- При сохранении документа его текст отправляется в embedding-модель (например, через RubyLLM), которая возвращает вектор из N чисел. Этот вектор кладётся в колонку типа vector.
- Когда приходит поисковый запрос — его текст точно так же прогоняется через ту же модель, получая вектор той же размерности.
- Только после этого можно сравнивать "вектор запроса" с "векторами документов" — искать похожие смыслы можно только в одном и том же векторном пространстве.
Если для документов и для запроса использовать разные модели эмбеддингов — сравнение станет бессмысленным, векторы будут не сопоставимы.
Код: Rails + RubyLLM + pgvector + neighbor + pg_search
Модель
```ruby
class Document < ApplicationRecord
include PgSearch::Model
has_neighbors :embedding, dimensions: 1536, distance: "cosine"
pg_search_scope :search_by_title_or_content,
against: [:title, :content],
using: { tsearch: { prefix: true, dictionary: "russian" } }
# при каждом сохранении документа формируется вектор его содержимого
before_save :generate_embedding, if: :content_changed?
private
def generate_embedding
self.embedding = RubyLLM.embed(content).vectors.first
end
end
```Метод generate_embedding вызывается перед сохранением документа (через before_save) и делает одну вещь: берёт текст из поля content, отправляет его в embedding-модель через RubyLLM.embed(content), получает обратно вектор чисел (.vectors.first) и записывает его в поле embedding того же объекта.
Гибридный поиск
```ruby
class HybridSearch
def initialize(query)
@query = query
end
def call
# превращаем текстовый запрос в вектор той же embedding-моделью,
# что использовалась для документов — иначе сравнение не имеет смысла
embedding = RubyLLM.embed(@query).vectors.first
# семантический поиск: находит документы по смысловой близости,
# даже если слова запроса не встречаются в тексте буквально
vector_results = Document
.nearest_neighbors(:embedding, embedding, distance: "cosine")
.limit(5)
.to_a
# полнотекстовый поиск: находит документы по точному совпадению слов
fulltext_results = Document
.search_by_title_or_content(@query)
.limit(5)
.to_a
# объединяем оба списка и убираем дубликаты, оставляя первые 5 результатов
(vector_results + fulltext_results).uniq(&:id).first(5)
end
end
```Вызов метода
```ruby
HybridSearch.new("как обрабатывать задачи в фоне без блокировки").call
# найдёт статьи про Sidekiq/Solid Queue через семантику,
# хотя ни одного слова из запроса в них нет
```Это простое объединение без взвешивания (union + dedup). Для более точного ранжирования применяют Reciprocal Rank Fusion — учитывают позицию документа в каждом из двух списков, а не просто склеивают их.
Пример на реальных данных
Добавим в базу несколько документов:
```ruby
documents = [
{ title: "Rails 8 сделал Solid Queue дефолтом",
content: "Rails 8 включает Solid Queue как дефолтный backend для Active Job, заменяя Sidekiq для простых кейсов." },
{ title: "Sidekiq остаётся стандартом для сложных джобов",
content: "Для тяжёлых фоновых задач с retry-логикой и мониторингом Sidekiq всё ещё предпочтительнее встроенных решений Rails." },
{ title: "pgvector 0.7 добавил бинарную квантизацию",
content: "Новая версия pgvector поддерживает binary quantization, снижая потребление памяти индексом HNSW." },
{ title: "Postgres 17 улучшил VACUUM",
content: "Postgres 17 переработал алгоритм VACUUM, снизив нагрузку на I/O при больших таблицах." },
{ title: "RubyLLM упрощает работу с эмбеддингами",
content: "Библиотека RubyLLM даёт единый интерфейс для генерации embeddings через OpenAI, Anthropic и локальные модели." }
]
documents.each { |d| Document.create!(title: d[:title], content: d[:content]) }
```Запрос без совпадения слов: "как обрабатывать задачи в фоне без блокировки"
```ruby
Document.search_by_title_or_content("как обрабатывать задачи в фоне без блокировки")
# => [] — слов из запроса буквально нет ни в одном документе
embedding = RubyLLM.embed("как обрабатывать задачи в фоне без блокировки").vectors.first
Document.nearest_neighbors(:embedding, embedding, distance: "cosine").limit(3)
# => 1. Sidekiq остаётся стандартом для сложных джобов — distance: 0.16
# 2. Rails 8 сделал Solid Queue дефолтом — distance: 0.21
# 3. RubyLLM упрощает работу с эмбеддингами — distance: 0.52
HybridSearch.new("как обрабатывать задачи в фоне без блокировки").call
# => [Sidekiq остаётся стандартом..., Rails 8 сделал Solid Queue дефолтом]
```pg_search вернул пустой список, весь результат пришёл от pgvector — семантика нашла смысл "задачи в фоне" без единого совпадающего слова.
Запрос с точным термином: "VACUUM Postgres 17"
```ruby
HybridSearch.new("VACUUM Postgres 17").call
# => 1. Postgres 17 улучшил VACUUM ← совпал в обоих списках, топ
# 2. pgvector 0.7 добавил бинарную квантизацию ← подтянут только семантикой
```
Здесь pg_search сразу нашёл точное совпадение и держит его в топе, а pgvector добавил вторым результатом документ, семантически близкий по теме "Postgres-производительность", хоть слово VACUUM там и не встречается.
| Запрос | pg_search | pgvector | Итог гибрида |
| "задачи в фоне без блокировки" | пусто | 2 релевантных | берёт из pgvector |
| "VACUUM Postgres 17" | точное совпадение | совпадение + тематический шум | pg_search держит топ, pgvector добавляет контекст |
Когда пользователь помнит точный термин — выигрывает pg_search; когда формулирует запрос своими словами — выигрывает pgvector. Простое объединение (uniq + first(N)) без взвешивания в отдельных случаях может пропустить в топ документ, который семантически "рядом", но не отвечает на вопрос напрямую — здесь стоит переходить на RRF с весами вместо плоского склеивания списков.
Почему PostgresSQL хорош для MVP
Весь пример выше работает в одной базе, без единого дополнительного сервиса. Это и есть главный аргумент в пользу PostgreSQL на старте продукта: pgvector — семантический поиск без отдельной векторной БД, pg_search — полнотекстовый поиск без Elasticsearch, а JSONB закрывает документо-подобное хранение без MongoDB рядом — гибкая структура данных без миграции на каждое изменение схемы. Меньше инфраструктуры — значит меньше operational overhead и быстрее итерации, а мигрировать на специализированные решения можно позже, когда появится реальная нагрузка и понятные метрики, а не заранее "на всякий случай".