High-Performance AI Documentation with Astro: Content Collections and Semantic Search

Build fast documentation sites with Astro, content collections, and AI-powered semantic search using pgvector and RAG. Achieve Lighthouse scores of 90+ with intelligent search.

0

Why Astro for AI Documentation

Documentation sites sit at an interesting crossroads. They need to be fast, searchable, and often require dynamic features like real-time search, user feedback, or integration with external APIs. Traditional approaches force you to choose: build a static site that’s quick but limited, or use a full framework that adds overhead.

Astro sidesteps this problem. It generates static HTML by default, ships zero JavaScript unless you explicitly ask for it, and lets you add interactivity only where you need it. For documentation, this means page loads typically under 200ms, excellent Lighthouse scores, and the ability to layer on intelligent features without compromising performance.

The real win is combining Astro’s static generation with a separate AI search backend. Your documentation pages stay lightweight and cached everywhere. Your search engine lives independently, handling semantic queries through vector embeddings and RAG. This separation of concerns makes both layers easier to scale and maintain.

Setting Up Content Collections

Content collections in Astro provide a structured way to manage documentation. Instead of scattering markdown files across your project, you define a schema, organize your content, and get type-safe access to your docs throughout your site.

Create a collections config file to define your documentation structure:

// src/content/config.ts
import { defineCollection, z } from 'astro:content';

const docsCollection = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    description: z.string(),
    category: z.enum(['guides', 'api', 'concepts', 'tutorials']),
    published: z.date(),
    updated: z.date().optional(),
    author: z.string(),
    tags: z.array(z.string()),
    difficulty: z.enum(['beginner', 'intermediate', 'advanced']).optional(),
    searchable: z.boolean().default(true),
  }),
});

export const collections = {
  docs: docsCollection,
};

Organize your markdown files to match this structure. A guide on authentication might live at src/content/docs/guides/authentication.md with frontmatter matching your schema. Astro validates every file against the schema at build time, catching inconsistencies early.

The real benefit emerges when you query your collection. You can fetch all docs, filter by category, sort by date, and generate pages dynamically, all with full type safety:

// src/pages/docs/[...slug].astro
import { getCollection } from 'astro:content';

const allDocs = await getCollection('docs', ({ data }) => {
  return data.searchable === true;
});

export async function getStaticPaths() {
  return allDocs.map((doc) => ({
    params: { slug: doc.slug },
    props: { doc },
  }));
}

const { doc } = Astro.props;
const { Content } = await doc.render();

This approach scales smoothly. Add 500 docs, and Astro generates static pages for all of them at build time. No database queries at runtime. No N+1 problems. Just HTML.

Optimizing Markdown Processing

Markdown is the lingua franca of documentation, but raw markdown processing can become a bottleneck. Astro uses Vite and Rollup under the hood, which helps, but you can optimize further.

First, configure Astro to use a performant markdown processor. Astro defaults to solid choices for markdown handling. If you need custom syntax highlighting or advanced features, consider Shiki for fast, server-side syntax highlighting:

// astro.config.mjs
import { defineConfig } from 'astro/config';

export default defineConfig({
  markdown: {
    syntaxHighlight: 'shiki',
    shikiConfig: {
      theme: 'github-dark',
      langs: ['javascript', 'typescript', 'python', 'sql', 'bash'],
    },
  },
});

Second, extract metadata efficiently. When building your search index, you’ll want summaries, headings, and structured data from each doc. Use a unified plugin to parse markdown into an AST, extract what you need, and pass it to your indexing pipeline:

// lib/markdown-indexer.ts
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkStringify from 'remark-stringify';
import { visit } from 'unist-util-visit';

export function extractDocMetadata(markdown: string) {
  const processor = unified().use(remarkParse);
  const ast = processor.parse(markdown);
  
  const headings: { level: number; text: string }[] = [];
  const codeBlocks: string[] = [];
  
  visit(ast, 'heading', (node) => {
    const text = node.children
      .map((child: any) => child.value || '')
      .join('');
    headings.push({ level: node.depth, text });
  });
  
  visit(ast, 'code', (node) => {
    codeBlocks.push(node.value);
  });
  
  return { headings, codeBlocks };
}

This metadata becomes part of your search index. When a user searches for a term, you can return not just the page title, but the most relevant section heading, giving them a direct link into the documentation.

Integrating AI-Powered Search with pgvector and RAG

Static HTML pages load fast, but search needs to be dynamic and intelligent. This is where vector embeddings and retrieval-augmented generation (RAG) shine. Instead of keyword matching, you embed your documentation into a vector database and return results based on semantic similarity.

