← You Don't Need Elasticsearch

Chapter 18: Search Across Frameworks

The Waiter of Gold Lapel · Published Apr 12, 2026 · 8 min

It is not my place to have opinions about which language you chose for your application. You had your reasons — team expertise, ecosystem requirements, performance characteristics, a codebase that predates the current decade. These are reasonable considerations, and I respect every one of them.

What IS my place is ensuring that the search works equally well in all of them.

Gold Lapel supports seven languages. The API is the same in all seven. The methods are the same. The return format is the same. The differences are naming conventions and parameter binding — the surface grammar of each language, not the substance. You should not have to choose a search solution based on which languages it supports. You should choose a language based on your needs, and the search should simply be there when you need it.

This chapter shows the patterns. Appendix A has the complete per-language reference. What follows is the demonstration that the pattern is, in fact, universal.

The Universal Pattern

Every search method across all seven languages follows the same structure:

  1. Call the method with connection, table, column(s), query, and options.
  2. The wrapper generates parameterized SQL.
  3. The SQL is sent through the Gold Lapel proxy.
  4. The proxy detects the pattern and auto-creates the appropriate index.
  5. Results return with all columns + _score + optional _highlight.

All identifiers (table names, column names) are validated against ^[a-zA-Z_][a-zA-Z0-9_]*$ before interpolation. All values are parameterized. SQL injection safe by construction. This security model does not vary across languages — it is not a feature of the Python wrapper or the Java wrapper. It is a property of the architecture.

Extensions are created lazily on first use. The first call to search_fuzzy() triggers CREATE EXTENSION IF NOT EXISTS pg_trgm if it hasn’t been created. The developer never writes CREATE EXTENSION. The wrapper handles it. In every language. Identically.

Language Conventions

LanguageMethod StyleParam BindingConnection Type
Pythonsearch_fuzzy()%spsycopg2/3 connection
JavaScriptsearchFuzzy()$1, $2pg Pool/Client
GoSearchFuzzy()$1, $2database/sql
Rubysearch_fuzzy()$1, $2PG::Connection
JavasearchFuzzy()?JDBC Connection
PHPsearchFuzzy()?PDO connection
.NETSearchFuzzy()@paramNpgsqlConnection

The naming convention follows each language’s idiom — snake_case for Python and Ruby, camelCase for JavaScript, Java, and PHP, PascalCase for Go and .NET. The shape is the same. The substance is identical. I find this a point worth emphasizing: the search does not change because the language changes. The language is the accent. The search is the conversation.

Full-Text Search in All Seven Languages

If you will permit me, I should like to demonstrate. One method call in each language, searching for “database performance” across the title and body columns of an articles table:

Python
results = goldlapel.search(conn, "articles", ["title", "body"], "database performance")
JavaScript
const results = await goldlapel.search(pool, "articles", ["title", "body"], "database performance");
Ruby
results = GoldLapel.search(conn, "articles", ["title", "body"], "database performance")
Go
results, err := goldlapel.Search(db, "articles", []string{"title", "body"}, "database performance")
Java
List<Map<String, Object>> results = GoldLapel.search(
    conn, "articles", new String[]{"title", "body"}, "database performance");
PHP
$results = GoldLapel::search($pdo, "articles", ["title", "body"], "database performance");
.NET
var results = GoldLapel.Search(conn, "articles", new[] {"title", "body"}, "database performance");

All seven generate the same SQL:

SQL
SELECT *, ts_rank(
    setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
    setweight(to_tsvector('english', coalesce(body, '')), 'B'),
    plainto_tsquery('english', $1)
) AS _score
FROM articles
WHERE ... @@ plainto_tsquery('english', $1)
ORDER BY _score DESC LIMIT 50

All seven return the same shape: rows with all columns plus _score. Seven languages. One pattern. Identical results. I trust the point is made.

Aggregations

Faceted search returns the same format across all languages — a list of category-count pairs:

Python
facets = goldlapel.facets(conn, "products", "category", "name", "chair")
# [{"category": "Furniture", "count": 127}, {"category": "Office", "count": 89}]
Ruby
facets = GoldLapel.facets(conn, "products", "category", "name", "chair")
# [{"category"=>"Furniture", "count"=>127}, {"category"=>"Office", "count"=>89}]
Java
List<Map<String, Object>> facets = GoldLapel.facets(
    conn, "products", "category", "name", "chair");
