A support chatbot can look impressive in a demo and still fail the first week it handles real tickets. The early version usually sends the latest user message to a vector database, retrieves a few similar document chunks, and asks a large language model (LLM) to write the answer. That works for simple FAQs, but technical support conversations are rarely that clean.
A user might say, "It still crashes with the same duplicate key error," after three previous turns about a driver, a database version, and a failed write. Another user might ask about a slow sorted query where the answer is buried several sections below a matching document title. A third user might ask about billing or account access, which the support bot should not answer at all.
The goal is not to build a bot that answers every message. The goal is to build a support system that answers only when it has enough grounded context, keeps the knowledge base fresh, protects private data, and escalates unresolved cases to a human with a useful summary.
This article walks through a practical GraphRAG architecture for that job. GraphRAG means retrieval-augmented generation combined with a knowledge graph. Standard retrieval-augmented generation (RAG) retrieves relevant text and adds it to the model prompt. GraphRAG adds explicit relationships between documents, tickets, errors, components, root causes, and resolution steps so the system can follow structured paths instead of relying only on similarity.
The Problem
Imagine a developer support product for a database platform. The system needs to answer questions from web chat, Slack, or a support ticket form. It can use public documentation, resolved tickets, community issues, forum posts, and internal support notes depending on the user's access level.
A realistic production target might include:
- Around 3000 peak support tickets per day.
- Around 200 peak concurrent chat sessions.
- A first bot response within a few seconds.
- Follow-up resolution or escalation within about a minute.
- A knowledge base with thousands of documentation pages, tens of thousands of resolved tickets, and many more community posts.
- Roughly 1.6 million vector chunks after chunking source items into smaller semantic pieces.
The hard part is not just traffic. The hard part is correctness under ambiguity.
A safe support agent has to do five things well:
- Preserve chat context across turns.
- Retrieve the right evidence, not merely similar text.
- Generate an answer only from verified context.
- Escalate low-confidence or out-of-scope cases.
- Keep its retrieval data fresh without slowing down live chat.
Naive RAG often fails at the second point.
Why Vector Search Alone Breaks
Vector search finds chunks that are close in meaning to the query. That is useful, but similarity is not the same as support relevance.
Consider these failure modes.
Similar chunks crowd out the useful one
A user asks about a duplicate key error. The vector search returns many chunks that mention inserts, indexes, and write failures. Several are related, but only one explains the specific cause and resolution. The LLM receives too much redundant context and may summarize the wrong part.
Hierarchical documentation hides the answer
Documentation is often arranged as title, section, subsection, and examples. A query may match the title of an article, while the needed fix is several subsections lower. If the chunking strategy does not preserve parent and child relationships, retrieval can stop at the summary and miss the actual procedure.
The latest message is not enough
In a multi-turn chat, the current message can be meaningless without history.
Turn 1, user: My application cannot connect.
Turn 2, bot: Which driver and error do you see?
Turn 3, user: The duplicate key one again.
If the system embeds only "The duplicate key one again," it loses the driver, connection context, and previous debugging path. Before retrieval, the system must rewrite the conversation into a context-aware internal search query.
Core Architecture
A production support agent needs two connected systems: the online query path and the offline ingestion path.
The online path handles user messages. It owns chat state, intent routing, hybrid retrieval, answer generation, and escalation.
The ingestion path keeps the knowledge base current. It accepts documentation updates, closed ticket events, and issue updates, then chunks, redacts, embeds, and stores the knowledge in both a vector database and a graph database.
Online query path
User
|
v
Chat Service and Session Cache
|
v
RAG Orchestrator
|\
| \-- FAQ Cache
| \-- Vector Database, semantic search
| \-- Knowledge Graph, relationship traversal
| \-- Blob Storage, raw chunks
|
v
GenAI Service with fallbacks
|
v
Answer or Human Escalation
Ingestion path
Documentation, Tickets, Issues
|
v
raw_documents topic
|
v
Spark Chunking and PII Redaction
|\
| \-- Blob Storage for raw chunks
| \-- chunked_documents topic
|
v
Entity Extraction and Embedding Processor
|\
| \-- Vector Database
| \-- Knowledge Graph
Use the vector database for fast semantic lookup. Use the knowledge graph for structured relationships. Use blob storage, such as S3, as the durable source of raw chunk text. Do not treat the vector database as a document store.
Step 1: Keep Session State Out of the Request Body
The chat API should not receive the full conversation every time. That bloats payloads, increases latency, and makes clients responsible for state they should not own.
Instead, return a session identifier when the conversation starts. Store conversation history in a low-latency cache. On every new message, the chat service loads the history, appends the message, and passes the updated conversation to the RAG orchestrator.
class ChatService:
def receive_message(self, session_id, user_message, user_context):
history = self.session_cache.load(session_id)
history.add_user_message(user_message)
self.session_cache.save(session_id, history)
self.send_working_reply(session_id)
result = self.rag_orchestrator.answer(
history=history,
user_context=user_context,
)
history.add_agent_message(result.message)
self.session_cache.save(session_id, history)
return result
This design gives the retrieval layer the full context without forcing every client to resend it.
Step 2: Route Cheap Cases Before Running GraphRAG
Not every user message needs vector search, graph traversal, and a large LLM call. Start with a tiered decision flow.
- Check an FAQ cache for exact or known simple requests.
- If there is no cache hit, use a smaller model to detect intent and extract entities.
- Rewrite the conversation into a focused internal search query.
- Run hybrid retrieval only when the request needs knowledge lookup.
- Escalate immediately when the intent is out of scope, such as billing, legal, or account-specific data.
class RagOrchestrator:
def answer(self, history, user_context):
faq_match = self.faq_cache.find(history.latest_message_text())
if faq_match is not None:
return SupportResult.from_cache(faq_match)
intent = self.intent_model.extract(history)
if intent.requires_human:
summary = self.summarizer.create_handoff_summary(history)
return SupportResult.escalate(summary)
search_plan = self.query_rewriter.rewrite(history, intent)
evidence = self.hybrid_retriever.retrieve(search_plan, user_context)
if evidence.is_too_weak():
summary = self.summarizer.create_handoff_summary(history)
return SupportResult.escalate(summary)
return self.answer_generator.generate(history, evidence)
The important design choice is that intent detection happens before expensive retrieval. A smaller, faster model can handle intent and entity extraction, while the larger model is reserved for final synthesis.
Step 3: Retrieve With Both Vectors and a Knowledge Graph
Hybrid retrieval starts with semantic search because it is fast and broad. It then narrows the result using graph relationships.
A vector search result might say, "these chunks are semantically close." The graph can say, "this chunk is part of a troubleshooting article, references a driver compatibility page, and resolves this specific error entity."
Useful relationship types include:
| Relationship | Example meaning | Why it helps retrieval |
|---|---|---|
| IS_PART_OF | A subsection belongs to a parent article | Retrieves the parent context when a small section matches |
| REFERENCES | One article links to another | Expands to closely related documents |
| HAS_ROOT_CAUSE | A ticket points to the cause of an error | Connects user symptoms to known fixes |
| USES_COMPONENT | A ticket involves a driver, service, or feature | Filters answers to the user's version or component |
| RESOLVES | A resolution chunk fixes a problem entity | Finds canonical resolution steps |
A simple retrieval plan looks like this:
1. Embed the rewritten support query.
2. Search the vector database for top matching chunk IDs.
3. Group and score results by parent document or resolved ticket.
4. Traverse the graph from the top IDs.
5. Follow relationships such as RESOLVES and HAS_ROOT_CAUSE.
6. Fetch the raw chunk text from blob storage.
7. Send only the strongest evidence to the answer generator.
The graph traversal should not scan the entire graph. First use OpenSearch or a similar vector database to find candidate IDs. Then use Neptune, Neo4j, or another graph database to traverse only those candidates. That keeps retrieval latency bounded.
Step 4: Generate Only From Verified Context
The answer generator should receive strict instructions. The LLM is not allowed to invent missing steps. It must answer from retrieved chunks and graph relationships, or it must escalate.
Support answer rules
- Use only the retrieved chunks and graph relationships.
- Prefer resolution chunks connected by RESOLVES or HAS_ROOT_CAUSE.
- Mention the product component or version only when it appears in the evidence.
- If the evidence is incomplete, say that verified information was not found.
- Do not answer billing, account access, legal, or private user data questions.
- Include the internal chunk IDs used to support the answer.
- If no chunk ID supports the answer, return an escalation response.
This also gives the UI a safety gate. If the model response does not cite an internal chunk ID, the UI can refuse to show it as a verified answer. The user can instead see a handoff message.
The escalation payload should include:
- The latest user question.
- A concise summary of the conversation.
- Detected intent and entities.
- Retrieved chunk IDs, if any.
- Why the system escalated, such as low confidence, no verified context, private data request, or unsupported category.
That makes the handoff useful instead of forcing the human agent to reread the full transcript.
Step 5: Build the Ingestion Pipeline for Freshness
A support agent becomes stale quickly if it misses new documentation, patched known issues, or recently resolved tickets. The ingestion pipeline should be event-driven, but it also needs a scheduled cleanup path.
A practical flow is:
1. Documentation, ticket, or issue update happens.
2. Webhook publishes metadata into the raw_documents topic.
3. Spark consumes pending items when topic lag crosses a threshold.
4. A daily scheduled job processes any remaining backlog.
5. Spark fetches source content and splits it into semantic chunks.
6. PII redaction runs before storage or embedding.
7. Raw chunks are written to blob storage.
8. Chunk metadata is published to the chunked_documents topic.
9. Entity extraction finds components, errors, methods, versions, and relations.
10. Embeddings are written to the vector database.
11. Nodes and edges are written to the knowledge graph.
Chunking should respect structure. A good chunk can be a documentation section, a paragraph group, a ticket component, or a resolution block. Chunk size around a few hundred tokens is a reasonable balance: large enough to preserve meaning, small enough to avoid overwhelming the LLM context window.
Every chunk should carry metadata, such as:
- Chunk ID.
- Parent document or ticket ID.
- Source type, such as documentation, ticket, or issue.
- Last updated timestamp.
- Security level.
- Blob storage path.
- Parent title and section path.
- Extracted entities and relationships.
Redact personally identifiable information before embedding. Once sensitive data is inside vectors, selective deletion is difficult because you may need to reindex affected chunks. Redaction belongs before vectorization, not after.
Step 6: Reduce Latency Without Removing Safety
The user path has sequential work: session lookup, intent extraction, vector search, graph traversal, chunk fetch, and final generation. To meet a tight support latency target, optimize each stage.
Use these patterns:
- FAQ cache: Return known answers without RAG.
- Prompt cache: Cache static prompt templates by prompt ID.
- Response cache: Cache final answers by contextualized query and model ID when safe.
- Request coalescing: Collapse identical in-flight requests during incidents.
- Small models for preprocessing: Use small, fine-tuned models for intent detection and entity extraction.
- Vector first, graph second: Use vector search to narrow candidates before graph traversal.
- Connection pooling: Reuse connections to external model providers and storage services.
- Regional co-location: Keep orchestrator, vector database, graph database, storage, and model endpoints in the same region when possible.
Request coalescing is especially useful during outages. If thousands of users ask the same support question at once, the first request performs the expensive LLM call while the others wait for that result.
class CoalescingGateway:
def get_or_create_answer(self, request_key, create_answer):
existing = self.in_flight.get(request_key)
if existing is not None:
return existing.wait_for_result()
promise = self.in_flight.start(request_key)
try:
answer = create_answer()
promise.resolve(answer)
return answer
except Exception as error:
promise.reject(error)
raise
finally:
self.in_flight.remove(request_key)
The GenAI service should also protect the rest of the system from provider problems. If a provider is slow or failing, open a circuit breaker and route to a backup model. For chat, streaming the first tokens improves perceived responsiveness. If time to first token crosses the allowed threshold, fail over instead of letting the API gateway time out.
Step 7: Test Grounding Before Production
A support agent cannot be tested with exact string equality. Two good answers can be worded differently. Instead, evaluate behavior against a golden dataset and use both automated and human review.
Build a golden dataset with cases such as:
| Case type | Example query | Expected behavior |
|---|---|---|
| Simple FAQ | How do I connect with the driver? | Return a concise setup answer from official documentation |
| Complex retrieval | My sorted read query is slow even with an index | Retrieve related index, sort, and query planning context |
| Error traversal | I see a duplicate key error in the logs | Traverse from error entity to root cause and resolution |
| Out of scope | I was charged twice | Escalate to a human because account data is private |
Then evaluate each response for three properties:
- Accuracy: Is the answer technically correct?
- Groundedness: Is every important claim supported by retrieved chunks?
- Helpfulness and tone: Does the answer respond to the user's problem and emotional state?
def evaluate_response(case, response):
if response.escalated:
return score_escalation(case, response)
if len(response.supporting_chunk_ids) == 0:
return 0
accuracy = judge_accuracy(case.expected_behavior, response.text)
grounding = judge_grounding(response.text, response.supporting_chunk_ids)
helpfulness = judge_helpfulness(case.user_message, response.text)
return weighted_score(
accuracy=accuracy,
grounding=grounding,
helpfulness=helpfulness,
)
Use an LLM-as-a-Judge for scalable scoring, but do not rely on it alone. Human reviewers should inspect low-confidence cases, negative feedback, and high-impact support categories. That review loop catches drift in prompts, chunking, entity extraction, and retrieval quality.
Step 8: Monitor the Pipeline and the User Path
Monitoring needs to cover both freshness and user experience. A bot can answer quickly with stale data, or slowly with correct data. Both are production problems.
| Area | Metric | Alert condition |
|---|---|---|
| Kafka ingestion | raw document topic lag | Lag above 200 triggers scaling, very high lag triggers urgent review |
| Ingestion freshness | Source event to vector write | More than 30 minutes |
| Embedding service | P95 embedding latency | More than 500 ms |
| Chat endpoint | P90 end-to-end RAG latency | More than 5 seconds |
| Retrieval | Vector plus graph lookup latency | More than 1.2 seconds |
| LLM provider | Time to first token | More than 1 second |
| LLM provider | Time to last token | More than 4 seconds |
| Cost | Daily LLM spend | More than 150 percent of baseline |
| Quality | Grounding score | Less than 90 percent |
| Feedback | Negative user feedback | More than 5 percent |
| Escalation | Escalation rate | More than 20 percent above baseline |
| Service health | 5xx error rate | More than 1 percent |
| Capacity | CPU or memory pressure | CPU above 85 percent or memory above 90 percent |
The quality metrics are just as important as latency metrics. A sudden rise in escalation rate may mean the intent model is misclassifying requests. A drop in grounding score may mean the vector index is stale, chunk metadata is weak, or graph relationships are missing.
Common Mistakes
Embedding raw private data before redaction
Redaction must happen before vectorization. Do not assume middleware later in the prompt path can fix sensitive data already embedded into the retrieval layer.
Searching only the latest user message
The most recent turn can be incomplete. Always rewrite from the full conversation history before retrieval.
Storing raw chunks inside the vector database
The vector database should store embeddings and metadata for search. Store raw chunks in durable blob storage and retrieve them only after candidate IDs are known.
Skipping query-time security filters
Every vector and graph lookup must filter by security level and user access. Do not retrieve broad internal context and hope the model ignores it.
Treating confidence as enough
Confidence without evidence is not safe. Require supporting chunk IDs or escalate.
Letting provider latency cascade
LLM providers can be slow or unavailable. Use timeouts, circuit breakers, streaming, and fallback models so one dependency does not break the whole support flow.
Implementation Checklist
- Store chat history by session ID in a low-latency cache.
- Check FAQ and predefined workflow caches before expensive retrieval.
- Rewrite the full conversation into a contextualized search query.
- Use vector search to find candidate chunks and documents.
- Use graph traversal to follow root cause, component, reference, and resolution relationships.
- Fetch raw chunks from blob storage only after retrieval identifies candidate IDs.
- Generate answers only from retrieved evidence.
- Escalate when evidence is weak, intent is out of scope, or private account data is requested.
- Redact PII before chunk storage or embedding.
- Store security metadata and enforce it during retrieval.
- Use small models for intent and entity extraction.
- Protect the GenAI service with timeouts, circuit breakers, and backup models.
- Evaluate with golden datasets, LLM-as-a-Judge scoring, user feedback, and human review.
- Monitor ingestion lag, freshness, retrieval latency, LLM latency, cost, grounding, feedback, and escalation rate.
Conclusion
A useful support agent is not just a prompt wrapped around documentation. It is a retrieval system, a graph system, a privacy boundary, a reliability layer, and an escalation workflow.
Naive RAG is a good starting point, but it is not enough for technical support where answers depend on relationships between errors, components, versions, tickets, and resolution steps. GraphRAG gives the orchestrator a way to move from similar text to structured evidence. With session-aware query rewriting, fresh ingestion, guarded generation, and strong monitoring, the agent can handle common cases automatically while routing ambiguous or sensitive cases to humans safely.