PostgreSQL with the pgvector extension handles this beautifully. Set up a table to store document chunks and their embeddings:

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE doc_chunks (
  id SERIAL PRIMARY KEY,
  doc_id TEXT NOT NULL,
  section_title TEXT,
  content TEXT NOT NULL,
  embedding vector(1536),
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX ON doc_chunks USING ivfflat (embedding vector_cosine_ops);

The embedding column stores a 1536-dimensional vector (typical for OpenAI’s text-embedding-3-small model). The index speeds up similarity searches across thousands of chunks.

Build an indexing pipeline that runs during your build or on a schedule. Fetch all your docs, split them into chunks, embed each chunk, and store the results:

// lib/indexing-pipeline.ts
import { getCollection } from 'astro:content';
import { OpenAIEmbeddings } from 'langchain/embeddings/openai';
import { RecursiveCharacterTextSplitter } from 'langchain/text_splitters';
import { Pool } from 'pg';

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

const embeddings = new OpenAIEmbeddings({
  openAIApiKey: process.env.OPENAI_API_KEY,
});

export async function indexDocumentation() {
  const allDocs = await getCollection('docs');
  const splitter = new RecursiveCharacterTextSplitter({
    chunkSize: 500,
    chunkOverlap: 100,
  });

  for (const doc of allDocs) {
    const { Content } = await doc.render();
    const text = Content.toString();
    const chunks = await splitter.splitText(text);

    for (const chunk of chunks) {
      const embedding = await embeddings.embedQuery(chunk);
      await pool.query(
        'INSERT INTO doc_chunks (doc_id, section_title, content, embedding) VALUES ($1, $2, $3, $4)',
        [doc.slug, doc.data.title, chunk, JSON.stringify(embedding)]
      );
    }
  }
}

Create an API endpoint that handles search queries. Accept a query string, embed it, and retrieve the most similar chunks from your database:

// src/pages/api/search.ts
import type { APIRoute } from 'astro';
import { OpenAIEmbeddings } from 'langchain/embeddings/openai';
import { Pool } from 'pg';

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

const embeddings = new OpenAIEmbeddings({
  openAIApiKey: process.env.OPENAI_API_KEY,
});

export const POST: APIRoute = async ({ request }) => {
  const { query } = await request.json();

  if (!query || query.length < 2) {
    return new Response(JSON.stringify({ results: [] }), { status: 400 });
  }

  const queryEmbedding = await embeddings.embedQuery(query);

  const result = await pool.query(
    `SELECT doc_id, section_title, content, 1 - (embedding <=> $1::vector) as similarity
     FROM doc_chunks
     WHERE 1 - (embedding <=> $1::vector) > 0.7
     ORDER BY similarity DESC
     LIMIT 10`,
    [JSON.stringify(queryEmbedding)]
  );

  return new Response(JSON.stringify({ results: result.rows }));
};

On the frontend, build a search component that calls this endpoint and displays results. Use Astro’s island architecture to add interactivity only where needed:

// src/components/SearchBox.tsx
import { useState, useRef, useEffect } from 'react';

export default function SearchBox() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);
  const debounceTimer = useRef<NodeJS.Timeout>();

  useEffect(() => {
    if (debounceTimer.current) {
      clearTimeout(debounceTimer.current);
    }

    if (query.length < 2) {
      setResults([]);
      return;
    }

    debounceTimer.current = setTimeout(async () => {
      setLoading(true);
      const response = await fetch('/api/search', {
        method: 'POST',
        body: JSON.stringify({ query }),
      });
      const { results } = await response.json();
      setResults(results);
      setLoading(false);
    }, 300);
  }, [query]);

  return (
    <div className="search-box">
      <input
        type="text"
        placeholder="Search documentation..."
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        className="search-input"
      />
      {loading && <p>Searching...</p>}
      <ul className="search-results">
        {results.map((result: any) => (
          <li key={result.id}>
            <a href={`/docs/${result.doc_id}`}>
              <strong>{result.section_title}</strong>
              <p>{result.content.substring(0, 150)}...</p>
            </a>
          </li>
        ))}
      </ul>
    </div>
  );
}

Wrap this component with the client directive so it hydrates only in the browser:

---
import SearchBox from '../components/SearchBox';
---

<SearchBox client:load />

This architecture keeps your static pages lean. Search logic runs server-side in your Node.js API, database queries happen in PostgreSQL, and the frontend just displays results. Users get fast page loads and intelligent, context-aware search results.

Partial Hydration and Strategic JavaScript