// [{category=Furniture, count=127}, {category=Office, count=89}]

Same query. Same SQL. Same results. The only difference is the data structure syntax each language uses to express a list of key-value pairs — and that is a difference the developer already understands, because it is a difference they live with in every other part of their codebase.

Return Format

Every method returns a list of rows containing:

  • All columns from the table — the full row, not just the ID
  • _score — relevance, similarity, or distance (always present)
  • _highlight — HTML snippet with <mark> tags (when highlight=True in search())

The _score semantics vary by method, and this is worth a table because one of them is inverted:

Method_score MeaningDirection
search()ts_rank (term frequency)Higher = more relevant
search_fuzzy()similarity (0.0–1.0)Higher = more similar
search_phonetic()similarity among phonetic matchesHigher = more similar
similar()cosine distanceLower = more similar
suggest()similarity to prefixHigher = more similar

Note the inversion for similar(). Cosine distance, not similarity. Lower is better. I flagged this in Chapter 8 and I flag it again here because it is the kind of inconsistency that produces bugs at 11 PM on a Thursday — and I would spare you that debugging session. Every other method: higher is better. similar(): lower is better. Remember this.

Framework-Specific Notes

A few practical observations for each framework, because the integration details matter:

Django: Works with Django’s database connection and ORM connection pooling. Django has built-in PostgreSQL FTS support since version 1.10 — it uses similar tsvector patterns. Gold Lapel adds the remaining twelve methods — fuzzy, semantic, phonetic, percolator, aggregations, custom analyzers — and the automatic indexing that Django’s native FTS does not provide.

Rails: Works with ActiveRecord’s connection. search() returns hashes, not ActiveRecord objects. The pg_search gem covers FTS well — Gold Lapel adds the remaining twelve methods and automatic index management. If you are already using pg_search, Gold Lapel complements it rather than replacing it.

Spring Boot: Works with JDBC connections and DataSource integration. No custom MetadataBuilderContributor required — Gold Lapel handles indexing outside the ORM. This simplifies what is typically one of the more complex integration points in the Spring Boot ecosystem.

Laravel: Works with PDO connections from Laravel’s database layer. Compatible with Laravel Scout for simple cases — Gold Lapel handles the cases Scout cannot: semantic search, percolator, hybrid ranking, phonetic matching.

Node.js / Prisma / Drizzle: Works alongside the ORM via the raw connection. Prisma’s lack of websearch_to_tsquery support is handled by Gold Lapel’s wrapper, which generates the SQL directly. Works with both pg Pool and Client connections.

Go: Standard database/sql interface. Returns []map[string]interface{}. I should note that the existing content for PostgreSQL search in Go is remarkably thin — this chapter and Appendix A may provide the most comprehensive coverage currently available.

.NET: NpgsqlConnection. Compatible with Entity Framework’s connection. Npgsql has pgvector support — Gold Lapel coordinates with it rather than replacing it. Similar to Go, comprehensive .NET + PostgreSQL search coverage is sparse. We have endeavored to correct that.

Honest Boundary

Gold Lapel’s wrapper methods cover the 13 most common search operations. For queries that don’t fit these methods — complex hybrid RRF pipelines (Chapter 13), custom aggregation patterns (Chapter 10), specialized percolator triggers (Chapter 11) — you write SQL directly using your language’s PostgreSQL client library. Gold Lapel is the fast path for common operations. Raw SQL is always available for everything else. The wrapper does not stand between you and the database. It stands beside you.

The wrapper returns rows as dictionaries, maps, or hashes — not ORM model objects. If your application relies heavily on ORM model instances, you may need a mapping step between Gold Lapel results and your model layer. This is the trade-off of a framework-agnostic API: it works in every language, but it does not integrate into any single ORM’s object graph. I consider this an honest trade-off, and I present it as such.

The API is consistent. The language is your choice. The search is the same — same SQL, same indexes, same results, same _score — regardless of whether you write Python, JavaScript, Ruby, Go, Java, PHP, or .NET. Seven languages, one search. I find that a satisfying property in a system, and I hope you find it a practical one.

Part V is complete. What remains is Part VI — two chapters. Chapter 19 looks beneath the API you have been using throughout this chapter — at the proxy that creates the indexes, the detection that makes them automatic, and the architecture that makes them fast. It is, if you will, a look inside the kitchen. The dishes have been served. Now, for those who are curious, I should like to show you how they are prepared.