Searching Conversation Data with Filters and Natural Language Dates
June 17, 2026

Searching through AI conversation data has always been a two-step process. You run a semantic query to find relevant results, then you filter, sort, and slice the results in your own code. Need conversations from the last 30 days tagged “urgent”? That’s a search call, a loop, a date comparison, and a tag check. Four operations where one should do. Every line of that filtering code is time spent debugging date math instead of building your product.
Server-side search filtering moves filter, sort, and date logic into the API layer so your application code doesn’t have to handle it. The DialogueDB Search API now supports this in a single request: tag operators, metadata comparisons, natural language date ranges, result ordering, and match evidence that shows which messages drove each result.
One API Call Replaces Client-Side Filtering
Here’s what finding urgent billing conversations from the last 30 days used to look like with basic search and client-side filtering:
import { DialogueDB } from "dialogue-db";
const db = new DialogueDB({ apiKey: process.env.DIALOGUEDB_API_KEY });
const results = await db.searchDialogues("billing issue");
const now = new Date();
const cutoff = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const filtered = results.results
.filter((r) => r.item.tags.includes("urgent"))
.filter((r) => new Date(r.item.created) >= cutoff)
.sort((a, b) => new Date(b.item.created).getTime() - new Date(a.item.created).getTime());
That’s 13 lines of code, most of it date arithmetic and array manipulation. Now the same query is one call:
const results = await db.searchDialogues("billing issue", {
tags: { $all: ["urgent"] },
filter: { created: "last 30 days" },
orderBy: "created",
order: "desc",
});
The API handles tag matching, date parsing, and sorting before returning results. Your application code stays focused on what to do with the data, and you eliminate an entire class of filtering bugs.
How Do Natural Language Date Filters Work?
Date filtering is where most of the friction lived. Computing ISO timestamps for “last month” or “Q1 2026” requires knowing the current date, handling month boundaries, and getting timezone math right. Developers end up writing (and debugging) date utility functions that have nothing to do with their actual product.
The search API accepts natural language phrases directly in the filter.created and filter.modified fields. The server parses the phrase, resolves it to an exact time range, and echoes the resolved range back so you can verify what it used.
const results = await db.searchMessages("onboarding confusion", {
filter: { created: "March 2025" },
});
console.log(results.request.filter);
// {
// created: {
// gte: "2025-03-01T00:00:00.000Z",
// lt: "2025-04-01T00:00:00.000Z"
// }
// }
The request object shows exactly how the server interpreted your date phrase, which is useful for debugging and for displaying the resolved range in your UI.
Here’s how the different date filter styles compare:
| Style | Example | Resolves To | Best For |
|---|---|---|---|
| Calendar reference | "last month" | Previous calendar month | Monthly reports, business period reviews |
| Rolling window | "last 30 days" | 30-day window ending now | Real-time dashboards, recent activity |
| Named period | "March 2025" | Full calendar month | Historical lookups, audits |
| ISO range object | { gte: "...", lt: "..." } | Exact boundaries you specify | Precise time slices, custom periods |
There’s an important distinction between “last month” and “last 30 days”. “Last month” means the previous calendar month (April 1 through April 30), while “last 30 days” is a rolling window ending right now. Pick the style that matches your use case: calendar references for reports tied to business periods, rolling windows for real-time dashboards.
For applications with users across time zones, the timezone parameter controls where day boundaries fall. The same query resolves to different UTC ranges depending on the user’s location:
const chicagoResults = await db.searchDialogues("support ticket", {
filter: { created: "yesterday" },
timezone: "America/Chicago",
});
// request.filter.created.gte: "2026-05-27T05:00:00.000Z"
// Yesterday in Chicago (UTC-5)
const tokyoResults = await db.searchDialogues("support ticket", {
filter: { created: "yesterday" },
timezone: "Asia/Tokyo",
});
// request.filter.created.gte: "2026-05-26T15:00:00.000Z"
// Yesterday in Tokyo (UTC+9)
You can still use ISO timestamps when you need exact boundaries. Range objects with gte, gt, lte, and lt work for precise control:
const results = await db.searchMessages("deployment failure", {
filter: {
created: {
gte: "2026-01-01T00:00:00Z",
lt: "2026-04-01T00:00:00Z",
},
},
});
Tag and Metadata Operators
Tags support three set operators that let you express precise matching logic without writing client-side .filter() chains.
$in matches objects with any of the listed tags. $all requires every listed tag to be present. $nin excludes objects that have any of the listed tags. You can combine operators in a single query:
const results = await db.searchDialogues("payment issue", {
tags: {
$all: ["billing", "urgent"],
$nin: ["spam", "test"],
},
});
This returns conversations tagged with both “billing” and “urgent” but not “spam” or “test”. Without server-side operators, you’d be filtering result arrays yourself and keeping that logic in sync across your app.
Metadata filtering works on the custom key-value data you attach to dialogues, messages, and memories. The operator set covers equality ($eq, $ne), set membership ($in, $nin), and range comparisons ($gt, $gte, $lt, $lte). Scalar values are shorthand for $eq, and arrays are shorthand for $in:
const results = await db.searchDialogues("account issue", {
metadata: {
tier: { $in: ["pro", "enterprise"] },
priority: { $gte: 5 },
status: { $ne: "archived" },
},
});
All metadata fields are combined with AND. This query finds conversations about account issues where the customer is on a Pro or Enterprise tier, the priority is 5 or higher, and the conversation is not archived.
Results Show Why Each Conversation Matched
By default, results are ordered by relevance. The orderBy parameter lets you reorder results by created or modified timestamps instead:
const results = await db.searchDialogues("feature request", {
filter: { created: "last 90 days" },
orderBy: "modified",
order: "desc",
limit: 10,
});
One important detail: ordering by date doesn’t turn search into a chronological scan. The API still finds semantically relevant results first, then reorders them by date. Combine orderBy with a date filter when the time window matters as much as relevance.
For dialogue searches, the response includes match evidence showing which messages drove the relevance score. Each dialogue result has an optional matches array containing the specific messages that matched your query:
const results = await db.searchDialogues("refund policy");
for (const result of results.results) {
console.log(`Dialogue: ${result.item.id} (${result.relevance})`);
if (result.matches) {
for (const match of result.matches) {
console.log(` Message: ${match.item.content} (${match.relevance})`);
}
}
}
This lets you build search UIs where users see the exact messages that matched, not just a conversation title. If you’re storing AI chat history and surfacing it in your product, match evidence gives your users confidence that search results are relevant.
The structured response also includes a request echo that reflects how the server interpreted your query parameters. When you use natural language dates, the echo shows the resolved UTC ranges. When you omit optional fields, it shows the defaults that were applied:
const results = await db.searchDialogues("billing", {
filter: { created: "last month" },
timezone: "America/New_York",
});
console.log(results.request);
// {
// orderBy: "relevance",
// order: "desc",
// candidateOrderBy: "relevance",
// filter: {
// created: {
// gte: "2026-04-01T04:00:00.000Z",
// lt: "2026-05-01T04:00:00.000Z"
// }
// }
// }
How to Combine Filters for Complex Queries
These features work best in combination. Here’s a realistic example for a customer success tool that surfaces renewal risks across enterprise accounts:
import { DialogueDB } from "dialogue-db";
const db = new DialogueDB({ apiKey: process.env.DIALOGUEDB_API_KEY });
async function findRenewalRisks(tenantId: string) {
const results = await db.searchDialogues(
"renewal risk, pricing concern, competitor mention, missing feature",
{
namespace: tenantId,
tags: { $in: ["support", "sales", "success"] },
metadata: {
tier: "enterprise",
stage: { $in: ["renewal", "expansion"] },
},
filter: { modified: "last 6 months" },
orderBy: "modified",
order: "desc",
limit: 20,
timezone: "America/New_York",
},
);
return results.results.map((r) => ({
dialogueId: r.item.id,
relevance: r.relevance,
lastModified: r.item.modified,
evidence: r.matches?.map((m) => m.item.content) ?? [],
}));
}
This function finds the 20 most recently active conversations about renewal risks for a specific enterprise tenant, scoped to support, sales, and success channels, with the messages that triggered each match. That’s one API call with no post-processing, replacing what would otherwise be dozens of lines of filtering, sorting, and date math spread across your application.
For the full parameter reference, see the Search API documentation. To understand how DialogueDB fits into the persistence layer, Conversation Persistence in TypeScript AI Agent Frameworks covers the architectural tradeoffs.
Ready to Build Better Conversations?
Get started with DialogueDB in minutes. Free tier included.
Get Your API Key