Back to blog
Customer Support Agent Memory: Users, Tickets and History

This blog is written by AI for SEO

Customer Support Agent Memory: Users, Tickets and History

HelixDB10 min read

Customer support agent memory is a join problem, not a vector index: the agent has to connect who the customer is, their plan, their past tickets, what fixed similar issues, and which docs answered them. This guide builds that as a graph in HelixDB, with embeddings on ticket and message nodes, similarity scoped to the same account, and BM25 for error codes, all in one engine.

You should already know embeddings and RAG. You need Node.js 20+, Docker or Podman for the local server, and a ticket system you are not replacing. By the end you will have a schema, indexes, an atomic write path, and a retrieval query that returns account-scoped history for a model prompt.

A plain vector index fails here for a simple reason. "Connection timeout on webhook" from a different customer on a different plan is noise. The same phrase from this account last month, resolved by a specific doc, is gold. That difference is a relationship, and a graph stores relationships.

Step 1: Decide what the agent must recall (and what stays in your ticket system)

The agent should recall cross-ticket context. Your helpdesk stays the source of truth for live state. Ticket status, assignment and the current plan belong in the system of record. Memory holds recurring issues, environment details, prior fixes and outcomes. Treat it as context, and confirm volatile facts like plan status or open-ticket state against the live system before the agent acts on them.

Write the recall list down before you model anything. For a support agent it is usually: who this user is and which account they belong to, what plan the account is on, the last N tickets from that account, which resolutions worked on similar problems, and which doc chunks were linked in those resolutions. Everything else (SLA timers, macros, assignment rules) stays in the ticket system.

Two rules keep this honest. First, resolve identity to a stable customer ID before any lookup, and scope every query to that ID or its account. Second, store timestamps on everything so the model can tell a current fact from a historical observation.

Step 2: Model users, accounts, tickets, messages, resolutions and doc chunks as a graph

Model each noun as a node and each relationship you will traverse as an edge. HelixDB is a graph database with native vector and full-text search, and everything in it is one labeled property graph, so the same schema holds your entities and your embeddings.

Nodes: Account, User, Ticket, Message, Resolution, DocChunk.

Edges:

  • User -[MEMBER_OF]-> Account

  • User -[OPENED]-> Ticket

  • Account -[HAS_TICKET]-> Ticket

  • Ticket -[HAS_MESSAGE]-> Message

  • Ticket -[RESOLVED_BY]-> Resolution

  • Resolution -[CITED]-> DocChunk

The HAS_TICKET edge from Account matters most. It lets you scope similarity to one customer's history later without scanning the whole index. Keep properties small and queryable: plan and region on Account, subject, status, created_at on Ticket, body, author_role, sent_at on Message, summary and fix_steps on Resolution.

If you want a worked example of the same pattern in another domain, the finance ops ontology guide models invoices, contracts and vendors the same way.

Step 3: Store embeddings and timestamps as properties and create the vector, full-text and time indexes

Embeddings and timestamps are plain properties on the nodes, and each gets an index. In HelixDB a vector is a property on a node or edge, so there is no separate vector store to sync. Vector and text searches need their indexes to exist before you query.

Start a local instance:

helix init local
helix start dev

This needs Docker or Podman and serves on port 6969. Create these indexes against your schema:

  • A vector index on Ticket.embedding and Message.embedding. Values normalise to float32, with cosine, euclidean or manhattan distance. The vector property has to be top-level. Use cosine for most text embedding models.

  • A BM25 text index on Ticket.subject, Message.body and Resolution.summary. This catches error codes like ERR_WEBHOOK_413, which embeddings blur.

  • A time-range index on Ticket.created_at.

On time: HelixDB does not model time for you. There is no built-in temporal or point-in-time layer, so you store created_at and resolved_at yourself and query ranges over them. The engine gives you efficient time-range indexing, which is what a recency window needs. Pick one timestamp format (epoch milliseconds works well) and use it everywhere.

