newsmode
search
Меню
arrow_back Назад

Obsidian-Copilot: An Assistant for Writing & Reflecting

auto_awesomeКраткое саммари

Юджин Ян рассказывает о прототипе 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: An Assistant for Writing & Reflecting

Obsidian-Copilot: помощник для письма и рефлексии

[ llm engineering 🛠 ] · 6 min read

[ llm engineering 🛠 ] · чтение 6 мин

What would a copilot for writing and thinking look like? To try answering this question, I built a prototype: Obsidian-Copilot. Given a section header, it helps draft a few paragraphs via retrieval-augmented generation. Also, if you write a daily journal, it can help you reflect on the past week and plan for the week ahead.

Как мог бы выглядеть копайлот для письма и размышлений? Чтобы попробовать ответить на этот вопрос, я собрал прототип: Obsidian-Copilot. По заголовку раздела он помогает набросать несколько абзацев с помощью retrieval-augmented generation. А если вы ведёте ежедневный дневник, он поможет отрефлексировать прошедшую неделю и спланировать следующую.

Obsidian Copilot: Helping to write drafts and reflect on the week

Obsidian Copilot: помощь в написании черновиков и рефлексии над неделей

Here’s a short 2-minute demo. The code is available at obsidian-copilot.

Вот короткое 2-минутное демо. Код доступен в obsidian-copilot.

How does it work?

Как это работает?

We start by parsing documents into chunks. A sensible default is to chunk documents by token length, typically 1,500 to 3,000 tokens per chunk. However, I found that this didn’t work very well. A better approach might be to chunk by paragraphs (e.g., split on \n\n).

Мы начинаем с разбиения документов на чанки. Разумный дефолт — разбивать документы по длине в токенах, обычно 1500–3000 токенов на чанк. Однако я обнаружил, что это работало не очень хорошо. Лучший подход — разбивать по абзацам (например, по \n\n).

Given that my notes are mostly in bullet form, I chunk by top-level bullets: Each chunk is made up of a single top-level bullet and its sub-bullets. There are usually 5 to 10 sub-bullets per top-level bullet making each chunk similar in length to a paragraph.

Поскольку мои заметки в основном в виде буллетов, я режу по верхнеуровневым буллетам: каждый чанк состоит из одного верхнеуровневого буллета и его суб-буллетов. Обычно на один верхнеуровневый буллет приходится 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)

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)

Next, we build an OpenSearch index and a semantic index on these chunks. In a previous experiment, I found that embedding-based retrieval alone might be insufficient and thus added classical search (i.e., BM25 via OpenSearch) in this prototype.

Дальше мы строим индекс OpenSearch и семантический индекс по этим чанкам. В предыдущем эксперименте я обнаружил, что одного только retrieval на эмбеддингах может быть недостаточно, поэтому в этом прототипе добавил классический поиск (т.е. BM25 через OpenSearch).

For OpenSearch, we start by configuring filters and fields. We include filters such as stripping HTML, removing possessives (i.e., the trailing ‘s in words), removing stopwords, and basic stemming. These filters are applied on both documents (during indexing) and queries. We also specify the fields we want to index and their respective types. Types matter because filters are applied on text fields (e.g., title, chunk) but not on keyword fields (e.g., path, document type). We don’t apply preprocessing on file paths to keep them as they are.

Для 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'}, } }

'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'}, } }

When querying, we apply boosts to make some fields count more towards the relevance score. In this prototype, I arbitrarily boosted titles by 5x and chunk headers (i.e., top-level bullets) by 2x. Retrieval can be improved by tweaking these boosts as well as other features.

При запросах мы применяем бусты, чтобы некоторые поля сильнее влияли на оценку релевантности. В этом прототипе я произвольно забустил заголовки в 5 раз, а заголовки чанков (т.е. верхнеуровневые буллеты) — в 2 раза. Retrieval можно улучшить, подбирая эти бусты, а также другие фичи.

For semantic search, we start by picking an embedding model. I referred to the Massive Text Embedding Benchmark Leaderboard, sorted it on descending order of retrieval score, and picked a model that had a good balance of embedding dimension and score.

Для семантического поиска начнём с выбора embedding-модели. Я посмотрел на Massive Text Embedding Benchmark Leaderboard, отсортировал по убыванию retrieval-скора и выбрал модель с хорошим балансом между размерностью эмбеддинга и качеством.

This led me to e5-small-v2. Currently, it’s ranked a respectable 7th, right below text-embedding-ada-002. What’s impressive is its embedding size of 384 which is far smaller than what most models have (768 - 1,536). And while it supports a maximum sequence length of only 512, this is sufficient given my shorter chunks. (More details in the paper Text Embeddings by Weakly-Supervised Contrastive Pre-training.) After embedding these documents, we store them in a numpy array.

Так я пришёл к e5-small-v2. Сейчас она занимает достойное 7-е место, прямо под text-embedding-ada-002. Что впечатляет — размер эмбеддинга 384, что заметно меньше, чем у большинства моделей (768–1536). И хотя она поддерживает максимальную длину последовательности всего 512, этого достаточно при моих коротких чанках. (Подробнее в статье Text Embeddings by Weakly-Supervised Contrastive Pre-training.) После эмбеддинга документов мы сохраняем их в numpy-массиве.

During query time, we tokenize and embed the query, do a dot product with the document embedding array, and take the top n results (in this case, 10).

Во время запроса мы токенизируем и эмбеддим запрос, делаем скалярное произведение с массивом эмбеддингов документов и берём топ 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

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

If you’re thinking of using the e5 models, remember to add the necessary prefixes during preprocessing. For documents, you’ll have to prefix them with “passage:‎ ” and for queries, you’ll have to prefix them with “query:‎

