В этой статье мы кратко рассмотрим технологию, которая лежит в основе ChatGPT — эмбеддинги, и напишем простой интеллектуальный поиск по кодовой базе проекта.

Эмбеддинг (от англ. embedding) — это процесс преобразования слов или текста в набор чисел – числовой вектор. Векторы можно сравнивать между собой, чтобы определить насколько два текста или слова похожи по смыслу.

К примеру, возьмем два числовых вектора (эмбеддинга) слов «отдать» и «подарить». Слова разные, но смысл схож, т.е. они взаимосвязаны, и результатом обоих будет передача чего-то кому-то. 

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

Нам понадобится аккаунт Open AI и токен. Если у вас еще нет аккаунта, то можете зарегистрироваться на официальном сайте Open AI. После регистрации и подтверждения аккаунта пройдите в разделе профиля API Keys и сгенерируйте API токен.

На старте дают $18 — мне этого хватило, чтобы сделать пример для этой статьи (ниже) и провести дальнейшее тестирование сервиса.

Выберите какой-нибудь проект на TypeScript в качестве кодовой базы. Рекомендую взять небольшой, чтобы не томить себя в ожиданиях генерации векторов. Или можете воспользоваться уже готовым. Еще нам нужен Python 3+ версии и библиотека от Open AI. Не пугайтесь, если не знаете какой-то язык — примеры будут простыми и не требуют глубокого понимания TypeScript и Python.

Приступим. Для начала напишем код для извлечения различных фрагментов кода из проекта, например, функции. TypeScript предоставляет удобный API компилятор для работы с AST деревом, что упрощает задачу. Установим csv-stringify библиотеку для генерации CSV:

$ npm install csv-stringify

Пишем извлечение информации из кода:

const path = require('path');
const ts = require('typescript');
const csv = require('csv-stringify/sync');
 
const cwd = process.cwd();
const configJSON = require(path.join(cwd, 'tsconfig.json'));
const config = ts.parseJsonConfigFileContent(configJSON, ts.sys, cwd);
const program = ts.createProgram(
    config.fileNames, 
    config.options, 
    ts.createCompilerHost(config.options)
);
const checker = program.getTypeChecker();

const rows = [];

const addRow = (fileName, name, code, docs = '') => rows.push({
    file_name: path.relative(cwd, fileName),
    name,
    code,
    docs
});

function addFunction(fileName, node) {
    const symbol = checker.getSymbolAtLocation(node.name);
    if (symbol) {
        const name = symbol.getName();
        const docs = getDocs(symbol);
        const code = node.getText();
        addRow(fileName, name, code, docs);
    }
}

function addClass(fileName, node) {
    const symbol = checker.getSymbolAtLocation(node.name);
    if (symbol) {
        const name = symbol.getName();
        const docs = getDocs(symbol);
        const code = `class ${name} {}`;
        addRow(fileName, name, code, docs);
        node.members.forEach(m => addClassMember(fileName, name, m));
    }
}

function addClassMember(fileName, className, node) {
    const symbol = checker.getSymbolAtLocation(node.name);
    if (symbol) {
        const name = className + ':' + symbol.getName();
        const docs = getDocs(symbol);
        const code = node.getText();
        addRow(fileName, name, code, docs);
    }
}

function addInterface(fileName, node) {
    const symbol = checker.getSymbolAtLocation(node.name);
    if (symbol) {
        const name = symbol.getName();
        const docs = getDocs(symbol);
        const code = `interface ${name} {}`;
        addRow(fileName, name, code, docs);
        node.members.forEach(m => addInterfaceMember(fileName, name, m));
    }
}

function addInterfaceMember(fileName, interfaceName, node) {
    if (!ts.isPropertySignature(node) || !ts.isMethodSignature(node)) {
        return;
    }
    const symbol = checker.getSymbolAtLocation(node.name);
    if (symbol) {
        const name = interfaceName + ':' + symbol.getName();
        const docs = getDocs(symbol);
        const code = node.getText();
        addRow(fileName, name, code, docs);
    }
}

