Пишем умный поиск по коду с Open AI

В этой статье мы кратко рассмотрим технологию, которая лежит в основе 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).

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

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

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

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

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

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

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

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

0
2 комментария
Дмитрий

Очень любопытно. Как раз на днях делал полнотексттвый поиск по базе sqlite3. Было бы интересно и это попробовать.
Я только изучаю python. Не понял зачем здесь нужен Type Script?

Ответить
Развернуть ветку
Кирилл Хорошилов
Автор

Просто для примера

Ответить
Развернуть ветку
-1 комментариев
Раскрывать всегда