Check the vector index section of the docs for the exact index-creation call for your SDK version, since indexes are declared separately from the writes below.

Step 4: Write tickets, messages and resolutions in one atomic batch

Write the ticket, its messages, the resolution and all connecting edges in one writeBatch(), so they commit or roll back together. A half-written ticket with an embedding but no edge to its account is a retrieval bug waiting to happen. HelixDB supports ACID transactions across graph, vector and text data, and that is the property you want here.

The TypeScript SDK (@helix-db/helix-db) uses typed batches. Entries inside a write batch can refer to earlier named entries, so you can add a node and then connect it in the same batch:

import { Client, NodeRef, g, writeBatch } from "@helix-db/helix-db";

const client = Client.server("http://localhost:6969");

const ticketEmbedding = await embed("Webhook returns 413 on large payloads");
const msgEmbedding = await embed("Our webhook fails with ERR_WEBHOOK_413 when payload is over 1MB");

const write = writeBatch()
  .varAs("ticket", g().addN("Ticket", {
    subject: "Webhook returns 413 on large payloads",
    status: "resolved",
    created_at: 1767225600000,
    embedding: ticketEmbedding,
  }))
  .varAs("msg", g().addN("Message", {
    body: "Our webhook fails with ERR_WEBHOOK_413 when payload is over 1MB",
    author_role: "customer",
    sent_at: 1767225600000,
    embedding: msgEmbedding,
  }))
  .varAs("fix", g().addN("Resolution", {
    summary: "Raise payload limit in webhook settings to 5MB",
    resolved_at: 1767229200000,
  }))
  .returning(["ticket", "msg", "fix"]);

await client.query(write.toQueryRequest()).send();

Then add the HAS_MESSAGE, RESOLVED_BY, HAS_TICKET and CITED edges with addE against node references in the same batch (use NodeRef for the account, which already exists). The exact edge-builder calls are in the SDK types. Verify them against the installed version rather than copying from memory.

Keep embedding generation outside the batch. Compute vectors first, then write. If the embedding call fails, nothing is written.

Step 5: Retrieve similar past tickets scoped to the same account, with BM25 for error codes

Narrow the candidate set with a graph traversal first, then run vector search over only those tickets. This is vector pre-filtering: similarity runs over a graph-filtered candidate set instead of the whole index, so a ticket from another tenant can never outrank this account's history because it is never a candidate.

The pattern in the SDK is to traverse from the account to its tickets, then call vectorSearchWith on that narrowed set. textSearchWith is the analogous filtered text search. Run both and merge:

import { Client, g, readBatch } from "@helix-db/helix-db";

const client = Client.server("http://localhost:6969");
const queryVec = await embed(incomingTicketText);

const read = readBatch()
  // semantic: similar tickets, same account only
  .varAs("similar",
    g().n(accountId)
      .out("HAS_TICKET")
      .vectorSearchWith("embedding", queryVec, 5)
      .valueMap(["$id", "subject", "created_at", "$distance"]))
  // keyword: exact error code, same account only
  .varAs("byCode",
    g().n(accountId)
      .out("HAS_TICKET")
      .textSearchWith("subject", "ERR_WEBHOOK_413", 5)
      .valueMap(["$id", "subject", "$score"]))
  .returning(["similar", "byCode"]);

const res = await client.query(read.toQueryRequest()).send();

Check the method names and argument order for vectorSearchWith and textSearchWith against the published 3.2.0 types before shipping. The shape above is the intent, and the types are the authority.

Why run both? Embeddings are good at "my integration stopped sending events" and bad at ERR_WEBHOOK_413. BM25 (the standard keyword-ranking function) is the reverse. Reciprocal-rank-fusion or a simple weighted merge of the two lists works fine. Then traverse from each hit via RESOLVED_BY and CITED to pull the fix and the doc chunks that answered it.

