All articles
Build Guides

Build a Notion Knowledge Assistant That Answers Questions from Your Workspace

FDE Coach EditorialJuly 28, 20268 min read

What We're Building

A retrieval-augmented generation (RAG) pipeline that transforms your Notion workspace into a queryable knowledge base. Instead of manually digging through databases and pages, you type a question and get a synthesized answer backed by your actual documentation.

Feature list:

  • Full Notion workspace sync via the official API
  • Chunking and embedding of pages into a vector store
  • Semantic search across all synced content
  • Chat interface powered by a fast, free LLM
  • Source attribution so you know exactly which page the answer came from

The entire stack runs on free tiers. No credit card gymnastics required.

Architecture Overview

The sync pipeline pulls every page from Notion, splits them into manageable chunks, generates embeddings, and stores them in Supabase's pgvector extension. When you ask a question, the query is embedded, similar chunks are retrieved, and Groq's Mixtral 8x7B synthesizes an answer using those chunks as context.

Prerequisites

Everything here is free-tier. No asterisks.

ServicePurposeFree Tier LimitSign-Up Link
Notion APIRead workspace contentUnlimited API requests on personal workspacesdevelopers.notion.com
SupabasePostgreSQL + pgvector host2 projects, 500 MB database eachsupabase.com
Groq CloudLLM inference via Mixtral 8x7B~30 requests/min, generous tokensconsole.groq.com
HuggingFace (optional)Free embedding model via Inference APIRate-limited but sufficient for small workspaceshuggingface.co

You'll also need Python 3.10+ and a virtual environment.

Step 1: Set Up Notion Integration and API Access

Head to notion.so/my-integrations and create a new integration. Give it a name like "Knowledge Assistant" and select your workspace. Copy the Internal Integration Secret — it starts with secret_.

Next, share every page you want indexed with the integration. Open each top-level page, click the ... menu, scroll to Connections, and add your integration. The API cannot access pages unless explicitly shared.

# .env
NOTION_INTEGRATION_TOKEN=secret_abc123...

Test connectivity with a quick script:

import os
from dotenv import load_dotenv
from notion_client import Client

load_dotenv()
notion = Client(auth=os.environ["NOTION_INTEGRATION_TOKEN"])

# Search returns all pages shared with the integration
results = notion.search(query="", filter={"property": "object", "value": "page"}).get("results")
print(f"Found {len(results)} accessible pages")

Step 2: Configure Supabase and pgvector

Create a new Supabase project. Once provisioned, enable the pgvector extension:

-- Run in Supabase SQL Editor
create extension if not exists vector;

Create the table that will hold your document chunks:

create table if not exists notion_chunks (
  id uuid primary key default gen_random_uuid(),
  page_id text not null,
  page_title text,
  chunk_text text not null,
  embedding vector(384),  -- match your embedding model dimension
  metadata jsonb default '{}'::jsonb,
  created_at timestamptz default now()
);

-- Index for similarity search
create index on notion_chunks using ivfflat (embedding vector_cosine_ops) with (lists = 100);

Grab your Supabase URL and service role key from the project settings. Add them to .env:

SUPABASE_URL=https://xxxxx.supabase.co
SUPABASE_SERVICE_KEY=eyJhbGciOi...

Step 3: Sync Notion Pages to the Vector Store

We'll use LlamaIndex for the heavy lifting — Notion page loading, chunking, and embedding. Install dependencies:

pip install llama-index llama-index-readers-notion llama-index-embeddings-huggingface \
            llama-index-vector-stores-supabase sentence-transformers python-dotenv

The sync script loads every page, splits it into nodes, generates embeddings with a free HuggingFace model, and upserts them into Supabase.

import os
from dotenv import load_dotenv
from llama_index.readers.notion import NotionPageReader
from llama_index.core.node_parser import SentenceSplitter
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
from llama_index.vector_stores.supabase import SupabaseVectorStore
from llama_index.core import StorageContext, VectorStoreIndex