Astro’s partial hydration approach is a game-changer for documentation. Instead of hydrating your entire site, you specify exactly which components need JavaScript. Your navigation and content display stay as pure HTML. Only your search box, interactive examples, or feedback forms get JavaScript.

Use the client directives strategically. Mark your search component with client:load to hydrate immediately, but keep your navigation and content display as pure HTML:

---
import SearchBox from '../components/SearchBox';
import Navigation from '../components/Navigation';
---

<Navigation /> <!-- Pure HTML, zero JavaScript -->
<SearchBox client:load /> <!-- Hydrated only in the browser -->
<article>
  <!-- Your documentation content, pure HTML -->
</article>

Astro’s image optimization also helps. Use the Image component to automatically generate responsive images, serve WebP where supported, and lazy-load images below the fold:

---
import { Image } from 'astro:assets';
import screenshot from '../assets/docs-screenshot.png';
---

<Image
  src={screenshot}
  alt="Documentation screenshot"
  loading="lazy"
  format="webp"
/>

For documentation, this means diagrams and screenshots load only when users scroll to them, keeping your initial page load fast.

Deploying on Edge Networks

Static HTML from Astro is a perfect fit for edge networks. Services like Netlify, Vercel, and Cloudflare Pages automatically cache your built files globally, serving them from locations close to your users.

For your search API, deploy it as a serverless function or edge function. Vercel Edge Functions and Cloudflare Workers both support Node.js and can query your PostgreSQL database with minimal latency:

// Deploy as Vercel Edge Function
import type { NextRequest } from 'next';

export const config = {
  runtime: 'edge',
};

export default async function handler(request: NextRequest) {
  // Your search logic here
  // The function runs at the edge, close to users
}

Your PostgreSQL database should be in a region close to your edge functions. Many providers offer regional replicas, so queries execute with minimal network latency.

Measuring Performance

Build your documentation site, then measure it. Run Lighthouse locally and on production. Most Astro sites achieve Lighthouse scores of 90 or higher by default. For search performance, monitor query latency. Aim for response times under 200ms. If queries slow down, check your pgvector index. The IVFFLAT index is fast for most cases, but for very large datasets, you might need to tune the number of probes or consider alternative indexing strategies.

Use real user monitoring to track actual page load times. Tools like Web Vitals let you collect Core Web Vitals from your users and identify patterns in the wild.

Conclusion

Astro makes it straightforward to build documentation sites that are both fast and intelligent. Content collections give you structure and type safety. Static generation keeps page loads quick. And by layering in a separate AI search backend powered by vector embeddings and RAG, you add semantic understanding without compromising performance.

The result is a documentation platform that scales with your product. Pages load quickly. Search understands context, not just keywords. And because the static and dynamic layers are decoupled, you can iterate on either without affecting the other.

Start with Astro’s defaults, measure your performance, and add complexity only where it matters. Most documentation sites don’t need it, but when they do, this architecture handles it gracefully.

What’s the difference between Astro and Next.js for documentation?

Astro generates static HTML by default and ships zero JavaScript unless you ask for it. Next.js typically renders on demand and requires a Node.js server. For documentation, Astro’s static-first approach means faster initial page loads, better caching, and lower infrastructure costs. Both can integrate AI search, but Astro’s separation of static content and dynamic search is cleaner.

Do I need a vector database for search, or can I use keyword search?

Keyword search is simpler but returns results based on exact matches. Vector embeddings understand meaning. If someone searches for “how to authenticate users” and your docs use the term “user login,” keyword search misses it. Vector search catches it. For small documentation sites under 100 pages, keyword search works fine. Beyond that, vectors give much better results.

How often should I re-index my documentation?

Re-index whenever you publish new docs or make significant updates. You can trigger indexing as part of your build pipeline (every deploy), or on a schedule (nightly). For most teams, re-indexing on every deploy is the right balance between freshness and cost.

Can I use Astro with a headless CMS instead of markdown files?

Yes. Astro’s content collections work with any data source. Instead of reading markdown files, you can fetch docs from Contentful, Sanity, or any API. The build-time processing and static generation remain the same, so performance doesn’t change.

What’s the typical cost of running this architecture?

Hosting static files on a CDN costs almost nothing (often free or a few dollars monthly). PostgreSQL with pgvector might run 15 to 50 dollars monthly on a managed service. API calls to embed text (if using OpenAI) depend on your volume but typically cost less than 10 dollars monthly for most documentation sites. Total: usually under 100 dollars monthly, often much less.

Leave a Reply

Your email address will not be published. Required fields are marked *