Использование OpenAI для увеличения времени, затрачиваемого на ваш блог

Использование OpenAI для увеличения времени, затрачиваемого на ваш блог

Сегодня вы узнаете, как создать систему рекомендаций для такой платформы, как Medium. Я хотел поделиться тем, чему научился при создании популярного плагина Obsidian AVA, который рекомендует ваши заметки.

Весь исходный код проекта, который мы собираемся создать, доступен здесь. Здесь вы можете попробовать интерактивную версию.

Вы также можете развернуть конечную версию на Vercel прямо сейчас.

Общая картина

В пунктах ниже описан процесс того, как мы будем реализовывать данный проект:

  1. Нам нужно будет преобразовать наши записи в блоге в числовое представление, называемое “вложение”, и сохранить его в базе данных.
  2. Мы получим наиболее похожие записи в блоге на ту, что читается в данный момент
  3. Мы разместим эти похожие записи в блоге сбоку

Эти 2 строки будут отвечать за реализацию ядра рекомендательной системы:

  1. embedbase.dataset('recsys').batchAdd('<my blog posts>')
  2. embedbase.dataset('recsys').search('<my blog post content>')

Погружение в процесс внедрения

Напоминаем, что мы создадим механизм рекомендаций для вашей издательской платформы, используя NextJS и tailwindcss. Мы также будем использовать Embedbase в качестве базы данных.

Другие используемые библиотеки:

  • gray-matter для синтаксического анализа Markdown front-matter (используется для хранения метаданных документа, полезен при получении рекомендованных результатов)
  • swr для лёгкого извлечения данных из конечных точек NextJS API
  • heroicons для значков
  • И, наконец, react-markdown.

Давайте начинать!

Вот что вам понадобится для этого урока:

  • Embedbase api key, база данных, которая позволяет вам находить наиболее похожие результаты. Не все базы данных подходят для такого рода работы. Сегодня мы будем использовать Embedbase, которая позволяет вам реализовать это. Embeddbase поможет вам находить семантическое сходство между поисковым запросом и сохранённым контентом.

Теперь вы можете клонировать репозиторий следующим образом:

git clone https://github.com/different-ai/embedbase-recommendation-engine-example

Откройте его с помощью вашей любимой IDE и установите зависимости:

npm i

Теперь вы должны быть в состоянии запустить проект:

npm run dev

Запишите только что созданный ключ API Embedbase в .env.local:

EMBEDBASE_API_KEY="<YOUR KEY>"

Создание нескольких записей в блоге

Как вы можете видеть, папка _posts содержит несколько записей в блоге с некоторыми внешними метаданными yaml, которые предоставляют дополнительную информацию о файле.

Использование OpenAI для увеличения времени, затрачиваемого на ваш блог

Дисклеймер: Записи в блоге, включенные в только что загруженный вами репозиторий, были сгенерированы GPT-4.

Подготовка и хранение документов

Первый шаг требует, чтобы мы сохраняли наши записи в Embedbase.

Чтобы прочитать записи в блоге, которые мы только что написали, нам нужно будет реализовать небольшой фрагмент кода для анализа интерфейса Markdown и сохранения его в метаданных документа. Это улучшит работу с рекомендациями за счёт дополнительной информации.

Для этого мы будем использовать библиотеку под названием gray-matter, давайте вставим следующий код в lib/api.ts:

import fs from 'fs'
import { join } from 'path'
import matter from 'gray-matter'

// Get the absolute path to the posts directory
const postsDirectory = join(process.cwd(), '_posts')

export function getPostBySlug(slug: string, fields: string[] = []) {
 const realSlug = slug.replace(/\.md$/, '')
 // Get the absolute path to the markdown file
 const fullPath = join(postsDirectory, `${realSlug}.md`)
 // Read the markdown file as a string
 const fileContents = fs.readFileSync(fullPath, 'utf8')
 // Use gray-matter to parse the post metadata section
 const { data, content } = matter(fileContents)

 type Items = {
  [key: string]: string
 }

 const items: Items = {}

 // Store each field in the items object
 fields.forEach((field) => {
  if (field === 'slug') {
   items[field] = realSlug
  }
  if (field === 'content') {
   items[field] = content
  }
  if (typeof data[field] !== 'undefined') {
   items[field] = data[field]
  }
 })

 return items
}

Теперь мы можем написать скрипт, который будет хранить наши документы в Embedbase, создав файл sync.ts в папке scripts.

Вам понадобится библиотека glob и Embedbase SDK, embedbase-js, для составления списка файлов и взаимодействия с API.

