Building a ChatGPT‑Powered Markdown Documentation System with Embedbase and Nextra
This article explains step‑by‑step how to turn a static Markdown documentation site into an AI‑enhanced, interactive knowledge base by storing content in Embedbase, retrieving semantically similar passages, constructing context‑aware prompts, and invoking ChatGPT through a custom Nextra search component.
This guide shows how to add ChatGPT‑driven question‑answering to an online Markdown documentation system, using OpenAI, Embedbase, Nextra, Next.js, Tailwind CSS and MDX.
Overview
The workflow consists of storing documentation chunks in Embedbase, letting users submit queries, retrieving the most similar chunks, building a contextual prompt, and sending it to ChatGPT for an answer.
Prerequisites
Embedbase API key (semantic search database)
OpenAI API key (ChatGPT access)
Nextra documentation framework installed with Node.js
Configuration
Add the keys to a .env file:
OPENAI_API_KEY="<YOUR KEY>"
EMBEDBASE_API_KEY="<YOUR KEY>"Creating Nextra Docs
Clone the official Nextra template from GitHub and run the development server:
# we won't use "pnpm" here, rather the traditional "npm"
rm pnpm-lock.yaml
npm i
npm run devPreparing and Storing Files
Write a script scripts/sync.js that reads all .mdx files, splits them into 100‑line chunks, and pushes the chunks to Embedbase.
const glob = require("glob");
const fs = require("fs");
const sync = async () => {
// 1. read all files under pages/* with .mdx extension
const documents = glob.sync("pages/**/*.mdx").map(path => ({
id: path.replace("pages/", "/").replace("index.mdx", "").replace(".mdx", ""),
data: fs.readFileSync(path, "utf-8")
}));
// 2. split documents into chunks of 100 lines
const chunks = [];
documents.forEach(document => {
const lines = document.data.split("
");
const chunkSize = 100;
for (let i = 0; i < lines.length; i += chunkSize) {
const chunk = lines.slice(i, i + chunkSize).join("
");
chunks.push({ data: chunk });
}
});
};
sync();Install [email protected] and upload the chunks:
const fetch = require("node-fetch");
const apiKey = process.env.EMBEDBASE_API_KEY;
const response = await fetch("https://embedbase-hosted-usx5gpslaq-uc.a.run.app/v1/documentation", {
method: "POST",
headers: {
"Authorization": "Bearer " + apiKey,
"Content-Type": "application/json"
},
body: JSON.stringify({ documents: chunks })
});
console.log(await response.json());Search Component
Replace the default Nextra search bar with a modal that accepts a question, calls a buildPrompt API, and streams the answer from ChatGPT.
import { DocsThemeConfig, useTheme } from 'nextra-theme-docs';
const Modal = ({ children, open, onClose }) => {
const theme = useTheme();
if (!open) return null;
return (
<div style={{position:'fixed',top:0,left:0,right:0,bottom:0,backgroundColor:'rgba(0,0,0,0.5)',zIndex:100}} onClick={onClose}>
<div style={{position:'absolute',top:'50%',left:'50%',transform:'translate(-50%,-50%)',backgroundColor:theme.resolvedTheme==='dark'?'#1a1a1a':'white',padding:20,borderRadius:5,width:'80%',maxWidth:700,maxHeight:'80%',overflow:'auto'}} onClick={e=>e.stopPropagation()}>
{children}
</div>
</div>
);
};Search UI:
import React, { useState } from 'react';
const Search = () => {
const [open, setOpen] = useState(false);
const [question, setQuestion] = useState("");
const [answer, setAnswer] = useState("");
const answerQuestion = async e => {
e.preventDefault();
setAnswer("");
const promptRes = await fetch("/api/buildPrompt", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({prompt:question})});
const {prompt} = await promptRes.json();
const resp = await fetch("/api/qa", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({prompt})});
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let done = false;
while (!done) {
const {value, done: doneReading} = await reader.read();
done = doneReading;
setAnswer(prev => prev + decoder.decode(value));
}
};
return (
<>
<input placeholder="Ask a question" onClick={() => setOpen(true)} type="text" />
<Modal open={open} onClose={() => setOpen(false)}>
<form onSubmit={answerQuestion} className="nx-flex nx-gap-3">
<input placeholder="Ask a question" type="text" value={question} onChange={e => setQuestion(e.target.value)} />
<button type="submit">Ask</button>
</form>
<p>{answer}</p>
</Modal>
</>
);
};Update theme.config.tsx to use the new component:
const config: DocsThemeConfig = {
logo: <span>My Project</span>,
project: { link: 'https://github.com/shuding/nextra-docs-template' },
chat: { link: 'https://discord.com' },
docsRepositoryBase: 'https://github.com/shuding/nextra-docs-template',
footer: { text: 'Nextra Docs Template' },
search: { component: <Search /> }
};Building the Contextual Prompt
Use @dqbd/tiktoken to count tokens and fetch the top‑5 similar chunks from Embedbase.
import { get_encoding } from "@dqbd/tiktoken";
const enc = get_encoding('cl100k_base');
const apiKey = process.env.EMBEDBASE_API_KEY;
const search = async query => {
const res = await fetch("https://embedbase-hosted-usx5gpslaq-uc.a.run.app/v1/documentation/search", {
method: "POST",
headers: { "Authorization": "Bearer " + apiKey, "Content-Type": "application/json" },
body: JSON.stringify({ query })
});
return await res.json();
};
export default async function buildPrompt(req, res) {
const { prompt } = req.body;
const searchResponse = await search(prompt);
let curLen = 0;
const returns = [];
for (const similarity of searchResponse["similarities"]) {
const sentence = similarity["data"];
const nTokens = enc.encode(sentence).length;
curLen += nTokens + 4;
if (curLen > 1800) break;
returns.push(sentence);
}
const context = returns.join("
###
");
const newPrompt = `Answer the question based on the context below, and if the question can't be answered based on the context, say "I don't know"
Context: ${context}
---
Question: ${prompt}
Answer:`;
res.status(200).json({ prompt: newPrompt });
}Streaming ChatGPT Responses
Utility OpenAIStream.ts creates a readable stream from the OpenAI chat completion endpoint.
import { createParser, ParsedEvent, ReconnectInterval } from "eventsource-parser";
export interface OpenAIStreamPayload {
model: string;
messages: { role: "user"; content: string }[];
stream: boolean;
}
export async function OpenAIStream(payload: OpenAIStreamPayload) {
const encoder = new TextEncoder();
const decoder = new TextDecoder();
let counter = 0;
const res = await fetch("https://api.openai.com/v1/chat/completions", {
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${process.env.OPENAI_API_KEY ?? ""}`
},
method: "POST",
body: JSON.stringify(payload)
});
const parser = createParser(event => {
if (event.type === "event") {
const data = event.data;
if (data === "[DONE]") return parser.close();
try {
const json = JSON.parse(data);
const text = json.choices[0]?.delta?.content;
if (!text) return;
if (counter < 2 && (text.match(/
/) || []).length) return;
const queue = encoder.encode(text);
controller.enqueue(queue);
counter++;
} catch (e) { controller.error(e); }
}
});
const stream = new ReadableStream({
async start(controller) {
for await (const chunk of res.body as any) {
parser.feed(decoder.decode(chunk));
}
}
});
return stream;
}Expose the streaming endpoint in pages/api/qa.ts:
import { OpenAIStream, OpenAIStreamPayload } from "../../utils/OpenAIStream";
export const config = { runtime: "edge" };
export default async function handler(req: Request, res: Response) {
const { prompt } = await req.json();
if (!prompt) return new Response("No prompt in the request", { status: 400 });
const payload: OpenAIStreamPayload = {
model: "gpt-3.5-turbo",
messages: [{ role: "user", content: prompt }],
stream: true
};
const stream = await OpenAIStream(payload);
return new Response(stream);
}Connecting Everything
The Search component calls /api/buildPrompt to get a context‑aware prompt and then streams the answer from /api/qa. Users can ask natural‑language questions about the documentation and receive AI‑generated answers.
Conclusion
Created a Nextra documentation site.
Stored documentation chunks in Embedbase.
Implemented an API to retrieve similar chunks.
Built a contextual prompt and streamed ChatGPT responses.
Integrated the whole flow into a custom search modal.
Further Reading
Embedding enables semantic search, recommendation, classification, and generative search. For production use consider storage infrastructure, cost optimisation, user isolation, token limits, and integration with services like Supabase or Firebase.
GitHub Action .github/workflows/index.yaml can automatically re‑index documentation on each push.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Architect
Professional architect sharing high‑quality architecture insights. Topics include high‑availability, high‑performance, high‑stability architectures, big data, machine learning, Java, system and distributed architecture, AI, and practical large‑scale architecture case studies. Open to ideas‑driven architects who enjoy sharing and learning.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