function getDocs(symbol) {
    return ts.displayPartsToString(symbol.getDocumentationComment(checker));
}

for (const fileName of config.fileNames) {
    const sourceFile = program.getSourceFile(fileName);
    const visitNode = node => {
        if (ts.isFunctionDeclaration(node)) {
            addFunction(fileName, node);
        } else if (ts.isClassDeclaration(node)) {
            addClass(fileName, node);
        } else if (ts.isInterfaceDeclaration(node)) {
            addInterface(fileName, node);
        }
        ts.forEachChild(node, visitNode);
    };
    ts.forEachChild(sourceFile, visitNode);
}

for (const row of rows) {
    row.combined = '';
    if (row.docs) {
        row.combined += `Code documentation: ${row.docs}; `;
    }
    row.combined += `Code: ${row.code}; Name: ${row.name};`;
}

const output = csv.stringify(rows, {
    header: true
});

console.log(output);

Скрипт собирает все нужные нам фрагменты и выдает CSV таблицу в консоль. CSV файл состоит из колонок file_name, name, code, docs, combined.

  • file_name - здесь будет содержаться путь до файла в проекте,

  • name - название фрагмента, к примеру, «имя функции»,

  • code - код сущности,

  • docs - описание из комментариев к фрагменту,

  • combined - это сложение контента code и docs колонок — мы будем использовать эту колонку для генерации векторов.

Запускать его не нужно.

Переходим к Python.

Установим библиотеку от Open AI и утилиты для работы с эмбеддингами:

$ pip install openai[embeddings]

Создаем файл create_search_db.py со следующим кодом:

from io import StringIO
from subprocess import PIPE, run
from pandas import read_csv
from openai.embeddings_utils import get_embedding as _get_embedding
from tenacity import wait_random_exponential, stop_after_attempt

get_embedding = _get_embedding.retry_with(wait=wait_random_exponential(min=1, max=60), stop=stop_after_attempt(10))

if __name__ == '__main__':
	# 1
	result = run(['node', 'code-to-csv.js'], stdout=PIPE, stderr=PIPE, universal_newlines=True)
	if result.returncode != 0:
	    raise RuntimeError(result.stderr)
	# 2
	db = read_csv(StringIO(result.stdout))
	# 3
	db['embedding'] = db['combined'].apply(lambda x: get_embedding(x, engine='text-embedding-ada-002'))
	# 4
	db.to_csv("search_db.csv", index=False)

Скрипт запускается code-to-csv.js(1), загружается результат в датафрейм(2) и генерирует векторы для контента в колонке combined(3). Векторы записываются в embedding колонку. Итоговая таблица со всем нужным для поиска сохраняется в файл search_db.csv(4).

Для работы скрипта понадобится API токен. Библиотека openai автоматически может брать токен из переменных окружения, поэтому мы напишем удобный скрипт, чтобы не записывать токен в окружение вручную:

export OPENAI_API_KEY=ВашТокен

Сохранить его куда-нибудь, к примеру в env.sh, и запустим:

$ source env.sh

Все готово для генерации базы поиска.

Запускаем скрипт create_search_db.py и ждем пока появится CSV файл с базой. Это может занять пару минут. После можно приступать к написанию поисковика.

Создаем новый файл search.py и пишем следующее:

import sys
import numpy as np
from pandas import read_csv
from openai.embeddings_utils import cosine_similarity, get_embedding as _get_embedding
from tenacity import  stop_after_attempt, wait_random_exponential

get_embedding = _get_embedding.retry_with(wait=wait_random_exponential(min=1, max=60), stop=stop_after_attempt(10))

def search(db, query):
	# 4
    query_embedding = get_embedding(query, engine='text-embedding-ada-002')
	# 5
    db['similarities'] = db.embedding.apply(lambda x: cosine_similarity(x, query_embedding))
    # 6
	db.sort_values('similarities', ascending=False, inplace=True)
    result = db.head(3)
    text = ""
    for row in result.itertuples(index=False):
        score=round(row.similarities, 3)
        if type(row.docs) == str:
            text += '/**\n * {docs}\n */\n'.format(docs='\n * '.join(row.docs.split('\n')))
        text += '{code}\n\n'.format(code='\n'.join(row.code.split('\n')[:7]))
        text += '[score={score}] {file_name}:{name}\n'.format(score=score, file_name=row.file_name, name=row.name)
        text += '-' * 70 + '\n\n'
    return text