Если вы думаете об использовании моделей e5, не забудьте добавить нужные префиксы при препроцессинге. Документы нужно префиксировать «passage:‎ », а запросы — «query:‎ ».

The retrieval service is a FastAPI app. Given an input query, it performs both BM25 and semantic search, deduplicates the results, and returns the documents’ text and associated title. The latter is used to link source documents when generating the draft.

Сервис retrieval — это приложение на FastAPI. По входному запросу он выполняет и BM25, и семантический поиск, дедуплицирует результаты и возвращает текст документов и связанный с ними заголовок. Последний используется для того, чтобы связать исходные документы при генерации черновика.

To start the OpenSearch node and semantic search + FastAPI server, we use a simple-docker compose file. They each run in their own containers, bridged by a common network. For convenience, we also define common commands in a Makefile.

Чтобы запустить узел OpenSearch и сервер семантического поиска + FastAPI, мы используем простой docker-compose файл. Они работают в своих контейнерах, объединённых общей сетью. Для удобства мы также описали общие команды в Makefile.

Finally, we integrate with Obsidian via a TypeScript plugin. The obsidian-plugin-sample made it easy to get started and I added functions to display retrieved documents in a new tab, query APIs, and stream the output. (I’m new to TypeScript so feedback appreciated!)

Наконец, мы интегрируемся с Obsidian через плагин на TypeScript. obsidian-plugin-sample позволил легко начать, и я добавил функции для отображения найденных документов в новой вкладке, обращения к API и стриминга вывода. (Я новичок в TypeScript, так что буду рад фидбеку!)

What else can we apply this to?

К чему ещё это можно применить?

While this prototype uses local notes and journal entries, it’s not a stretch to imagine the copilot retrieving from other documents (online). For example, team documents such as product requirements and technical design docs, internal wikis, and even code. I’d guess that’s what Microsoft, Atlassian, and Notion are working on right now.

Хотя этот прототип использует локальные заметки и записи в дневнике, нетрудно представить, как копайлот достаёт информацию из других документов (онлайн). Например, командных документов вроде product requirements и technical design docs, внутренних вики и даже кода. Думаю, именно над этим сейчас работают Microsoft, Atlassian и Notion.

It also extends beyond personal productivity. Within my field of recommendations and search, researchers and practitioners are excited about layering LLM-based generation on top of existing systems and products to improve the customer experience. (I expect we’ll see some of this in production by the end of the year.)

Это распространяется и за пределы личной продуктивности. В моей области рекомендаций и поиска исследователи и практики увлечены идеей наслоения LLM-генерации поверх существующих систем и продуктов для улучшения customer experience. (Думаю, к концу года увидим что-то из этого в проде.)

Ideas for improvement

Идеи для улучшения

One idea is to try LLMs with larger context sizes that allow us to feed in entire documents instead of chunks. (This may help with retrieval recall but puts more onus on the LLM to identify the relevant context for generation.) Currently, I’m using gpt-3.5-turbo which is a good balance of speed and cost. Nonetheless, I’m excited to try claude-1.3-100k and provide entire documents as context.

Одна идея — попробовать LLM с большими контекстами, которые позволят скармливать целые документы вместо чанков. (Это может помочь с retrieval recall, но переносит больше нагрузки на LLM по выделению релевантного контекста для генерации.) Сейчас я использую gpt-3.5-turbo, который хорошо балансирует скорость и стоимость. Тем не менее, мне интересно попробовать claude-1.3-100k и подавать целые документы в качестве контекста.

Another idea is to augment retrieval with web or internal search when necessary. For example, when documents and notes go stale (e.g., based on last updated timestamp), we can look up the web or internal documents for more recent information.

Другая идея — дополнить retrieval веб- или внутренним поиском, когда это нужно. Например, когда документы и заметки устаревают (скажем, по timestamp последнего обновления), можно искать в вебе или внутренних документах более свежую информацию.

• • •

• • •

Here’s the GitHub repo if you’re keen to try. Start by cloning the repo and updating the path to your obsidian-vault and huggingface hub cache. The latter saves us from downloading the tokenizer and model each time you start the containers.

Вот 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

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

Then, build the image and indices before starting the retrieval app.

Затем соберите образ и индексы перед запуском приложения 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

# 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

Finally, install the copilot plugin, enable it in community plugin settings, and update the API key. You’ll have to restart your Obsidian app if you had it open before installation.

Наконец, установите плагин copilot, включите его в настройках community-плагинов и обновите API-ключ. Если Obsidian был открыт до установки, придётся его перезапустить.

make install-plugin

make install-plugin

If you tried it, I would love to hear how it went, especially where it didn’t work well and how it can be improved. Or if you’ve been working with retrieval-augmented generation, I’d love to hear about your experience so far!

Если вы попробовали — буду рад услышать, как всё прошло, особенно где это работало плохо и как это можно улучшить. Или если вы работали с retrieval-augmented generation, мне очень интересен ваш опыт!

If you found this useful, please cite this write-up as:

Если этот материал был вам полезен, цитируйте его так:

Yan, Ziyou. (Jun 2023). Obsidian-Copilot: An Assistant for Writing & Reflecting. eugeneyan.com. https://eugeneyan.com/writing/obsidian-copilot/.

Yan, Ziyou. (Jun 2023). Obsidian-Copilot: An Assistant for Writing & Reflecting. eugeneyan.com. https://eugeneyan.com/writing/obsidian-copilot/.

or

или

@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/} }

@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/} }



Join 11,800+ readers getting updates on machine learning, RecSys, LLMs, and engineering.

Присоединяйтесь к 11 800+ читателей, получающих обновления о machine learning, RecSys, LLM и инженерии.