У большинства людей с 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 │
└─────────────────────────────────┘
│
▼
ответ
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-...
Файл 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. Можно повторно запускать когда добавишь новые.
Файл 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"
Чтобы было приятно:
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
Текущий простой по параграфам — неплохо, но может рвать смысл. Улучшения:
RecursiveCharacterTextSplitter из LangChain.Embedding-поиск иногда возвращает не лучшее. Можно добавить второй шаг — cross-encoder reranking:
pip install sentence-transformers
# rerank через cross-encoder ms-marco-MiniLM-L-12-v2
Резко поднимает качество ответов.
Если в metadata добавишь дату создания заметки — можно фильтровать «только недавние».
Эмбеддинги отправляются в OpenAI. Если заметки конфиденциальны — используй локальную модель (sentence-transformers BGE-M3) и Ollama для генерации.
Совершенно копеечно для такой пользы.
watchdog).