В Embedbase концепция dataset представляет один из ваших источников данных, например, продукты, которые вы едите, ваш список покупок, отзывы клиентов или обзоры продуктов.

Когда вы добавляете данные, вам нужно указать dataset, а позже вы сможете запросить этот набор данных или несколько одновременно, чтобы получить рекомендации.

Хорошо, давайте, наконец, реализуем скрипт для отправки ваших данных в Embedbase, вставив следующий код в scripts/sync.ts:

import glob from "glob";
import { createClient, BatchAddDocument } from 'embedbase-js'
import { splitText } from 'embedbase-js/dist/main/split';
import { getPostBySlug } from "../lib/api";

try {
    // load the .env.local file to get the api key
    require("dotenv").config({ path: ".env.local" });
} catch (e) {
    console.log("No .env file found" + e);
}
// you can find the api key at https://app.embedbase.xyz
const apiKey = process.env.EMBEDBASE_API_KEY;
// this is using the hosted instance
const url = 'https://api.embedbase.xyz'
const embedbase = createClient(url, apiKey)

const batch = async (myList: any[], fn: (chunk: any[]) => Promise<any>) => {
    const batchSize = 100;
    // add to embedbase by batches of size 100
    return Promise.all(
        myList.reduce((acc: BatchAddDocument[][], chunk, i) => {
            if (i % batchSize === 0) {
                acc.push(myList.slice(i, i + batchSize));
            }
            return acc;
            // here we are using the batchAdd method to send the documents to embedbase
        }, []).map(fn)
    )
}

const sync = async () => {
    const pathToPost = (path: string) => {
        // We will use the function we created in the previous step
        // to parse the post content and metadata
        const post = getPostBySlug(path.split("/").slice(-1)[0], [
            'title',
            'date',
            'slug',
            'excerpt',
            'content'
        ])
        return {
            data: post.content,
            metadata: {
                path: post.slug,
                title: post.title,
                date: post.date,
                excerpt: post.excerpt,
            }
        }
    };
    // read all files under _posts/* with .md extension
    const documents = glob.sync("_posts/**/*.md").map(pathToPost);

    // using chunks is useful to send batches of documents to embedbase
    // this is useful when you send a lot of data
    const chunks = []
    documents.map((document) =>
        splitText(document.data, {}, async ({ chunk, start, end }) => chunks.push({
            data: chunk,
            metadata: document.metadata,
        }))
    )
    const datasetId = `recsys`

    console.log(`Syncing to ${datasetId} ${chunks.length} documents`);

    // add to embedbase by batches of size 100
    return batch(chunks, (chunk) => embedbase.dataset(datasetId).batchAdd(chunk))
        .then((e) => e.flat())
        .then((e) => console.log(`Synced ${e.length} documents to ${datasetId}`, e))
        .catch(console.error);
}

sync();

Отлично, вы можете запускать его прямо сейчас:

npx tsx ./scripts/sync.ts

Это то, что у вас должно получиться:

Использование OpenAI для увеличения времени, затрачиваемого на ваш блог

Профессиональный совет: теперь вы можете визуализировать свои данные в Embedbase dashboard (с открытым исходным кодом), здесь:

Использование OpenAI для увеличения времени, затрачиваемого на ваш блог

Или задайте вопросы об этом, используя ChatGPT здесь (обязательно отметьте набор данных “recsys”):

Использование OpenAI для увеличения времени, затрачиваемого на ваш блог

Реализация функции рекомендаций

Теперь мы хотим иметь возможность получать рекомендации для наших записей в блоге, поэтому мы добавим конечную точку API (если вы не знакомы с Next.js API pages, ознакомьтесь с этим) в pages/api/recommend.ts:

import { createClient } from "embedbase-js";

// Let's create an Embedbase client with our API key
const embedbase = createClient("https://api.embedbase.xyz", process.env.EMBEDBASE_API_KEY);

export default async function recommend (req, res) {
 const query = req.body.query;
 if (!query) {
  res.status(400).json({ error: "Missing query" });
  return;
 }

 const datasetId = "recsys";
 // in this case even if we call the function search, 
 // we actually get recommendations
 let results = await embedbase.dataset(datasetId).search(query, {
  // We want to get the first 4 results
  limit: 4,
 });
 res.status(200).json(results);
}

Создание интерфейса блога

Всё, что нам сейчас нужно сделать, это подключить всё это в удобном пользовательском интерфейсе. На этом наша задача подойдёт к концу!

Компоненты

