DEV Community

Cover image for # Building a Personal Notes Assistant with RAG, Amazon Bedrock, and Pinecone
Jamal
Jamal

Posted on

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

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.


What is RAG?

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.

  • Standard AI Flow: Question -> Model -> Answer
  • RAG AI Flow: 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.


A Real-Life Analogy

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:

  1. Identify the core topic of your question.
  2. Check the library catalog for matching locations.
  3. Grab the top three relevant pages.
  4. Read those specific pages and summarize the answer for you.

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

Understanding Embeddings

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:

  • "Where did I save the uploaded document?"
  • "Which service stores my note files?"

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


System Architecture

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

How Notes Get Ingested

  1. Upload: You send a .txt file to the Flask backend.
  2. Validation: Flask checks the file format, size, and character encoding.
  3. Storage: The full original file goes to a private Amazon S3 bucket.
  4. Chunking: The document text is cut into smaller, overlapping snippets.
  5. Embedding: Amazon Titan converts each text snippet into a 512-dimensional vector.
  6. Indexing: Pinecone stores the vectors along with original text snippet metadata.

How Questions Get Answered

  1. Ask: You send a question to the /ask endpoint.
  2. Embed Question: Amazon Titan converts your question into a vector.
  3. Search: Pinecone retrieves the top 3 closest matching note snippets.
  4. Prompt Assembly: The question and the 3 snippets are combined into an instruction prompt.
  5. Generate Answer: Amazon Nova Lite reads the prompt and writes a factual response.
  6. Response: Flask returns the final answer as JSON.

Code Walkthrough

1. Step 1: Validating Incoming Uploads

Security starts at the entry point. The /ingest endpoint accepts multipart form data and runs several checks before touching the rest of our system:

# Check for file presence and secure filename
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)

# Validate encoding and content readability
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)
Enter fullscreen mode Exit fullscreen mode

Catching bad requests early with specific errors like INVALID_FILE_TYPE prevents corrupted files from crashing downstream services like Pinecone or S3.

2. Step 2: Saving Raw Files to S3

Once validated, the file goes to S3. To avoid accidental overwrites when uploading 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")
    # Create a unique path key
    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
Enter fullscreen mode Exit fullscreen mode

3. Step 3: Chunking Text for Precision

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)
Enter fullscreen mode Exit fullscreen mode

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.

4. Step 4: Vector Embedding & Indexing

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",
)
Enter fullscreen mode Exit fullscreen mode

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.

5. Step 5 & 6: Retrieval & Answer Generation

When asking a question via /ask, Pinecone finds the three nearest chunks:

# Grab top 3 matching snippets
documents = docsearch.as_retriever(search_kwargs={"k": 3}).invoke(question.strip())
context_str = "\n\n".join(doc.page_content for doc in documents)
Enter fullscreen mode Exit fullscreen mode

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})
Enter fullscreen mode Exit fullscreen mode

Serverless Deployment Highlights

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)
Enter fullscreen mode Exit fullscreen mode

The underlying infrastructure is configured in AWS CloudFormation:

  • API Gateway: Routes endpoint traffic (/health, /ingest, /ask).
  • AWS Lambda: Hosts the application logic on Python 3.12.
  • IAM Roles: Grants minimal execution permissions for Bedrock, S3, and CloudWatch.

Key Takeaways & Lessons Learned

  1. RAG Is a Search System First: If retrieval yields poor or irrelevant snippets, even the best LLM will fail to give a good answer.
  2. Consistency Is Critical: Embedding settings during ingestion must match query settings perfectly.
  3. Standard Software Engineering Matters: Machine learning features still rely on traditional tasks like request validation, error handling, file cleanup, and IAM security.
  4. Context Grounding Controls Hallucinations: Explicit system prompts prevent the AI model from making up facts outside your uploaded notes.

My GitHub repo : my-rag-notes-app

Top comments (0)