load_dotenv()

# 1. Load Notion pages
reader = NotionPageReader(integration_token=os.environ["NOTION_INTEGRATION_TOKEN"])
# The reader fetches all pages shared with the integration
documents = reader.load_data()
print(f"Loaded {len(documents)} documents")

# 2. Chunk documents
parser = SentenceSplitter(chunk_size=512, chunk_overlap=50)
nodes = parser.get_nodes_from_documents(documents)
print(f"Split into {len(nodes)} chunks")

# 3. Embedding model (free, 384-dim)
embed_model = HuggingFaceEmbedding(
    model_name="BAAI/bge-small-en-v1.5",
    device="cpu"
)

# 4. Set up Supabase vector store
vector_store = SupabaseVectorStore(
    supabase_url=os.environ["SUPABASE_URL"],
    supabase_key=os.environ["SUPABASE_SERVICE_KEY"],
    table_name="notion_chunks",
    embedding_dimension=384,
)

# 5. Embed and persist
storage_context = StorageContext.from_defaults(vector_store=vector_store)
index = VectorStoreIndex(
    nodes,
    storage_context=storage_context,
    embed_model=embed_model,
)
print(f"Indexed {len(nodes)} chunks into Supabase")

Run this script to populate your vector store. For workspaces with hundreds of pages, it takes a few minutes.

Step 4: Build the RAG Query Engine

With the vector store populated, we construct a query engine that retrieves relevant chunks and pipes them to Mixtral for answer generation.

pip install llama-index-llms-groq
from llama_index.llms.groq import Groq
from llama_index.core import Settings

# Configure Groq as the LLM
Settings.llm = Groq(
    model="mixtral-8x7b-32768",
    api_key=os.environ["GROQ_API_KEY"],
    temperature=0.3,
)

# Reuse the same embedding model
Settings.embed_model = embed_model

# Reconstruct index from existing vector store
index = VectorStoreIndex.from_vector_store(
    vector_store=vector_store,
    embed_model=embed_model,
)

# Build query engine with source attribution
query_engine = index.as_query_engine(
    similarity_top_k=5,
    response_mode="compact",  # Fits retrieved context into single prompt
    verbose=False,
)

# Test it
response = query_engine.query("What's our onboarding process for new engineers?")
print(response)
print("\n--- Sources ---")
for node in response.source_nodes:
    print(f"- {node.metadata.get('page_title', 'Unknown')}: score {node.score:.3f}")

The response_mode="compact" setting tells LlamaIndex to stuff all retrieved chunks into the prompt, then let Mixtral synthesize. For longer contexts, switch to "refine" or "tree_summarize".

Step 5: Wire Up the Chat Interface

Streamlit gives us a clean chat UI with zero frontend code.

pip install streamlit

Create app.py:

import streamlit as st
from query_engine import build_query_engine  # Encapsulate Step 4 logic

st.set_page_config(page_title="Notion Knowledge Assistant", layout="wide")
st.title("🧠 Notion Knowledge Assistant")

@st.cache_resource
def get_query_engine():
    return build_query_engine()

query_engine = get_query_engine()

# Chat state
if "messages" not in st.session_state:
    st.session_state.messages = []

# Render history
for msg in st.session_state.messages:
    with st.chat_message(msg["role"]):
        st.markdown(msg["content"])

# Input
if prompt := st.chat_input("Ask anything from your Notion workspace..."):
    st.session_state.messages.append({"role": "user", "content": prompt})
    with st.chat_message("user"):
        st.markdown(prompt)

    with st.chat_message("assistant"):
        with st.spinner("Searching your second brain..."):
            response = query_engine.query(prompt)
        st.markdown(response.response)

        # Show sources in an expander
        with st.expander("📎 Sources"):
            for i, node in enumerate(response.source_nodes):
                title = node.metadata.get("page_title", "Untitled")
                snippet = node.text[:300].replace("\n", " ")
                st.caption(f"**{i+1}. {title}** (relevance: {node.score:.2f})")
                st.text(snippet + "...")

    st.session_state.messages.append({"role": "assistant", "content": response.response})