if __name__ == '__main__':
	# 1
    db = read_csv('search_db.csv')
	# 2
    db['embedding'] = db.embedding.apply(eval).apply(np.array)
    query = sys.argv[1]
    print('')
	# 3
    print(search(db, query))

Разберем работу скрипта. Данные из search_db.csv загружаются в датафрейм(1), в объектно-ориентированное представление таблицы. Потом строки с векторами из таблицы конвертируются в массивы с числами(2), чтобы с ними можно было работать. В конце запускается функция поиска по базе со строкой запроса(3).

Функция поиска генерирует вектор для запроса(4), сравнивает этот вектор с каждым вектором из базы и сохраняет степень схожести в similarities колонку(5).

Степень схожести определяется числом от 0 до 1, где 1 означает максимальная подходящий вариант. Данные в таблице сортируются по similarities(6).

В заключении извлекаются первые три строки из базы и выводятся в консоль.

Поисковик готов, можно протестировать.

Для теста запускаем команду с запросом:

Пробуем ввести запрос на другом языке:

Как вы видите, поиск осуществляется с учетом значения слов в запросе, а не просто по ключевым словам.

Инструмент не ограничен только этим случаем и одним проектом. Можно организовать более масштабный поиск по всем проектам сразу. Это полезно, если вы каждый год разрабатываете по нескольку схожих в функционале приложений и хотели бы быстро находить готовые решения. Или у вас много документации, а поиск по ключевым словам не всегда эффективен. Все зависит от задач и сферы применения.

Благодарю за внимание!

Полезные ссылки:

Комментарии (3)


  1. turboslon
    00.00.0000 00:00

    Статья об очень хорошем и полезном. Семантический поиск в 50 строк кода - это здорово. Я прям жду, чтобы этот инструмент пришел в мою IDE. Но было бы здорово, если бы Вы немного рассказали, как эта магия работает. Например:

    Для обработки текста нейросети не работают напрямую с текстовой информацией. Вместо этого они преобразуют текст сначала в набор токенов (это могут быть кусочки текста от буквы до целой фразы, хотя сейчас распространен подход, в котором берут несколько букв, часто встречающихся вместе), а потом для каждого токена выдумывают случайный вектор в многомерном пространстве. Кстати, посмотреть, как фраза разваливается на токены, используемые в GPT моделях от OpenAI можно вот тут.

    Соответствие токенов и этих случайных векторов нам бы лучше сохранить где-то (еще пригодится, чтобы понять, что там нейросеть нам пытается сказать, и сделать обратное преобразование из векторов в токены, а потом склеить эти токены, чтобы получился текст), поэтому все токены нумеруют (обычно в порядке убывания частоты их появления)

    Случайные вектора - это как раз эмбеддинги и есть, но до того, как нейросеть обучится, они нам особой пользы не принесут. И магия нейросетей состоит в том, что после чтения всего интернета много раз, нейросеть растаскивает эти вектора в пространстве таким образом, что похожие по смыслу слова -> токены -> вектора оказываются рядом. И вот такие эмбеддинги уже полезные: по ним можно предположить, насколько два текста (текст -> токены -> эмбеддинги) схожи путем сравнения просто кучки чисел. И ровно их вы используете для поиска по кодовой базе.

    Еще я бы рассказал пару слов об операции сравнения, но «поля этой книги слишком узки для неё».

    за статью - еще раз спасибо!


    1. hokid Автор
      00.00.0000 00:00

      Спасибо за отзыв! Есть уже плагин VS Code. А вот для IDE(имею ввиду только от Jet Brains) пока не видел ничего подобного.


  1. yitoka
    00.00.0000 00:00

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