Files
SlipItIn/.github/skills/astro-sites-manager/references/related-content.md
Tim Krampitz 01046b01e4 Neue Skills, Referenzen & OpenWiki-Doku integriert
Umfangreiche Erweiterung der Skill-Bibliothek: Neue Skills für Humanisierung (Englisch/PT-BR), Design-Validierung, AI-SEO und Coolify-Deployment inkl. Regelwerke, Presets, Pattern-Referenzen, Testfälle und Automatisierungsskripte. Zusätzliche Skills für Revenue-Centric Design, Pier Cloud, OKF, Lebenslauf- und LinkedIn-Optimierung sowie zahlreiche Referenzdateien, Checklisten und YAML/JSON/Markdown-Templates. Einführung einer vollständigen OpenWiki-Dokumentation mit Architektur-, Domain- und Workflow-Beschreibungen, zentralem Index und automatisierten Updates. Modularer Aufbau, restriktive Lizenzen und umfassende Qualitäts- und Evaluationsmechanismen für alle neuen Inhalte.
2026-07-26 14:00:58 +02:00

13 KiB

Related Content com Vector Embeddings

Conteúdo relacionado semântico para Astro content collections usando @philnash/astro-related-content. Gera sugestões de posts relacionados via vector embeddings locais (transformers.js) sem depender de APIs externas em runtime.


Conceito

A integração calcula similaridade semântica entre posts usando embeddings (vetores numéricos que representam o "significado" do texto). Posts com vetores próximos são semanticamente similares. Tudo roda em build time — zero impacto no visitante.


Instalação

npm install @philnash/astro-related-content

Configuração Básica

// astro.config.ts
import astroRelatedContent from '@philnash/astro-related-content'

export default defineConfig({
  integrations: [
    astroRelatedContent({
      collections: ['blog'],
      generation: {
        limit: 4,        // posts relacionados por item
        watch: false,    // não regenerar em dev mode (economiza CPU)
      },
      embeddings: {
        model: 'onnx-community/embeddinggemma-300m-ONNX',
        dtype: 'fp32',
        pooling: 'mean',
        batchSize: 1,
      },
    }),
  ],
})

Escolha de Modelo

Modelo Idiomas Context Tamanho Pooling Uso
Xenova/all-MiniLM-L6-v2 EN only 256 tokens ~22MB mean Default, ruim para PT-BR
onnx-community/embeddinggemma-300m-ONNX Multilingual 2048 tokens ~300MB mean Recomendado para PT-BR
onnx-community/Qwen3-Embedding-0.6B-ONNX Multilingual 32k tokens ~600MB last_token Posts muito longos
onnx-community/granite-embedding-small-english-r2-ONNX EN 8192 tokens ~130MB cls EN com context longo

Regra: Para conteúdo em português, NUNCA usar o modelo default (all-MiniLM-L6-v2). Use embeddinggemma-300m-ONNX ou superior.


Custom Provider (LiteLLM, OpenAI, etc.)

A integração aceita custom providers via interface EmbeddingProvider:

// litellm-provider.ts
import { createEmbeddingProvider } from '@philnash/astro-related-content/providers'