Launch with streamlit run app.py. The first query triggers index reconstruction, which takes a few seconds. Subsequent queries are near-instant.

Extensions and Optimization

Incremental sync: Instead of re-indexing the entire workspace, store last_edited_time from Notion's API and only re-process pages that changed. Schedule this with a cron job or a simple while True: time.sleep(3600) loop.

Better embeddings: The free BGE-small model works, but moving to text-embedding-3-small from OpenAI ($0.02/1M tokens, which is pennies for most workspaces) improves retrieval quality dramatically. Swap HuggingFaceEmbedding for OpenAIEmbedding.

Hybrid search: pgvector supports full-text search alongside vector similarity. Add a tsvector column and combine BM25 + cosine similarity for better recall on keyword-heavy queries.

Multi-tenant workspaces: Add a workspace_id column to notion_chunks and filter queries by workspace. The free tier handles a handful of small workspaces comfortably.

If you're building agentic workflows around tools like this, you might also find value in the Gmail AI Triage Agent or the Calendar Negotiation Agent patterns — same RAG-under-the-hood philosophy applied to different data sources.

Common Pitfalls

Integration not sharing pages: The Notion API silently returns empty results if you forget to share pages with your integration. Always verify with the search script in Step 1 before debugging downstream.

Rate limits on HuggingFace embeddings: The free Inference API throttles aggressively. If you hit 429 errors, add a time.sleep(1) between batches or switch to local embedding with sentence-transformers:

from llama_index.embeddings.huggingface import HuggingFaceEmbedding
embed_model = HuggingFaceEmbedding(
    model_name="BAAI/bge-small-en-v1.5",
    device="cpu",  # or "cuda" if you have a GPU
    trust_remote_code=True,
)

Chunk size mismatches: If your embedding model outputs 384-dimensional vectors, your notion_chunks.embedding column must be vector(384). Mismatched dimensions cause silent insert failures.

Groq context window: Mixtral 8x7B has a 32K context window, but stuffing 5 chunks of 512 tokens each plus the user query can push limits. If you see truncated responses, reduce similarity_top_k or chunk_size.

FAQ

Q: Can I use this with a team workspace? A: Yes. The Notion integration must be installed by a workspace admin. Each team member who wants private queries would need their own integration, or you can build a shared assistant that indexes only pages accessible to the integration.

Q: How much does this cost at scale? A: For a workspace with 1,000 pages, embedding and storage fit comfortably within Supabase's free 500 MB. Groq's free tier handles ~30 queries per minute. Beyond that, you're looking at ~$0.27 per million tokens on Groq's paid plan.

Q: Can I index Notion databases, not just pages? A: The NotionPageReader in LlamaIndex handles databases by reading each row as a separate document. For relational data, consider extracting structured properties and storing them as metadata for filtered queries.

Q: How do I keep the index in sync? A: The simplest approach is a nightly full re-sync. For near-real-time, poll Notion's search endpoint filtered by last_edited_time > {last_sync} and upsert only changed pages.

Q: Is this production-ready? A: For internal tools, absolutely. For customer-facing products, you'd want to add authentication, rate limiting, and switch to dedicated embedding infrastructure. The architecture — vector DB + fast LLM — scales directly.

If you're thinking about deploying LLM features in environments with compliance requirements, the Enterprise LLM Feature Deployment Case Study walks through the exact playbook.

#notion#knowledge-base#rag#chatbot#llamaindex

Want to build like a Forward Deployed Engineer?

FDE Coach is a cohort-based program in frontend, backend, AWS, and AI. Build real products and get referred to 200+ hiring partners.

Explore the program

More build guides

August 15 · 0d left
Enroll Now