Obsidian-Copilot: An Assistant for Writing & Reflecting
Юджин Ян рассказывает о прототипе Obsidian-Copilot — помощника для письма и рефлексии в Obsidian, построенного на retrieval-augmented generation. Документы из заметок нарезаются на чанки по верхнеуровневым буллетам, индексируются в OpenSearch (BM25) и через семантический поиск на эмбеддингах e5-small-v2 (размерность 384, выбранной по MTEB Leaderboard). Сервис поиска реализован на FastAPI, запускается через docker-compose, а интеграция с Obsidian сделана через TypeScript-плагин. По заголовку раздела копайлот помогает черновать абзацы, а на основе дневника — рефлексировать о прошедшей неделе и планировать следующую. В качестве LLM используется gpt-3.5-turbo, но автор хочет попробовать claude-1.3-100k для подачи целых документов в контекст, а также добавить веб-поиск для актуализации устаревших заметок.
Obsidian-Copilot: помощник для письма и рефлексии
[ llm engineering 🛠 ] · чтение 6 мин
Как мог бы выглядеть копайлот для письма и размышлений? Чтобы попробовать ответить на этот вопрос, я собрал прототип: Obsidian-Copilot. По заголовку раздела он помогает набросать несколько абзацев с помощью retrieval-augmented generation. А если вы ведёте ежедневный дневник, он поможет отрефлексировать прошедшую неделю и спланировать следующую.
Obsidian Copilot: помощь в написании черновиков и рефлексии над неделей
Вот короткое 2-минутное демо. Код доступен в obsidian-copilot.
Как это работает?
Мы начинаем с разбиения документов на чанки. Разумный дефолт — разбивать документы по длине в токенах, обычно 1500–3000 токенов на чанк. Однако я обнаружил, что это работало не очень хорошо. Лучший подход — разбивать по абзацам (например, по \n\n).
Поскольку мои заметки в основном в виде буллетов, я режу по верхнеуровневым буллетам: каждый чанк состоит из одного верхнеуровневого буллета и его суб-буллетов. Обычно на один верхнеуровневый буллет приходится 5–10 суб-буллетов, и каждый чанк по длине получается похожим на абзац.
chunks = defaultdict() current_chunk = [] chunk_idx = 0 current_header = None for line in lines: if '##' in line: # Chunk header = Section header current_header = line if line.startswith('- '): # Top-level bullet if current_chunk: # If chunks accumulated, add it to chunks if len(current_chunk) >= min_chunk_lines: chunks[chunk_idx] = current_chunk chunk_idx += 1 current_chunk = [] # Reset current chunk if current_header: current_chunk.append(current_header) current_chunk.append(line)
Дальше мы строим индекс OpenSearch и семантический индекс по этим чанкам. В предыдущем эксперименте я обнаружил, что одного только retrieval на эмбеддингах может быть недостаточно, поэтому в этом прототипе добавил классический поиск (т.е. BM25 через OpenSearch).
Для OpenSearch мы начинаем с настройки фильтров и полей. Мы включаем фильтры, такие как удаление HTML, удаление притяжательных форм (т.е. завершающего 's у слов), удаление стоп-слов и базовый стемминг. Эти фильтры применяются и к документам (при индексировании), и к запросам. Мы также указываем поля, которые хотим проиндексировать, и их типы. Типы важны, потому что фильтры применяются к text-полям (например, заголовок, чанк), но не к keyword-полям (например, путь, тип документа). К путям файлов мы препроцессинг не применяем, чтобы оставить их как есть.
'mappings': { 'properties': { 'title': {'type': 'text', 'analyzer': 'english_custom'}, 'type': {'type': 'keyword'}, 'path': {'type': 'keyword'}, 'chunk_header': {'type': 'text', 'analyzer': 'english_custom'}, 'chunk': {'type': 'text', 'analyzer': 'english_custom'}, } }
При запросах мы применяем бусты, чтобы некоторые поля сильнее влияли на оценку релевантности. В этом прототипе я произвольно забустил заголовки в 5 раз, а заголовки чанков (т.е. верхнеуровневые буллеты) — в 2 раза. Retrieval можно улучшить, подбирая эти бусты, а также другие фичи.
Для семантического поиска начнём с выбора embedding-модели. Я посмотрел на Massive Text Embedding Benchmark Leaderboard, отсортировал по убыванию retrieval-скора и выбрал модель с хорошим балансом между размерностью эмбеддинга и качеством.
Так я пришёл к e5-small-v2. Сейчас она занимает достойное 7-е место, прямо под text-embedding-ada-002. Что впечатляет — размер эмбеддинга 384, что заметно меньше, чем у большинства моделей (768–1536). И хотя она поддерживает максимальную длину последовательности всего 512, этого достаточно при моих коротких чанках. (Подробнее в статье Text Embeddings by Weakly-Supervised Contrastive Pre-training.) После эмбеддинга документов мы сохраняем их в numpy-массиве.
Во время запроса мы токенизируем и эмбеддим запрос, делаем скалярное произведение с массивом эмбеддингов документов и берём топ n результатов (в данном случае 10).
def query_semantic(query, tokenizer, model, doc_embeddings_array, n_results=10): query_tokenized = tokenizer(f'query: {query}', max_length=512, padding=False, truncation=True, return_tensors='pt') outputs = model(**query_tokenized) query_embedding = average_pool(outputs.last_hidden_state, query_tokenized['attention_mask']) query_embedding = F.normalize(query_embedding, p=2, dim=1).detach().numpy() cos_sims = np.dot(doc_embeddings_array, query_embedding.T) cos_sims = cos_sims.flatten() top_indices = np.argsort(cos_sims)[-n_results:][::-1] return top_indices
Если вы думаете об использовании моделей e5, не забудьте добавить нужные префиксы при препроцессинге. Документы нужно префиксировать «passage: », а запросы — «query: ».
Сервис retrieval — это приложение на FastAPI. По входному запросу он выполняет и BM25, и семантический поиск, дедуплицирует результаты и возвращает текст документов и связанный с ними заголовок. Последний используется для того, чтобы связать исходные документы при генерации черновика.
Чтобы запустить узел OpenSearch и сервер семантического поиска + FastAPI, мы используем простой docker-compose файл. Они работают в своих контейнерах, объединённых общей сетью. Для удобства мы также описали общие команды в Makefile.
Наконец, мы интегрируемся с Obsidian через плагин на TypeScript. obsidian-plugin-sample позволил легко начать, и я добавил функции для отображения найденных документов в новой вкладке, обращения к API и стриминга вывода. (Я новичок в TypeScript, так что буду рад фидбеку!)
К чему ещё это можно применить?
Хотя этот прототип использует локальные заметки и записи в дневнике, нетрудно представить, как копайлот достаёт информацию из других документов (онлайн). Например, командных документов вроде product requirements и technical design docs, внутренних вики и даже кода. Думаю, именно над этим сейчас работают Microsoft, Atlassian и Notion.
Это распространяется и за пределы личной продуктивности. В моей области рекомендаций и поиска исследователи и практики увлечены идеей наслоения LLM-генерации поверх существующих систем и продуктов для улучшения customer experience. (Думаю, к концу года увидим что-то из этого в проде.)
Идеи для улучшения
Одна идея — попробовать LLM с большими контекстами, которые позволят скармливать целые документы вместо чанков. (Это может помочь с retrieval recall, но переносит больше нагрузки на LLM по выделению релевантного контекста для генерации.) Сейчас я использую gpt-3.5-turbo, который хорошо балансирует скорость и стоимость. Тем не менее, мне интересно попробовать claude-1.3-100k и подавать целые документы в качестве контекста.
Другая идея — дополнить retrieval веб- или внутренним поиском, когда это нужно. Например, когда документы и заметки устаревают (скажем, по timestamp последнего обновления), можно искать в вебе или внутренних документах более свежую информацию.
• • •
Вот GitHub-репозиторий, если хотите попробовать. Начните с клонирования репозитория и обновления пути к вашему obsidian-vault и кэшу huggingface hub. Последнее избавит от необходимости скачивать токенизатор и модель каждый раз, когда вы запускаете контейнеры.
git clone https://github.com/eugeneyan/obsidian-copilot.git # Open Makefile and update the following paths export OBSIDIAN_PATH = /Users/eugene/obsidian-vault/ export TRANSFORMER_CACHE = /Users/eugene/.cache/huggingface/hub
Затем соберите образ и индексы перед запуском приложения retrieval.
# Build the docker image make build # Start the opensearch container and wait for it to start. # You should see something like this: [c6587bf83572] Node 'c6587bf83572' initialized make opensearch # In ANOTHER terminal, build your artifacts (this can take a while) make build-artifacts # Start the app. You should see this: Uvicorn running on http://0.0.0.0:8000 make run
Наконец, установите плагин copilot, включите его в настройках community-плагинов и обновите API-ключ. Если Obsidian был открыт до установки, придётся его перезапустить.
make install-plugin
Если вы попробовали — буду рад услышать, как всё прошло, особенно где это работало плохо и как это можно улучшить. Или если вы работали с retrieval-augmented generation, мне очень интересен ваш опыт!
Если этот материал был вам полезен, цитируйте его так:
Yan, Ziyou. (Jun 2023). Obsidian-Copilot: An Assistant for Writing & Reflecting. eugeneyan.com. https://eugeneyan.com/writing/obsidian-copilot/.
или
@article{yan2023copilot, title = {Obsidian-Copilot: An Assistant for Writing & Reflecting}, author = {Yan, Ziyou}, journal = {eugeneyan.com}, year = {2023}, month = {Jun}, url = {https://eugeneyan.com/writing/obsidian-copilot/} }
Присоединяйтесь к 11 800+ читателей, получающих обновления о machine learning, RecSys, LLM и инженерии.