export const litellmProvider = createEmbeddingProvider({
  name: 'litellm',
  version: '1.0.0',

  async embed(texts, options) {
    const response = await fetch(`${options.baseUrl}/embeddings`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${options.apiKey}`,
      },
      body: JSON.stringify({ model: options.model, input: texts }),
    })
    const data = await response.json()
    return data.data.map((item: any) => item.embedding)
  },

  getMetadata(options) {
    return { model: options.model, baseUrl: options.baseUrl }
  },
})
// astro.config.ts
import { litellmProvider } from './litellm-provider'

astroRelatedContent({
  collections: ['blog'],
  embeddings: {
    provider: litellmProvider,
    baseUrl: 'http://localhost:4000',
    apiKey: 'sk-...',
    model: 'text-embedding-3-small',
    batchSize: 10,
  },
})

Artefatos Gerados

A integração gera na pasta .astro-related-content/:

Arquivo Conteúdo Tamanho típico (32 posts)
data.json Rankings (top N related por post) ~14KB
vectors.json Cache de embeddings + metadata ~736KB

O modelo ONNX é cacheado em .astro/astro-related-content/models/ (não vai pro repo — .astro/ está no .gitignore).


Uso no Componente

Bug de compatibilidade Astro v7

O getRelatedContent() do virtual module não funciona com Astro v7 glob loader. O motivo: a integração gera IDs como slug/index no data.json, mas o Astro v7 usa slug (sem /index) como entry.id.

Workaround: Usar getRelatedContentMatches() + lookup manual:

---
// RelatedPosts.astro
import { getPostRoute } from '@/lib/data-utils'
import { formatDate } from '@/lib/utils'
import { Icon } from 'astro-icon/components'
import { Image } from 'astro:assets'
import { getCollection, type CollectionEntry } from 'astro:content'
import { getRelatedContentMatches } from 'virtual:astro-related-content'
import Link from './Link.astro'

interface Props {
  postId: string
}

const { postId } = Astro.props
const matches = getRelatedContentMatches('blog', `${postId}/index`)

let relatedContent: { entry: CollectionEntry<'blog'>; score: number }[] = []
if (matches.length > 0) {
  const allEntries = await getCollection('blog')
  const entryById = new Map(allEntries.map((e) => [e.id, e]))

  relatedContent = matches.flatMap((match) => {
    // match.id = "slug/index", entry.id no Astro v7 = "slug"
    const normalizedId = match.id.replace(/\/index$/, '')
    const entry = entryById.get(normalizedId) || entryById.get(match.id)
    return entry ? [{ entry, score: match.score }] : []
  })
}
---

{
  relatedContent.length > 0 && (
    <section class="mt-12 border-t pt-8">
      <h2 class="mb-6 flex items-center gap-2 text-xl font-medium">
        <Icon name="lucide:sparkles" class="size-5" />
        Leitura Relacionada
      </h2>
      <div class="grid gap-4 sm:grid-cols-2">
        {relatedContent.map((item) => (
          <Link
            href={getPostRoute(item.entry)}
            class="hover:bg-muted/50 flex gap-3 rounded-xl border p-3 transition-colors duration-300"
          >
            {item.entry.data.image && (
              <div class="hidden w-16 shrink-0 sm:block">
                <Image
                  src={item.entry.data.image}
                  alt={item.entry.data.title}
                  width={128}
                  height={67}
                  class="rounded-md object-cover"
                />
              </div>
            )}
            <div class="min-w-0">
              <h3 class="mb-1 truncate text-sm font-medium">
                {item.entry.data.title}
              </h3>
              <p class="text-muted-foreground text-xs">
                {formatDate(item.entry.data.date)}
              </p>
            </div>
          </Link>
        ))}
      </div>
    </section>
  )
}

Deploy no Coolify — Sem Baixar Modelo em Produção

O Problema

Na primeira build Docker, a integração baixa o modelo ONNX (~300MB) e processa todos os embeddings. Em um servidor com bandwidth limitada, isso pode levar 20+ minutos e esgotar disco.

A Solução: Dual-Mode (Local + CI)

Princípio: Gerar embeddings localmente, commitar o cache, e em CI usar apenas o data.json pré-gerado via Vite plugin leve (sem modelo, sem transformers.js).

1. Commitar os artefatos

Garantir que .astro-related-content/ NÃO está no .gitignore:

# Verificar
grep "astro-related-content" .gitignore
# Se aparecer, remover a linha

# Commitar cache
git add .astro-related-content/
git commit -m "chore: cache embeddings related content"

2. Configuração condicional no astro.config.ts

import { existsSync } from 'node:fs'
import { resolve } from 'node:path'
import astroRelatedContent from '@philnash/astro-related-content'

// Em CI: usa data.json pré-gerado sem baixar modelo
// Local: roda integração completa com embeddings
const isCI = Boolean(process.env.CI || process.env.DOCKER)
const dataJsonPath = resolve('.astro-related-content/data.json')
const hasPrebuiltData = existsSync(dataJsonPath)

const relatedContentIntegrations = isCI && hasPrebuiltData
  ? [] // Virtual module vem do Vite plugin abaixo
  : [
      astroRelatedContent({
        collections: ['blog'],
        generation: { limit: 4 },
        embeddings: {
          model: 'onnx-community/embeddinggemma-300m-ONNX',
          dtype: 'fp32',
          pooling: 'mean',
          batchSize: 1,
        },
      }),
    ]

// Plugin Vite leve para CI — serve virtual module do data.json commitado
function relatedContentVitePlugin() {
  const VIRTUAL_ID = 'virtual:astro-related-content'
  const RESOLVED_ID = '\0' + VIRTUAL_ID
  return {
    name: 'related-content-prebuilt',
    resolveId(id: string) {
      if (id === VIRTUAL_ID) return RESOLVED_ID
    },
    load(id: string) {
      if (id !== RESOLVED_ID) return
      const absPath = resolve('.astro-related-content/data.json')
      return `
import { getCollection } from "astro:content";
import relatedContentData from ${JSON.stringify(`/@fs/${absPath}`)};

export function getRelatedContentMatches(collection, id) {
  const collectionData = relatedContentData[collection];
  if (!collectionData) return [];
  const matches = collectionData[id];
  return Array.isArray(matches) ? matches.map((m) => ({ ...m })) : [];
}

export function getRelatedContentIds(collection, id) {
  return getRelatedContentMatches(collection, id).map((m) => m.id);
}

export async function getRelatedContent(collection, id) {
  const matches = getRelatedContentMatches(collection, id);
  const entries = await getCollection(collection);
  const entryById = new Map(
    entries.flatMap((entry) => {
      const normalizedId = String(entry.id).replace(/\\.(md|mdx)$/, "");
      return normalizedId === entry.id
        ? [[entry.id, entry]]
        : [[entry.id, entry], [normalizedId, entry]];
    }),
  );
  return matches.flatMap((match) => {
    const entry = entryById.get(match.id);
    return entry ? [{ entry, score: match.score }] : [];
  });
}
`
    },
  }
}

export default defineConfig({
  integrations: [
    // ... outras integrações
    ...relatedContentIntegrations,
  ],
  vite: {
    plugins: [
      // ... outros plugins
      ...(isCI && hasPrebuiltData ? [relatedContentVitePlugin()] : []),
    ],
  },
})

3. Dockerfile com ENV CI=true

FROM node:22-slim AS build
WORKDIR /app
ENV CI=true
ENV NODE_OPTIONS="--max-old-space-size=512"
COPY package*.json .npmrc ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

O ENV CI=true ativa o Vite plugin leve. Nenhum modelo é baixado. Build completa em ~10-30s.


Workflow Operacional

Ação Onde O que acontece
Novo post Local astro build → regenera embedding só do post novo → commit cache → push
Editar post Local astro build → recalcula embedding do editado → commit cache → push
Deploy Coolify Usa data.json pré-commitado → build rápido (~30s)
Primeiro setup Local Download modelo (~300MB) + embeddings de todos os posts (1-20min)

Tempos Reais (32 posts, EmbeddingGemma 300m)

Etapa Tempo
Primeira geração (download modelo + 32 embeddings) ~22 min
Build subsequente local (cache hit) ~10 s
Build CI com data.json pré-gerado ~10 s
Build CI sem cache (modelo baixando) ~22+ min

Checklist de Implementação

  • npm install @philnash/astro-related-content
  • Configurar integração no astro.config.ts (com lógica CI/local)
  • Criar componente RelatedPosts.astro (com workaround Astro v7)
  • Integrar componente no template de post ([...id].astro ou similar)
  • Rodar astro build localmente para gerar embeddings
  • Verificar .astro-related-content/ NÃO está no .gitignore
  • Commitar data.json + vectors.json
  • Setar ENV CI=true no Dockerfile
  • Deploy e validar no Coolify

Troubleshooting

Build no Coolify demora 20+ minutos

Causa: CI=true não setado no Dockerfile, ou .astro-related-content/data.json não commitado. A integração completa está rodando e baixando o modelo. Fix: Setar ENV CI=true no Dockerfile E commitar a pasta .astro-related-content/.

Causa: Bug de ID entre integração e Astro v7. O getRelatedContent() do virtual module não faz match porque IDs diferem. Fix: Usar getRelatedContentMatches() + lookup manual com normalizedId = match.id.replace(/\/index$/, '').

Embeddings ruins para português

Causa: Usando modelo default (all-MiniLM-L6-v2) que é English-only. Fix: Usar onnx-community/embeddinggemma-300m-ONNX (multilingual).

Cache invalidado a cada build

Causa: A metadata do provider (model, dtype, pooling, version) mudou entre builds. A integração invalida todo o cache quando metadata difere. Fix: Não alterar configuração de embeddings após gerar o cache. Se precisar mudar modelo, regenerar tudo localmente e re-commitar.

Cannot find module '@huggingface/transformers' em CI

Causa: O pacote @huggingface/transformers é dependência transitiva só necessária quando a integração completa roda. Em CI com o Vite plugin, não é necessário. Fix: Se usar o dual-mode (CI plugin), isso não acontece. Se rodar integração em CI, garantir que npm ci instala todas deps.