Two documented limits matter at scale. An unrestricted vector search caps at 800 effective results. A traversal-scoped search applies that ceiling after candidate intersection and rejects rather than silently clamping. A candidate stream over 1,000,000 unique entities is a query error. A single account's tickets sit far below both.

Step 6: Add recency with time-range queries and assemble the context for the model

Filter by a time window on created_at so stale tickets drop out, then build a compact, timestamped context block. Because you store timestamps yourself, recency is a range predicate over an indexed property, not a built-in feature. Apply it in the same traversal that scopes to the account:

import { Client, Predicate, g, readBatch } from "@helix-db/helix-db";

const client = Client.server("http://localhost:6969");
const ninetyDaysAgo = Date.now() - 90 * 24 * 60 * 60 * 1000;

const recent = readBatch()
  .varAs("recent",
    g().n(accountId)
      .out("HAS_TICKET")
      .where(Predicate.gte("created_at", ninetyDaysAgo))
      .valueMap(["$id", "subject", "status", "created_at"]))
  .returning(["recent"]);

await client.query(recent.toQueryRequest()).send();

Use the recent set for the "what has this customer been dealing with lately" part of the prompt, and the similarity set (without a window, or with a wide one) for "what fixed this before". They answer different questions.

Assemble context in a fixed order the model can rely on:

  1. Account facts: plan, region, fetched live from your system of record, marked as current.

  2. Recent tickets: subject, status, date.

  3. Similar resolved tickets: summary, fix steps, date, and the doc chunks cited.

  4. The doc chunks themselves, de-duplicated.

Label every item with its date and source. Keep it short. Five similar tickets and a handful of doc chunks beat fifty. After the reply goes out, write the exchange and outcome back with the batch from Step 4 so the next ticket sees it.

Step 7: Handle deletion, tenant isolation and stale facts

Deletion, isolation and staleness are three separate problems, and each needs its own answer.

Deletion. When a user asks to be forgotten, remove their User node, their tickets and messages, and any resolutions or embeddings derived only from them. Because the data is a graph, you can traverse from the user to everything they own and delete it in one batch. The full pattern, including what to do with shared resolutions, is in How to Delete an AI Agent's Memory of a User.

Tenant isolation. Scope every lookup to a customer or account ID, as in Step 5. HelixDB also supports tenant-partitioned vector indexes, which keep tenant data isolated on shared infrastructure. That is partitioning, not per-user permissions or row-level access control, so enforce who can call the agent for which account in your own application layer.

Stale facts. A plan or status copied into a node goes out of date. Either skip volatile fields in memory and fetch them live, or store them with a timestamp and let the model see the age. Mark resolutions that stopped working (a doc was rewritten, a bug was fixed upstream) with a status property and filter them out of retrieval rather than deleting the history.

What to do next and troubleshooting

Run it on a real week of tickets before you tune anything. Backfill from your helpdesk export through the Step 4 batch, then replay ten tickets you already know the answer to and check whether the right resolution appears in the top five.

If similar tickets look wrong, check whether you scoped by account. Unscoped search will surface other customers' tickets that are semantically close but irrelevant. If error codes are not found, confirm the BM25 index exists on the property you are searching. If vector or text search errors out, the corresponding index is missing. If writes partly appear, you are issuing separate calls instead of one writeBatch(). If a scoped search is rejected, you have hit the 800-result ceiling and should lower the limit.

If you are also deciding where this fits in a wider agent design, LLM Memory Architecture: How Agents Remember Across Sessions covers the layers around it.

Start with one account's history in a local HelixDB

Support memory works when the account is the unit of retrieval, and a graph makes that a traversal instead of a sync job across three databases. Run helix init local and helix start dev, load one account's last 90 days of tickets, and run the Step 5 query. If the right fix shows up in the top five, extend it to every account. HelixDB is open source at github.com/HelixDB/helix-db. Star it if it saves you a database.

Build with HelixDB

Give your coding agent the setup prompt, or sign up and deploy a database.

Sign up