← AI Курс
Мини-проект·P2

Проект «За 1 час»: RAG над своими заметками (Obsidian / Notion)

Что построим: ассистент, отвечающий на вопросы по твоим личным заметкам Время: ~60 минут Уровень: урок 6 + урок 8 курса

Зачем этот проект

У большинства людей с Obsidian / Notion / Bear / Apple Notes одна и та же проблема: 500+ заметок, и невозможно ничего найти. «Помню, я где-то писал про X» — но прокрутить вручную, или поиск по словам не находит, потому что точных совпадений нет.

RAG (Retrieval-Augmented Generation) решает: индексируем все заметки, при вопросе достаём релевантные куски и просим LLM сгенерировать ответ на их основе. Получается твой личный «второй мозг», который реально помнит за тебя.

Что узнаешь

Что понадобится

Архитектура

┌───────────────┐    ┌──────────┐    ┌───────────────┐    ┌──────────┐
│  Папка        │ -> │ Chunking │ -> │  Embeddings   │ -> │  Chroma  │
│  с .md        │    │ по парагр│    │  text-embed-3 │    │  vector  │
└───────────────┘    └──────────┘    └───────────────┘    └────┬─────┘
                                                                │
                                                                │ (повторно)
                                                                │
                          вопрос пользователя                   │
                                  │                             │
                                  ▼                             │
                          ┌───────────────┐                     │
                          │ embed query   │                     │
                          └───────────────┘                     │
                                  │                             │
                                  ▼                             │
                          ┌─────────────────────────────────┐   │
                          │ найти топ-5 ближайших chunks    │◄──┘
                          └─────────────────────────────────┘
                                  │
                                  ▼
                          ┌─────────────────────────────────┐
                          │ LLM: ответь на вопрос на основе │
                          │ только этих chunks              │
                          └─────────────────────────────────┘
                                  │
                                  ▼
                              ответ

Шаг 1. Подготовка

mkdir rag-notes && cd rag-notes
python3 -m venv venv && source venv/bin/activate
pip install openai chromadb python-dotenv

.env:

OPENAI_API_KEY=sk-...

Шаг 2. Скрипт индексации

Файл indexer.py:

import os
import glob
from pathlib import Path
from dotenv import load_dotenv
from openai import OpenAI
import chromadb

load_dotenv()
client = OpenAI()

# путь к твоим заметкам — поменяй
NOTES_DIR = os.path.expanduser("~/Documents/Obsidian/MyVault")

# инициализация Chroma (создаст папку chroma_db рядом)
chroma = chromadb.PersistentClient(path="./chroma_db")

# если коллекция была — пересоздаём
try:
    chroma.delete_collection("notes")
except:
    pass
collection = chroma.create_collection("notes")


def chunk_markdown(text: str, max_size: int = 800) -> list[str]:
    """Режем по параграфам, склеивая мелкие."""
    paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
    chunks = []
    current = ""
    for p in paragraphs:
        if len(current) + len(p) <= max_size:
            current += "\n\n" + p if current else p
        else:
            if current:
                chunks.append(current)
            current = p if len(p) <= max_size else p[:max_size]
    if current:
        chunks.append(current)
    return chunks


def embed(texts: list[str]) -> list[list[float]]:
    """Эмбеддинг батчем — быстрее и дешевле."""
    response = client.embeddings.create(
        model="text-embedding-3-small",
        input=texts
    )
    return [item.embedding for item in response.data]


def index_notes():
    files = glob.glob(f"{NOTES_DIR}/**/*.md", recursive=True)
    print(f"Найдено {len(files)} файлов")

    all_chunks = []
    all_metadata = []
    all_ids = []

    for filepath in files:
        with open(filepath, "r", encoding="utf-8") as f:
            content = f.read()

        # простая очистка: убираем YAML frontmatter
        if content.startswith("---"):
            parts = content.split("---", 2)
            if len(parts) >= 3:
                content = parts[2].strip()

        chunks = chunk_markdown(content)
        rel_path = os.path.relpath(filepath, NOTES_DIR)

        for i, chunk in enumerate(chunks):
            all_chunks.append(chunk)
            all_metadata.append({"file": rel_path, "chunk": i})
            all_ids.append(f"{rel_path}:{i}")

    print(f"Всего chunks: {len(all_chunks)}")

    # эмбеддим батчами по 100
    batch_size = 100
    for i in range(0, len(all_chunks), batch_size):
        batch = all_chunks[i:i+batch_size]
        embs = embed(batch)
        collection.add(
            ids=all_ids[i:i+batch_size],
            embeddings=embs,
            documents=batch,
            metadatas=all_metadata[i:i+batch_size]
        )
        print(f"Проиндексировано: {min(i+batch_size, len(all_chunks))}/{len(all_chunks)}")

    print("✓ Индексация завершена")


