Использование OpenAI для увеличения времени, затрачиваемого на ваш блог
Использование OpenAI для увеличения времени, затрачиваемого на ваш блог
Сегодня вы узнаете, как создать систему рекомендаций для такой платформы, как Medium. Я хотел поделиться тем, чему научился при создании популярного плагина Obsidian AVA, который рекомендует ваши заметки.
Весь исходный код проекта, который мы собираемся создать, доступен здесь. Здесь вы можете попробовать интерактивную версию.
Вы также можете развернуть конечную версию на Vercel прямо сейчас.
Общая картина
В пунктах ниже описан процесс того, как мы будем реализовывать данный проект:
- Нам нужно будет преобразовать наши записи в блоге в числовое представление, называемое “вложение”, и сохранить его в базе данных.
- Мы получим наиболее похожие записи в блоге на ту, что читается в данный момент
- Мы разместим эти похожие записи в блоге сбоку
Эти 2 строки будут отвечать за реализацию ядра рекомендательной системы:
embedbase.dataset('recsys').batchAdd('<my blog posts>')
embedbase.dataset('recsys').search('<my blog post content>')
Погружение в процесс внедрения
Напоминаем, что мы создадим механизм рекомендаций для вашей издательской платформы, используя NextJS и tailwindcss. Мы также будем использовать Embedbase в качестве базы данных.
Другие используемые библиотеки:
gray-matter
для синтаксического анализа Markdown front-matter (используется для хранения метаданных документа, полезен при получении рекомендованных результатов)swr
для лёгкого извлечения данных из конечных точек NextJS APIheroicons
для значков- И, наконец,
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
, которые предоставляют дополнительную информацию о файле.
Дисклеймер: Записи в блоге, включенные в только что загруженный вами репозиторий, были сгенерированы 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
Это то, что у вас должно получиться:
Профессиональный совет: теперь вы можете визуализировать свои данные в Embedbase dashboard (с открытым исходным кодом), здесь:
Или задайте вопросы об этом, используя ChatGPT здесь (обязательно отметьте набор данных “recsys”):
Реализация функции рекомендаций
Теперь мы хотим иметь возможность получать рекомендации для наших записей в блоге, поэтому мы добавим конечную точку 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)
Вы должны увидеть это:
Конечно, не стесняйтесь изменять стиль, исходя из собственных вкусов.
Заключительные мысли
Подводя итог, мы:
- Создали несколько постов в блоге
- Подготовили и сохранили наши записи в Embedbase
- Создали механизм рекомендаций с помощью нескольких строк кода
- Создали интерфейс для отображения записей в блоге и их рекомендаций
Спасибо, что прочитали эту статью! Вы можете найти полный код в ветке “complete” репозитория.
Ресурсы
- Ознакомьтесь с этим действием GitHub, которое автоматически индексирует ваши записи в блоге при каждом нажатии
- Примеры и другие ресурсы по документации Embeddbase на базе GPT-4