“What happened to the Maryland power plant yesterday?” is a question traditional hybrid search handles poorly without filters on time and metadata. Yet the industry is still competing on who can store the most vectors at the lowest price, not on search capabilities that yield the best answers. Storing vectors is the commodity part. Retrieving the right evidence is the hard part.
While working on agentic memory and RAG backends, we realized that PostgreSQL gave us all the primitives needed to compose full-text and semantic search with structured metadata, hierarchy, and time filters in a single query, using a single database. The prevailing “wisdom” of commercial and academic memory solutions favored complex pipelines and systems. We had a simpler hypothesis: given the right search capabilities and access to the raw information, an agent could find the relevant context itself. We applied this approach across multiple systems, and it proved extremely effective in practice.
We wanted to measure how well it worked. On the LoCoMo agent-memory benchmark, our agent achieved an F1 score of 0.665, surpassing the previously reported best of 0.613. It searched raw conversational turns in a single Postgres table, with no fact extraction, knowledge graph, or memory manager. Our experiments kept pointing to the search interface—not additional pipeline machinery—as the place to invest. Full results and limitations are in Agent Memory Is a Search Problem, Not a Storage Problem. RAG Complexity Is a Bet Against the Model found a similar pattern on other benchmarks.
As more internal applications adopted the same machinery, we decided to extract it into a small TypeScript library. We’re open-sourcing it today as searchgres.js.
searchgres.js features:
Ranking results by BM25, Semantic, and Hybrid search
Composable filters for: hierarchy, metadata, regex, and time
Robust automatic management of asynchronous embedding generation
A TypeScript API for web applications, RAG backends, and agent tools, plus an optional reference MCP server.
The library is available as searchgres on npm, under the Apache-2.0 license, with support for Node, Bun, and Deno.
Was this reduction planned?
Imagine an operations dashboard for a fictional power plant, Harbor Station. On June 15, its TimescaleDB-backed chart shows Unit 2’s output falling from 250 MW to 180 MW. An operator selects 14:00 to 14:30 UTC and asks:
Was this reduction planned?
The measurements show when the output dropped, but not why. A dispatch instruction, shift log, or equipment report could explain what happened. The search needs to find records for the selected plant, unit, and time range, while excluding reports about similar events elsewhere or on a different day.
In searchgres.js, an index is a dedicated PostgreSQL schema that contains searchable records and their supporting database indexes and routines. A record is one row of searchable text with a tree path and optional metadata and time information. Your application writes content into this schema and keeps it up to date, while searchgres.js maintains the embeddings and handles search through a TypeScript API.
Inside the dashboard’s search form request handler, the search is one call:
// index is the handle returned by openIndex() at application startup.
// The search box supplies the question; the chart supplies the scope.
const hits = await index.search({
semantic: "Was this reduction planned?",
fulltext: "Was this reduction planned?",
filter: {
and: [
{ tree: "plants.harbor" },
{ meta: { unit: "U2" } },
{ temporalOverlaps: ["2026-06-15T14:00:00Z", "2026-06-15T14:30:00Z"] },
],
},
limit: 5,
});
Two records in this fictional corpus supply the evidence:
Dispatch instruction D-4821
Scheduled derate: Unit 2 output limit set to 180 MW under dispatch instruction D-4821.
Shift log
Unit 2 is holding at the derated limit of 180 MW; no equipment alarms during the reduction.
A derate is a reduction in a unit’s maximum permitted output. Together, these records support a planned reduction: dispatch sets the lower limit, and the shift log confirms the unit was following it. Each hit includes the record’s content, ID, metadata, tree, temporal value, and score, so the dashboard can show the evidence alongside the chart without fetching another set of records.
The query combines two different jobs:
Ranking: semantic search connects “planned reduction” with “scheduled derate,” even though the words differ. BM25 keyword search, supplied through fulltext, contributes lexical matches such as “reduction” in the shift log.
Scope: the tree filter selects plants.harbor and its descendants, metadata selects Unit 2, and the temporal filter selects records whose event time or effective period overlaps the chart selection.
You can use either ranking method, combine both, or omit ranking to list all records matching the filters. Hierarchy, metadata, temporal, and regex filters can be combined with and, or, and not. The search guide covers the combinations and controls.
Scope belongs inside retrieval
When you supply both semantic and fulltext, searchgres.js runs semantic and BM25 keyword search over the same records. Each produces its own ranked shortlist of matches, called candidates. The library combines those lists using Reciprocal Rank Fusion (RRF), which scores records based on their positions in each list rather than directly comparing keyword and vector scores. A record can appear in either list or both.
A key to good results is a property called pre-filtering, which ensures that both candidate sets only include items that match the filters.
Suppose you instead ask each search for its 20 best matches across the whole corpus, then filter those lists to Harbor Unit 2 in the selected period. All 20 candidates in each list could describe other units or dates, leaving both lists empty. The report you need might have ranked 21st, but it was never returned.
We can return pre-filtered candidate sets only because the database supports it. Postgres will natively continue searching the BM25 and vector indexes until it finds a LIMIT number of matching records, even if the top items in the indexes don’t match.
Keeping embeddings consistent as records change
An operator should be able to save a shift report without waiting for an embedding provider. For a record written without a vector, searchgres.js stores the record and queues embedding work in the same transaction. Keyword search and filters can use the text immediately; semantic retrieval becomes available when its vector is ready.
That sounds straightforward, but there are plenty of ways to get it subtly wrong. Suppose a background job begins embedding a shift report, then a user corrects the text while the provider request is still running. Attaching the first vector to the corrected text would make the index search for one account of events and return another.
Then there are the queue-processing problems. What happens when a worker crashes halfway through a batch? How do failed requests get retried? How do multiple workers share the work without holding database locks while waiting for the provider?
We’ve written these types of systems multiple times and every time we find new and, clears throat, interesting edge cases. That’s why it belongs in a library and not hand-coded or vibe-coded.
You supply the model, run the workers, and monitor the queue. searchgres.js handles all the headaches associated with keeping the embeddings up to date.
Process queued work on demand with index.processEmbeddings(), or run index.startEmbeddingWorker() in a long-lived process. The embedding guide covers error reporting, retries, and graceful shutdown.
Searching with an agent
In the dashboard, the form selection supplied the search scope. An AI agent can choose that scope through a tool your application exposes. Suppose a plant engineer asks:
Why did Harbor Unit 2 drop to 180 MW yesterday afternoon?
The agent might search Harbor’s Unit 2 records for that afternoon, find a reference to D-4821, then search the dispatch collection for that identifier. On the second search, it can relax the time constraint because the instruction may have been issued earlier. It can then cite the dispatch instruction alongside the shift log in its answer.
An agent differs from a human in one useful way: it can iterate on search parameters quickly and cheaply. After each search, it adjusts the parameters based on the last one's results. A human could do the same in theory, but let’s be real, in practice, it's too much work.
An agent can only search well if it knows the shape of the search space: how the hierarchy is laid out, what the timestamps mean, and what metadata exists. There are two ways to give it that. You can run our reference MCP server with searchgres mcp, hand the agent one general search tool, and pair it with a good prompt. Or you can expose task-specific tools through your agent framework and have them call the searchgres.js library directly.
You supply the embedding model and a postgres.js connection pool, then start embedding workers. searchgres.js handles the search infrastructure so you can experiment with the parts that make your application useful: how to chunk and organize content, what an agent should remember, how it searches, and how it uses the results. You can change prompts, try summarization strategies, or give a model more control over retrieval without rebuilding the embedding queue.
If your application needs access controls, it can express record-access restrictions through the same composable filters, so each ranking method searches within that scope.
The search schema can live beside your application tables or in a separate database.
Install the library, database driver, and embedding provider:
npm install searchgres postgres @ai-sdk/openai
Set DATABASE_URL and OPENAI_API_KEY. Save this script as plant-search.mts and run it with node plant-search.mts on Node 22.18 or later. It creates a fresh plant_ops_demo schema, writes five fictional records, generates their embeddings, and searches them.
import postgres from "postgres";
import { openai } from "@ai-sdk/openai";
import { createIndex, openIndex } from "searchgres";
const sql = postgres(process.env.DATABASE_URL!);
try {
// Create once; subsequent application starts only need openIndex().
await createIndex(sql, "plant_ops_demo", { dimensions: 1536 });
const index = await openIndex(sql, "plant_ops_demo", {
embedding: openai.embedding("text-embedding-3-small"),
});
await index.insertMany([
{
content:
"Scheduled derate: Unit 2 output limit set to 180 MW " +
"under dispatch instruction D-4821.",
tree: "plants.harbor.dispatch",
meta: { unit: "U2" },
temporal: ["2026-06-15T14:00:00Z", "2026-06-15T14:30:00Z"],
},
{
content:
"Unit 2 holding at the derated limit of 180 MW; " +
"no equipment alarms during the reduction.",
tree: "plants.harbor.shifts",
meta: { unit: "U2" },
temporal: ["2026-06-15T14:05:00Z"],
},
{
content:
"Routine water chemistry sample collected; readings within normal limits.",
tree: "plants.harbor.chemistry",
meta: { unit: "U2" },
temporal: ["2026-06-15T14:10:00Z"],
},
{
content: "Unplanned output reduction following a cooling-system fault.",
tree: "plants.harbor.shifts",
meta: { unit: "U3" },
temporal: ["2026-06-15T14:05:00Z"],
},
{
content: "Output reduction caused by a fuel-pressure alarm.",
tree: "plants.harbor.shifts",
meta: { unit: "U2" },
temporal: ["2026-06-14T14:05:00Z"],
},
]);
const { failed, remaining } = await index.processEmbeddings();
if (failed > 0 || remaining > 0) {
throw new Error(
"Embedding work is incomplete; inspect the embedding queue."
);
}
const question = "Was this reduction planned?";
const hits = await index.search({
semantic: question,
fulltext: question,
filter: {
and: [
{ tree: "plants.harbor" },
{ meta: { unit: "U2" } },
{ temporalOverlaps: ["2026-06-15T14:00:00Z", "2026-06-15T14:30:00Z"] },
],
},
limit: 5,
});
for (const hit of hits) console.log(hit.score, hit.tree, hit.content);
} finally {
await sql.end(); // Your application owns the connection lifecycle.
}
In our test, the script returned these records, shown here with scores omitted and content abbreviated:
plants.harbor.shifts Unit 2 holding at the derated limit of 180 MW...
plants.harbor.dispatch Scheduled derate: Unit 2 output limit set to 180 MW...
plants.harbor.chemistry Routine water chemistry sample collected...
The derate has an effective period; the shift log has a single timestamp. temporalOverlaps matches both against the selected window. The Unit 3 fault and the previous day’s alarm are excluded, even though they contain matching keywords. The water-chemistry record is eligible too: scope determines what can match, while ranking orders those matches. Scores and ordering can vary with embeddings; the unit and time constraints do not.
To adapt it, replace the sample records with your own text, choose paths and metadata that reflect your application, and build filters from the context your application already knows. Keep the same search API whether the next query comes from a person or an agent.
Announcing pg_vectorsearch: Scalable Open-Source Vector Search in PostgreSQL
pg_vectorsearch is a new open-source Postgres extension. Its PRISM index serves 100M vectors in 6 ms on 4x less RAM than HNSW, with builds in under 2 hours.