if __name__ == "__main__":
    index_notes()

Запуск:

python indexer.py

На 200 заметках ~1 минута и стоит ~$0.02. Можно повторно запускать когда добавишь новые.

Шаг 3. Скрипт поиска и генерации ответа

Файл ask.py:

import os
import sys
from dotenv import load_dotenv
from openai import OpenAI
import chromadb

load_dotenv()
client = OpenAI()
chroma = chromadb.PersistentClient(path="./chroma_db")
collection = chroma.get_collection("notes")


SYSTEM_PROMPT = """Ты ассистент по личным заметкам пользователя.

ПРАВИЛА:
- Отвечай ТОЛЬКО на основе предоставленных фрагментов заметок.
- Если в заметках нет информации — честно скажи "В заметках это не нашёл".
- НЕ придумывай. НЕ дополняй из общих знаний.
- В ответе ссылайся на конкретные файлы: "[в заметке X]".
- Если в заметках есть противоречие — укажи на него.
- Будь краток. По делу."""


def ask(question: str, k: int = 5) -> str:
    # эмбеддим вопрос
    q_emb = client.embeddings.create(
        model="text-embedding-3-small",
        input=question
    ).data[0].embedding

    # ищем
    results = collection.query(
        query_embeddings=[q_emb],
        n_results=k
    )

    # форматируем найденные chunks
    chunks_text = ""
    for doc, meta in zip(results["documents"][0], results["metadatas"][0]):
        chunks_text += f"\n\n--- из файла {meta['file']} ---\n{doc}"

    # просим LLM ответить
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": f"ВОПРОС: {question}\n\nФРАГМЕНТЫ ЗАМЕТОК:{chunks_text}"}
        ],
        temperature=0.2,
        max_tokens=600
    )
    return response.choices[0].message.content


if __name__ == "__main__":
    if len(sys.argv) > 1:
        question = " ".join(sys.argv[1:])
    else:
        question = input("Вопрос: ")
    print("\n", ask(question))

Использование:

python ask.py "что я записывал про новые модели Anthropic?"
python ask.py "какие у меня были идеи pet-проектов"
python ask.py "что я читал про embeddings"

Шаг 4. Streamlit-интерфейс (по желанию)

Чтобы было приятно:

pip install streamlit

Файл app.py:

import streamlit as st
from ask import ask

st.title("🧠 Мой второй мозг")

if "history" not in st.session_state:
    st.session_state.history = []

for q, a in st.session_state.history:
    with st.chat_message("user"):
        st.write(q)
    with st.chat_message("assistant"):
        st.write(a)

if question := st.chat_input("Спроси по своим заметкам..."):
    with st.chat_message("user"):
        st.write(question)
    with st.spinner("Ищу..."):
        answer = ask(question)
    with st.chat_message("assistant"):
        st.write(answer)
    st.session_state.history.append((question, answer))
streamlit run app.py

Тонкости

Качество чанкинга

Текущий простой по параграфам — неплохо, но может рвать смысл. Улучшения:

Reranking

Embedding-поиск иногда возвращает не лучшее. Можно добавить второй шаг — cross-encoder reranking:

pip install sentence-transformers
# rerank через cross-encoder ms-marco-MiniLM-L-12-v2

Резко поднимает качество ответов.

Фильтр по дате

Если в metadata добавишь дату создания заметки — можно фильтровать «только недавние».

Privacy

Эмбеддинги отправляются в OpenAI. Если заметки конфиденциальны — используй локальную модель (sentence-transformers BGE-M3) и Ollama для генерации.

Стоимость

Совершенно копеечно для такой пользы.

Что дальше — варианты развития

Что узнал

  1. Полный pipeline RAG: chunking → embedding → vector DB → retrieval → generation.
  2. Работа с Chroma как локальной vector DB.
  3. Промптинг для «отвечай только на основе данных» (борьба с галлюцинациями).
  4. Простая Streamlit-обёртка над Python-скриптом.
🚀 Эволюция: через 2 месяца использования у тебя будет личный ИИ-ассистент, знающий все твои заметки лучше тебя. Это базовый паттерн для всех корпоративных «AI knowledge base» — что ты делаешь — то же самое, что Notion AI или Glean.
Без VPN и подписок: возьми локальные embeddings (sentence-transformers) и Ollama вместо Claude — всё работает на твоём ноутбуке без облака. Полный пример — урок p22 (RAG для своих документов).