I've been taking notes for years. Thousands of them — book summaries, meeting notes, code snippets, research, ideas, half-formed thoughts. The problem: most of these notes were effectively dead. I could rarely find the one I needed when I needed it, and the connections between ideas were invisible.
Adding AI to my note system changed this. Here's how I built an intelligent knowledge base that actually surfaces information when it's relevant.
The Problem with Traditional Note Systems
The fundamental issue with note-taking apps — Obsidian, Notion, Roam, even plain markdown files — is that retrieval is still keyword-based. You remember the exact title or a specific word to search for. If you don't, the note is lost.
What we actually need:
- Search by concept, not just keywords ("notes about dealing with imposter syndrome" should find a note titled "Thoughts on Confidence")
- Cross-note synthesis ("what have I written about building habits?")
- Contextual surfacing ("you're writing about X — here's what you wrote about related topics")
These are exactly the problems RAG-powered AI solves.
Architecture Overview
Markdown Files / Obsidian Vault
↓ (ingestion pipeline)
Text Chunking + Embeddings
↓
Vector Store (Qdrant / pgvector)
↓ (query interface)
Semantic Search + LLM Synthesis
↓
Answers, Connections, Insights
Building the Ingestion Pipeline
The ingestion pipeline processes your notes and builds the vector index:
import os
from pathlib import Path
from langchain.text_splitter import MarkdownHeaderTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_qdrant import QdrantVectorStore
from qdrant_client import QdrantClient, models
# Initialize
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
qdrant_client = QdrantClient(":memory:") # Use URL for production
# Create collection
qdrant_client.create_collection(
collection_name="notes",
vectors_config=models.VectorParams(size=1536, distance=models.Distance.COSINE)
)
vectorstore = QdrantVectorStore(
client=qdrant_client,
collection_name="notes",
embedding=embeddings
)
def ingest_notes(vault_path: str):
"""Ingest all markdown notes from an Obsidian vault."""
vault = Path(vault_path)
markdown_files = list(vault.rglob("*.md"))
splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=[
("#", "Header 1"),
("##", "Header 2"),
("###", "Header 3"),
]
)
all_docs = []
for md_file in markdown_files:
content = md_file.read_text(encoding="utf-8")
# Parse frontmatter
metadata = parse_frontmatter(content)
# Split by headers
splits = splitter.split_text(content)
for split in splits:
# Enrich metadata
split.metadata.update({
"source": str(md_file.relative_to(vault)),
"filename": md_file.stem,
"created": metadata.get("created", ""),
"tags": metadata.get("tags", [])
})
all_docs.append(split)
# Batch ingest to avoid rate limits
batch_size = 100
for i in range(0, len(all_docs), batch_size):
batch = all_docs[i:i + batch_size]
vectorstore.add_documents(batch)
print(f"Indexed {min(i + batch_size, len(all_docs))}/{len(all_docs)} chunks")
return len(all_docs)
Handling Obsidian-Specific Features
Obsidian notes have special syntax worth handling:
import re
def parse_obsidian_markdown(content: str) -> dict:
"""Extract Obsidian-specific features."""
# Extract [[wikilinks]]
wikilinks = re.findall(r'\[\[([^\]]+)\]\]', content)
# Extract #tags
hashtags = re.findall(r'(?<!\S)#([a-zA-Z][a-zA-Z0-9_-]*)', content)
# Extract YAML frontmatter
frontmatter = {}
if content.startswith('---'):
end = content.find('---', 3)
if end != -1:
yaml_content = content[3:end]
frontmatter = parse_yaml(yaml_content)
# Clean content for embedding (remove wikilinks markup)
clean_content = re.sub(r'\[\[([^\|]+)(?:\|[^\]]+)?\]\]', r'\1', content)
return {
"content": clean_content,
"wikilinks": wikilinks,
"tags": hashtags + frontmatter.get("tags", []),
"metadata": frontmatter
}
Keeping wikilinks as metadata lets you build a graph visualization of note connections on top of the semantic search.
The Query Interface
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
llm = ChatOpenAI(model="gpt-4o", temperature=0.3)
KNOWLEDGE_BASE_PROMPT = """
You are a personal knowledge assistant with access to the user's private notes and research.
Answer questions by synthesizing insights from their notes.
Always cite which notes you're drawing from.
If the notes don't contain enough information, say so honestly.
Relevant notes:
{context}
Question: {question}
Answer with citations to specific notes:
"""
def query_knowledge_base(question: str, top_k: int = 8) -> dict:
"""Query the knowledge base and synthesize an answer."""
# Retrieve relevant chunks with MMR for diversity
docs = vectorstore.max_marginal_relevance_search(
question,
k=top_k,
fetch_k=top_k * 3
)
# Format context with source information
context = "\n\n".join([
f"[From: {doc.metadata['filename']}]\n{doc.page_content}"
for doc in docs
])
# Generate synthesized answer
prompt = ChatPromptTemplate.from_template(KNOWLEDGE_BASE_PROMPT)
chain = prompt | llm
answer = chain.invoke({"context": context, "question": question})
return {
"answer": answer.content,
"sources": list(set(doc.metadata["filename"] for doc in docs)),
"num_sources": len(docs)
}
Building a CLI Interface
import click
from rich.console import Console
from rich.markdown import Markdown
console = Console()
@click.command()
@click.argument("question")
@click.option("--vault", default="~/obsidian", help="Path to your notes vault")
def ask(question: str, vault: str):
"""Ask your knowledge base a question."""
console.print(f"\n[bold cyan]Searching your notes...[/bold cyan]")
result = query_knowledge_base(question)
console.print(Markdown(result["answer"]))
console.print(f"\n[dim]Sources: {', '.join(result['sources'])}[/dim]")
@click.command()
@click.option("--vault", default="~/obsidian", help="Path to your notes vault")
def index(vault: str):
"""Index your notes vault."""
count = ingest_notes(vault)
console.print(f"[green]Indexed {count} note chunks[/green]")
What This Unlocks
Once this is running, you get capabilities that feel genuinely magical:
- "What have I learned about deep work?" → Synthesizes insights from 15 notes you wrote over 3 years
- "Find contradictions in my notes about diet" → Surfaces conflicting notes you wrote at different times
- "What books did I note related to habit formation?" → Semantic search beats keyword search every time
- "Summarize everything I know about TypeScript" → Instant synthesis of scattered code notes
The notes you took years ago become discoverable again. Ideas connect across time. Knowledge compounds instead of accumulating silently.
Automating Index Updates
Run the ingestion pipeline automatically on file changes:
# Using watchdog to re-index on file save
pip install watchdog
# Run incremental indexer
python -c "
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
class VaultHandler(FileSystemEventHandler):
def on_modified(self, event):
if event.src_path.endswith('.md'):
reindex_file(event.src_path)
observer = Observer()
observer.schedule(VaultHandler(), path='/path/to/vault', recursive=True)
observer.start()
"
This is the most practical AI project I've built for my own use. The return on investment is immediate and compounding.