cd /news/artificial-intelligence/building-a-personal-notes-assistant-… · home topics artificial-intelligence article
[ARTICLE · art-111314] src=dev.to ↗ pub= topic=artificial-intelligence verified=true sentiment=· neutral

Building a Personal Notes Assistant with RAG, Amazon Bedrock, and Pinecone

A developer built a personal notes assistant using RAG with Amazon Bedrock and Pinecone, enabling users to upload .txt files and ask questions answered strictly from their notes. The system uses embeddings for semantic search and includes validation checks to prevent corrupted files from crashing downstream services.

read5 min views1 publishedAug 26, 2026

Have you ever saved hundreds of digital notes only to spend twenty minutes hunting for one tiny detail?

That exact frustration led me to build a custom notes assistant. I wanted a private API where I could upload my personal files, ask a question in plain English, and get an answer drawn directly from my notes—not just a generic guess from a public AI model.

In this guide, I’ll break down how I built this project using Python, Flask, Amazon Bedrock, Pinecone, S3, AWS Lambda, and API Gateway. I’ll keep the explanations clear and conversational while walking through the real code and key takeaways.

Project Goal:Upload plain text (.txt

) notes and receive accurate answers strictly grounded in their content.

RAG stands for Retrieval-Augmented Generation.

Normally, when you ask an AI model a question, it relies entirely on its training data. If your information is private, recent, or highly specific, the model won't know it.

Question -> Model -> Answer

Question -> Search My Notes -> Grab Relevant Snippets -> Model -> Answer

By adding a retrieval step, we feed the model relevant passages from our own documents alongside our question. We aren't retraining the AI model; we're giving it an open-book test using documents we control.

Imagine walking into a library and asking a librarian: "What do my project notes say about serverless memory limits?"

The librarian doesn't read every book on the shelves from cover to cover. Instead, they:

Here is how that physical library maps directly to our technical setup:

Physical Library RAG System Component
Books on shelves Original text files stored in Amazon S3
Individual pages Document chunks
Catalog cards Embeddings (numerical representations of meaning)
Searching the catalog Pinecone similarity search
Top three selected pages Top three matching text chunks
Librarian giving the answer Amazon Nova Lite generating the response

An embedding is just a snippet of text converted into a list of numbers (a vector) that represents its core meaning. Words or phrases with similar meanings end up near each other in digital space.

For instance, a traditional keyword search might miss the connection between these two sentences:

Because they share few identical words, keyword search struggles. But a semantic search using embeddings recognizes that both sentences ask about file storage.

The application handles two main workflows: Ingestion (saving and indexing notes) and Querying (searching notes and answering questions).

.txt

file to the Flask backend./ask

endpoint.Security starts at the entry point. The /ingest

endpoint accepts multipart form data and runs several checks before touching the rest of our system:

if "file" not in request.files:
    return error_response("MISSING_FILE", "No file was provided", 400)

uploaded_file = request.files["file"]
filename = secure_filename(uploaded_file.filename or "")

if not filename or os.path.splitext(filename)[1].lower() != ".txt":
    return error_response("INVALID_FILE_TYPE", "Only UTF-8 .txt files are accepted", 415)

content = uploaded_file.read()
if not content:
    return error_response("EMPTY_FILE", "Uploaded text file is empty", 400)

try:
    decoded_content = content.decode("utf-8")
except UnicodeDecodeError:
    return error_response("INVALID_TEXT_ENCODING", "Text file must use UTF-8 encoding", 400)

if not decoded_content.strip() or "\x00" in decoded_content:
    return error_response("INVALID_TEXT_CONTENT", "File must contain valid text", 400)

Catching bad requests early with specific errors like INVALID_FILE_TYPE

prevents corrupted files from crashing downstream services like Pinecone or S3.

Once validated, the file goes to S3. To avoid accidental overwrites when up multiple files with generic names like notes.txt

, the application generates a unique ID (UUID) for each storage key while keeping the real filename in S3 metadata:

def upload_text_file(file_path: str, original_filename: str) -> tuple[str, str]:
    bucket = os.getenv("S3_DOCUMENT_BUCKET")
    key = f"uploads/{uuid4().hex}.txt"

    boto3.client("s3").upload_file(
        file_path,
        bucket,
        key,
        ExtraArgs={
            "ContentType": "text/plain; charset=utf-8",
            "Metadata": {"original-filename": Path(original_filename).name},
        },
    )
    return bucket, key

Sending entire long documents directly into vector search reduces precision. We split text into chunks using LangChain's RecursiveCharacterTextSplitter

:

text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=100,
)
docs = text_splitter.split_documents(documents)

Why include overlap?

If a key idea gets split right at character 1,000, half of the context ends up in Chunk A and half in Chunk B. Overlapping neighboring chunks by 100 characters preserves complete sentences and context across boundaries.

Next, we convert text chunks into numbers using Amazon Titan Text Embeddings V2 and save them into Pinecone:

embedding = BedrockEmbeddings(
    model_id="amazon.titan-embed-text-v2:0",
    dimensions=512,
    normalize=True,
    region_name="ap-south-1",
)

PineconeVectorStore.from_documents(
    docs,
    index_name=index_name,
    embedding=embedding,
    namespace="default",
)

Golden Rule: Your document chunks and your incoming search questions must use the exact same embedding model, dimension count, and normalization settings. Otherwise, vector distances become meaningless.

When asking a question via /ask

, Pinecone finds the three nearest chunks:

documents = docsearch.as_retriever(search_kwargs={"k": 3}).invoke(question.strip())
context_str = "\n\n".join(doc.page_content for doc in documents)

We pass those snippets into Amazon Nova Lite with strict prompt instructions:

PROMPT = ChatPromptTemplate.from_template(
    """Answer the question using only the context below.

Context:
{context}

Question: {question}
"""
)

llm = ChatBedrockConverse(
    model_id="amazon.nova-lite-v1:0",
    temperature=0.2, # Low temperature keeps answers factual
    max_tokens=512,
)

chain = PROMPT | llm | StrOutputParser()
answer = chain.invoke({"question": question, "context": context_str})

For local development, Flask handles traditional HTTP calls. When deploying to AWS, we run Flask inside AWS Lambda behind an API Gateway using serverless-wsgi

:

import serverless_wsgi
from server import app

def handler(event, context):
    return serverless_wsgi.handle_request(app, event, context)

The underlying infrastructure is configured in AWS CloudFormation:

/health

, /ingest

, /ask

).My GitHub repo : my-rag-notes-app

── more in #artificial-intelligence 4 stories · sorted by recency
── more on @amazon bedrock 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/building-a-personal-…] indexed:0 read:5min 2026-08-26 ·