Напоминаем, что для оформления мы используем tailwindcss, который позволяет вам быстро создавать современные веб-сайты, даже не покидая свой HTML:

// components/BlogSection.tsx
interface BlogPost {
  id: string
  title: string
  href: string
  date: string
  snippet: string
}


interface BlogSectionProps {
  posts: BlogPost[]
}

export default function Example({ posts }: BlogSectionProps) {
  return (
    <div className="bg-white py-24 sm:py-32">
      <div className="mx-auto max-w-7xl px-6 lg:px-8">
        <div className="mx-auto max-w-2xl">
          <h2 className="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl">From the blog</h2>
          <div className="mt-10 space-y-16 border-t border-gray-200 pt-10 sm:mt-16 sm:pt-16">
            {posts.length === 0 &&
              <div role="status" className="max-w-md p-4 space-y-4 border border-gray-200 divide-y divide-gray-200 rounded shadow animate-pulse dark:divide-gray-700 md:p-6 dark:border-gray-700">
                <div className="flex items-center justify-between">
                  <div>
                    <div className="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5"></div>
                    <div className="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700"></div>
                  </div>
                  <div className="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12"></div>
                </div>
                <div className="flex items-center justify-between pt-4">
                  <div>
                    <div className="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5"></div>
                    <div className="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700"></div>
                  </div>
                  <div className="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12"></div>
                </div>
                <div className="flex items-center justify-between pt-4">
                  <div>
                    <div className="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5"></div>
                    <div className="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700"></div>
                  </div>
                  <div className="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12"></div>
                </div>
                <div className="flex items-center justify-between pt-4">
                  <div>
                    <div className="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5"></div>
                    <div className="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700"></div>
                  </div>
                  <div className="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12"></div>
                </div>
                <div className="flex items-center justify-between pt-4">
                  <div>
                    <div className="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5"></div>
                    <div className="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700"></div>
                  </div>
                  <div className="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12"></div>
                </div>
                <span className="sr-only">Loading...</span>
              </div>
            }
            {posts.map((post) => (
              <article key={post.id} className="flex max-w-xl flex-col items-start justify-between">
                <div className="flex items-center gap-x-4 text-xs">
                  <time dateTime={post.date} className="text-gray-500">
                    {post.date}
                  </time>
                </div>
                <div className="group relative">
                  <h3 className="mt-3 text-lg font-semibold leading-6 text-gray-900 group-hover:text-gray-600">
                    <a href={post.href}>
                      <span className="absolute inset-0" />
                      {post.title}
                    </a>
                  </h3>
                  <p className="mt-5 line-clamp-3 text-sm leading-6 text-gray-600">{post.snippet}</p>
                </div>
              </article>
            ))}
          </div>
        </div>
      </div>
    </div>
  )
}
// components/ContentSection.tsx
import Markdown from './Markdown';

interface ContentSectionProps {
  title: string
  content: string
}


export default function ContentSection({ title, content }: ContentSectionProps) {

  return (
    <div className="bg-white px-6 py-32 lg:px-8 prose lg:prose-xl">
      <div className="mx-auto max-w-3xl text-base leading-7 text-gray-700">
        <h1 className="mt-2 text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl">{title}</h1>
        <Markdown>{content}</Markdown>
      </div>
    </div>
  )
}
// components/Markdown.tsx
import ReactMarkdown from "react-markdown";
import { PrismLight as SyntaxHighlighter } from "react-syntax-highlighter";
import tsx from "react-syntax-highlighter/dist/cjs/languages/prism/tsx";
import typescript from "react-syntax-highlighter/dist/cjs/languages/prism/typescript";
import scss from "react-syntax-highlighter/dist/cjs/languages/prism/scss";
import bash from "react-syntax-highlighter/dist/cjs/languages/prism/bash";
import markdown from "react-syntax-highlighter/dist/cjs/languages/prism/markdown";
import json from "react-syntax-highlighter/dist/cjs/languages/prism/json";

SyntaxHighlighter.registerLanguage("tsx", tsx);
SyntaxHighlighter.registerLanguage("typescript", typescript);
SyntaxHighlighter.registerLanguage("scss", scss);
SyntaxHighlighter.registerLanguage("bash", bash);
SyntaxHighlighter.registerLanguage("markdown", markdown);
SyntaxHighlighter.registerLanguage("json", json);

import rangeParser from "parse-numeric-range";
import { oneDark } from "react-syntax-highlighter/dist/cjs/styles/prism";

