Building PETER with Bedrock

Context

After building my personal portfolio website to showcase projects and experiments, I wanted to make it more interactive and useful.
The goal was to create something that could explain my work, using the same structured content that powers my site.

That idea became PETER, short for Personal Engineering Tech & Experience Responder.
It is an AI assistant built on AWS Bedrock that uses my portfolio content and project metadata as a knowledge base.


The Goal

Functional Goals

  1. Let users ask questions about my projects, tools, and approach to analytics.
  2. Use AWS Bedrock Knowledge Bases to retrieve accurate context from real portfolio content.
  3. Automatically generate page metadata and citations so answers can point to the right sources.
  4. Extend my structured data mindset into conversational systems, not just dashboards.

Design Goals


Architecture Overview

The system builds on the same pipeline that serves my website, with a few key additions for Bedrock and Knowledge Bases.

MDX Files (.mdx)

MDX → HTML + Metadata Compiler

Output Folder: bedrock/html

S3 Bucket (Knowledge Base Storage)

AWS Bedrock Knowledge Base (S3 Vector Index)

Chat Interface (Next.js)

Bedrock Runtime (Retrieval + Generation)

Components


Metadata Generation

AWS Bedrock Knowledge Bases support metadata for each document. I use this to store values like title, date, stack, and canonical URL.

In the compiler script (mdxtohtml.mjs), each MDX file is parsed with gray-matter, flattened, and paired with rendered HTML.

const bedrock_metadata = {
  "metadataAttributes": {
    "url": getPageUrl(filePath),
    ...flattenObject(metadata)
  }
}

This produces a small JSON file for each article:

{
  "metadataAttributes": {
    "url": "https://peterventon.co.uk/work/ai-powered-portfolio",
    "title": "AI Powered Portfolio",
    "stack_0": "AWS",
    "projectType": "Personal project",
    "date": "02/11/2025"
  }
}

When a question triggers retrieval, Bedrock includes this metadata with the response.
The chat interface uses it to show clickable citations.


Converting MDX to HTML

Since Bedrock does not support .mdx directly, I prebuild everything into clean HTML.
The compiler handles both rendering and metadata creation.

Processing Steps

  1. Read the file and extract frontmatter with gray-matter.
  2. Evaluate MDX into React using @mdx-js/mdx.
  3. Render to static HTML with react-dom/server.
  4. Sanitize and serialize the output using rehype.
  5. Write both HTML and metadata JSON to /bedrock/html.
const file = await unified()
  .use(rehypeParse, { fragment: true })
  .use(rehypeSanitize)
  .use(rehypeMermaid)
  .use(rehypeStringify)
  .process(html)

await fs.writeFile(`${slug}.html`, file.toString())
await fs.writeFile(`${slug}.html.metadata.json`, JSON.stringify(bedrock_metadata))

The result is a clean HTML corpus with metadata that Bedrock can index and serve.


Bedrock Knowledge Base Setup

Each HTML file and its metadata are uploaded to an S3 bucket configured as a Knowledge Base source.
Instead of OpenSearch, this setup uses the new S3 Vector Indexing feature to embed and retrieve documents directly from S3.

When a user asks a question:

  1. Bedrock retrieves the most relevant HTML chunks and their metadata.
  2. The model generates a grounded response.
  3. The API route attaches the metadata.url and metadata.title as citations in the chat interface.
S3 Vector IndexBedrock Runtime/api/chatNext.js ChatUserS3 Vector IndexBedrock Runtime/api/chatNext.js ChatUserAsk questionSend queryRetrieve + GenerateFetch HTML + MetadataContext + Source MetadataResponse + MetadataStream message + citationsShow answer with linked sources

Routing for Citations

Citations always link to an intentional route.
The compiler assigns each document a URL based on where it lives and its frontmatter status.

This ensures every citation leads somewhere useful, even if the content is not public yet.

Example mapping:

function getPageUrl(filePath, metadata) {
  const base = "https://www.peterventon.co.uk";
  const slug = /* derive from filePath */;
  const section = /* 'projects', 'kb-additional', etc. */;

  if (section === "kb-additional") return `${base}/kb/${slug}`;
  if (section === "projects") {
    const isActive = metadata?.active !== false;
    return isActive
      ? `${base}/work/${slug}`
      : `${base}/bedrock/unreleased?t=${encodeURIComponent(metadata?.title ?? slug)}&slug=${slug}`;
  }

  return `${base}/bedrock/missing`;
}

Handling Sensitive Fields with SSM

My build process uses several environment variables such as API keys, prompts, and config values.
To keep these out of version control, I use AWS Systems Manager Parameter Store (SSM) to fetch them securely during builds.

The Amplify pipeline retrieves and exports each variable before running the build.

version: 1
frontend:
  phases:
    preBuild:
      commands:
        - npm ci
        - npx playwright install chromium
        - echo "Fetching secrets from SSM..."
        - |
          for secret in $(aws ssm get-parameters-by-path \
            --path "<secret>" \
            --with-decryption \
            --region "eu-west-2" \
            --query "Parameters[*].{Name:Name}" \
            --output text); do

              key=$(basename "$secret")
              value=$(aws ssm get-parameter \
                --name "$secret" \
                --with-decryption \
                --region "eu-west-2" \
                --query "Parameter.Value" \
                --output text)
              
              if [ "$key" = "BEDROCK_SYSTEM_PROMPT" ]; then
                echo "Writing System Prompt File..."
                echo  "$value" >> bedrock_system_prompt.txt
                continue
              fi

              echo "$key=$value" >> .env
              export "$key=$value"
          done
    build:
      commands:
        - npm run build

Sensitive values are stored encrypted at rest and fetched only at build time.
Nothing sensitive is ever committed to GitHub.


Deduplicating Sources in the Chat Interface

Originally, multiple chunks from the same document appeared as separate sources.
This cluttered the citation list, especially for long articles.

Image showing old citations from the AI assistant

I fixed this by merging identical URLs and showing a count next to each title.

Image showing new citations from the AI assistant

The result is cleaner and easier to read, while still showing how many times a document was used.


Expanding the Knowledge Base

With the content and metadata pipeline in place, I began enriching the Knowledge Base with structured and external data.

This follows the same logic as BI modeling: a shared semantic layer that unifies multiple data sources.


Results

1
AWS Knowledge Base
Connected via Bedrock
40+
Documents Indexed
HTML + Metadata pairs
100%
Grounded Responses
Each answer cites its source

Lessons Learned


Next Steps


Conclusion

PETER shows how data modeling principles like consistency and governance can improve even AI systems.
By combining my content, metadata, and Bedrock’s retrieval pipeline, the portfolio becomes a living knowledge system.

Every answer now has context, provenance, and a clear source, just like any well-modeled BI platform.

Model once. Reuse everywhere. Scale beyond dashboards.