Most portfolios have a boring "About Me" paragraph. I replaced mine with something you can talk to — an AI terminal that answers questions about me, pulls my live GitHub activity, and remembers the conversation. You can try it right now on my portfolio: rehbarkhan.in.
I'm Rehbar Khan, a Full Stack & Gen-AI developer, and in this post I'll break down exactly how it works — the RAG pipeline, the LangGraph agent, the memory layer, and how I wired in real GitHub data with MCP. No fluff, just the architecture.
The problem
I wanted a portfolio assistant that could:
- Answer questions about my background accurately — no hallucinated jobs or fake projects.
- Fetch live data (my latest GitHub activity), not a stale snapshot.
- Remember the conversation across messages.
- Stream responses token-by-token like a real terminal.
That rules out "just prompt an LLM." You need retrieval for grounding, tools for live data, and state for memory. Here's the stack I landed on.
Architecture at a glance
Next.js 16 chat UI ──► FastAPI (streaming) ──► LangGraph agent
├── RAG retriever → pgvector (Neon Postgres)
├── GitHub MCP tool → live GitHub data
└── Redis checkpointer → conversation memory
- Frontend: Next.js 16 (App Router, TypeScript) — a streaming terminal UI.
- Backend: FastAPI with streaming responses.
-
Orchestration: LangGraph (
StateGraph,ToolNode,tools_condition). -
LLM: OpenAI
gpt-4o-mini. -
Retrieval:
text-embedding-3-small→ pgvector on Neon Postgres. - Memory: Redis checkpointer for per-session history.
- Live data: GitHub via the Model Context Protocol (MCP).
1. The RAG pipeline
Everything the bot knows about me lives in a single reference.txt. I chunk it, embed it, and store it in Postgres with pgvector:
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_postgres import PGVector
splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=80)
chunks = splitter.split_text(open("reference.txt").read())
vectorstore = PGVector.from_texts(
texts=chunks,
embedding=OpenAIEmbeddings(model="text-embedding-3-small"),
connection=CONNECTION_STRING, # load from env, never hardcode!
collection_name="profile",
)
Why chunk_size=500 / overlap=80? My profile data is dense and fact-heavy — short chunks keep each fact retrievable without dragging in unrelated context. The overlap stops sentences from getting cut mid-fact across chunk boundaries.
At query time I retrieve with a score threshold, not just top-k, so irrelevant matches get dropped instead of padding the prompt:
retriever = vectorstore.as_retriever(
search_type="similarity_score_threshold",
search_kwargs={"k": 2, "score_threshold": 0.4},
)
This is the anti-hallucination trick: if nothing clears the threshold, the model gets no context and answers "I don't know" instead of inventing something.
2. The LangGraph agent
Instead of a linear chain, I use a graph so the model can decide whether to call a tool (like fetching GitHub data) or answer directly:
from langgraph.graph import StateGraph, START
from langgraph.prebuilt import ToolNode, tools_condition
graph = StateGraph(State)
graph.add_node("model", call_model)
graph.add_node("tools", ToolNode(tools))
graph.add_conditional_edges("model", tools_condition)
graph.add_edge("tools", "model")
graph.add_edge(START, "model")
tools_condition routes to the tool node only when the model emits a tool call, then loops back. Clean, and no brittle if/else routing.
3. Live GitHub data via MCP
The bot can answer "what has Rehbar been building lately?" with real data because I connected the GitHub MCP server as a tool source:
from langchain_mcp_adapters.client import MultiServerMCPClient
client = MultiServerMCPClient({
"Github": {
"transport": "streamable_http",
"url": "https://api.githubcopilot.com/mcp/",
"headers": {"Authorization": f"Bearer {github_token}"},
}
})
tools = await client.get_tools()
MCP is the killer part — I get a whole toolset without hand-writing API wrappers. The agent picks the right tool on its own.
4. Memory with Redis
LangGraph checkpointers make conversation memory almost free. I use a Redis-backed saver keyed by session id, so each visitor gets their own thread:
graph = builder.compile(checkpointer=AsyncRedisSaver(...))
What I learned
- Score thresholds beat top-k for factual RAG — precision over recall when the cost of a wrong fact is high.
- MCP removes glue code. Adding a live data source went from "write an API client" to "point at a server."
- Graphs > chains the moment you need conditional tool use.
Try it
The bot is live on my portfolio — ask it anything about my work: rehbarkhan.in. Full stack is Next.js + FastAPI + LangGraph + pgvector + Redis.
I'm Rehbar Khan, a Full Stack & Gen-AI developer building intelligent systems with LangChain, RAG, and LLMs. More at rehbarkhan.in · GitHub · LinkedIn.
Top comments (0)