const Markdown = ({ children }) => {
  const syntaxTheme = oneDark;

  const MarkdownComponents: object = {
    code({ node, inline, className, ...props }) {
      const hasLang = /language-(\w+)/.exec(className || "");
      const hasMeta = node?.data?.meta;

      const applyHighlights: object = (applyHighlights: number) => {
        if (hasMeta) {
          const RE = /{([\d,-]+)}/;
          const metadata = node.data.meta?.replace(/\s/g, "");
          const strlineNumbers = RE?.test(metadata)
            ? RE?.exec(metadata)[1]
            : "0";
          const highlightLines = rangeParser(strlineNumbers);
          const highlight = highlightLines;
          const data: string = highlight.includes(applyHighlights)
            ? "highlight"
            : null;
          return { data };
        } else {
          return {};
        }
      };

      return hasLang ? (
        <SyntaxHighlighter
          style={syntaxTheme}
          language={hasLang[1]}
          PreTag="div"
          className="codeStyle"
          showLineNumbers={true}
          wrapLines={hasMeta}
          useInlineStyles={true}
          lineProps={applyHighlights}
        >
          {props.children}
        </SyntaxHighlighter>
      ) : (
        <code className={className} {...props} />
      );
    },
  };

  return (
    <ReactMarkdown components={MarkdownComponents}>{children}</ReactMarkdown>
  );
};

export default Markdown;
Страницы

Когда дело дойдёт до страниц, мы настроим pages/index.tsx для перенаправления на первую страницу записи в блоге:

// pages/index.tsx
import Head from 'next/head'
import { useRouter } from 'next/router'
import { useEffect } from 'react'


export default function Home() {
  const router = useRouter()
  useEffect(() => {
    router.push('/posts/understanding-machine-learning-embeddings')
  }, [router])
  return (
    <>
      <Head>
        <title>Embedbase recommendation engine</title>
        <meta name="description" content="Embedbase recommendation engine" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <link rel="icon" href="/favicon.ico" />
      </Head>
    </>
  )
}

И создадим страницу публикации, которая будет использовать компоненты, созданные нами ранее, в дополнение к рекомендуемой конечной точке API:

// pages/posts/[post].tsx
import useSWR, { Fetcher } from "swr";
import BlogSection from "../../components/BlogSection";
import ContentSection from "../../components/ContentSection";
import { ClientSearchData } from "embedbase-js";
import { getPostBySlug } from "../../lib/api";
export default function Index({ post }) {
    const fetcherSearch: Fetcher<ClientSearchData, string> = () => fetch('/api/recommend', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ query: post.content }),
    }).then((res) => res.json());
    const { data: similarities, error: errorSearch } =
        useSWR('/api/search', fetcherSearch);
    console.log("similarities", similarities);
    return (
        <main className='flex flex-col md:flex-row'>
            <div className='flex-1'>
                <ContentSection title={post.title} content={post.content} />
            </div>
            <aside className='md:w-1/3 md:ml-8'>
                <BlogSection posts={similarities
                    // @ts-ignore
                    ?.filter((result) => result.metadata.path !== post.slug)
                    .map((similarity) => ({
                        id: similarity.hash,
                        // @ts-ignore
                        title: similarity.metadata?.title,
                        // @ts-ignore
                        href: similarity.metadata?.path,
                        // @ts-ignore
                        date: similarity.metadata?.date.split('T')[0],
                        // @ts-ignore
                        snippet: similarity.metadata?.excerpt,
                    })) || []} />
            </aside>
        </main>
    )
}


export const getServerSideProps = async (ctx) => {
    const { post: postPath } = ctx.params
    const post = getPostBySlug(postPath, [
        'title',
        'date',
        'slug',
        'excerpt',
        'content'
    ])
    return {
        props: {
            post: post,
        },
    }
}

Перейдите в браузер, чтобы ознакомиться с результатами (http://localhost:3000)

Вы должны увидеть это:

Использование OpenAI для увеличения времени, затрачиваемого на ваш блог

Конечно, не стесняйтесь изменять стиль, исходя из собственных вкусов.

Заключительные мысли

Подводя итог, мы:

  • Создали несколько постов в блоге
  • Подготовили и сохранили наши записи в Embedbase
  • Создали механизм рекомендаций с помощью нескольких строк кода
  • Создали интерфейс для отображения записей в блоге и их рекомендаций

Спасибо, что прочитали эту статью! Вы можете найти полный код в ветке “complete” репозитория.

Ресурсы

+1
0
+1
1
+1
0
+1
0
+1
0

Ответить

Ваш адрес email не будет опубликован. Обязательные поля помечены *