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.

Architect
Architect
Architect
Building a ChatGPT‑Powered Markdown Documentation System with Embedbase and Nextra

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 dev

Preparing 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.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

AINode.jsChatGPTEmbeddingOpenAINextra
Architect
Written by

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.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.