Northwind Enterprise Agent Notebook¶
Author: Rashad Malik
Objective
This notebook builds an AI-powered assistant that answers business questions by querying two distinct data sources: a structured SQL database (Northwind) and an unstructured document store (company handbooks). The agent automatically determines which source to query based on the user's question.
Key Steps
- Ingest documents into a FAISS vector store using HuggingFace sentence embeddings
- Connect to the Northwind SQLite database containing employee, order, and product data
- Build a LangChain agent equipped with both RAG retrieval and SQL query tools
- Test the agent with three query types: policy lookups, database queries, and hybrid questions requiring both sources
- Verify responses by comparing agent outputs against manual queries
Enterprise Applications
This pattern addresses a common problem in organisations: business data lives in multiple systems that don't talk to each other. An employee might need to check both the HR handbook and the payroll database to answer a simple question about leave entitlement. By combining retrieval-augmented generation with SQL tooling, a single agent can route questions to the appropriate source and synthesise answers from both structured and unstructured data.
The same architecture applies to customer support (querying both product databases and support documentation), legal research (case databases and policy documents), or any domain where answers require context from multiple systems.
Required libraries¶
import os
import sqlite3
import shutil
from dotenv import load_dotenv
from pathlib import Path
# LangChain Core
from langchain.agents import create_agent
# LangChain LLMs
from langchain_openai import ChatOpenAI
# LangChain SQL
from langchain_community.utilities import SQLDatabase
from langchain_community.agent_toolkits import SQLDatabaseToolkit
# LangChain RAG (Vector Store)
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import TextLoader
from langchain_community.vectorstores import FAISS
from langchain_huggingface import HuggingFaceEmbeddings
from langchain.tools import tool
We use a .env file to store sensitive credentials (such as API keys) separately from our code. This is a common practice that:
- Keeps secrets out of version control (the
.envfile should be added to.gitignore). - Makes it easy to use different credentials in development, testing, and production environments.
- Allows collaborators to provide their own keys without modifying the notebook.
To run this notebook yourself, you will need to:
- Obtain an API key from OpenAI.
- Create a file named
.envin the root directory of this project (the same folder as this notebook). - Add the following line to the file, replacing the placeholder with your actual key:
OPENAI_API_KEY=sk-your-api-key-here - Optionally, if you wish to use LangSmith for tracing and debugging, add your LangSmith API key as well:
LANGSMITH_API_KEY=your-langsmith-key-here
The load_dotenv() function reads these values and makes them available as environment variables, which the LangChain libraries use automatically.
# Loads all variables from the .env file
load_dotenv()
# Confirmation that keys were loaded correctly
print(f"OpenAI Key Loaded: {'OPENAI_API_KEY' in os.environ}")
print(f"LangSmith Key Loaded: {'LANGSMITH_API_KEY' in os.environ}")
OpenAI Key Loaded: True LangSmith Key Loaded: True
1 Ingesting Documents to Vector Store¶
This cell performs the one-time ingestion process, which prepares our documents so the AI agent can search through them later. Here's what each step does:
Load the source documents: The code reads three markdown files from the
knowledge_basefolder:company_info.md: company history, mission, values, and global operations.hr_policy.md: employee handbook covering leave entitlements, expenses, and working practices.it_guide.md: IT support procedures, hardware policy, and security guidelines.
Split into chunks: Large documents are broken into smaller, overlapping pieces (around 1,000 characters each). This is necessary because embedding models work better with shorter text, and it helps the search return more precise results rather than entire documents.
Convert text to vectors: Each chunk is passed through a HuggingFace sentence transformer model (
all-MiniLM-L6-v2), which converts the text into a vector. These vectors capture the meaning of the text, so similar concepts end up with similar vectors.Save to a vector database: The vectors are stored in a FAISS index on disk. FAISS is a library that enables fast similarity search. When a question is asked, the question text is converted to a vector, and the chunks with the most similar vectors are found.
# Load documents
documents = []
for md_file in Path('./knowledge_base/').glob("*.md"):
loader = TextLoader(str(md_file), encoding='utf-8')
documents.extend(loader.load())
# Split documents
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=100)
docs = text_splitter.split_documents(documents)
# Load embedding model
print("Loading embedding model (this may take a moment)...")
embeddings = HuggingFaceEmbeddings(model_name="all-MiniLM-L6-v2")
# Create and save the vector store
print("Creating and saving vector store...")
vector_store = FAISS.from_documents(docs, embeddings)
vector_store.save_local("faiss_index")
print("Vector store 'faiss_index' created and saved successfully.")
Loading embedding model (this may take a moment)... Creating and saving vector store... Creating and saving vector store... Vector store 'faiss_index' created and saved successfully. Vector store 'faiss_index' created and saved successfully.
2 Preparing the Northwind Database¶
The Northwind database is a sample dataset originally created by Microsoft to demonstrate database concepts. It simulates a fictional company called "Northwind Traders" that imports and exports speciality foods. The database contains realistic business data including employees, customers, orders, products, and suppliers. This makes it ideal for testing SQL queries and building data-driven applications.
This database is well-suited for this project because:
- It contains interconnected tables that mirror real enterprise data (e.g. linking employees to orders to products).
- It is small enough to run locally.
- It provides a realistic scenario for business questions like "Who is our top salesperson?" or "What products are running low on stock?"
The below cell copies the source SQLite file to the project root folder, renaming it to northwind.db. This keeps the original file intact and gives us a clean working copy for the agent to query.
# Define the source path and the target path
source_db_path = os.path.join('data', 'northwind-SQLite3-0.1.0', 'Northwind_small.sqlite')
target_db_name = 'northwind.db'
# Check if the target file already exists
if not os.path.exists(target_db_name):
print(f"Database '{target_db_name}' not found.")
# Check if the source file exists
if os.path.exists(source_db_path):
print(f"Copying '{source_db_path}' to '{target_db_name}'...")
shutil.copy(source_db_path, target_db_name)
print(f"Database '{target_db_name}' is ready.")
else:
print(f"ERROR: Source file not found at '{source_db_path}'.")
print("Please ensure the file path is correct based on your project structure.")
else:
print(f"Database '{target_db_name}' already exists. Skipping copy.")
Database 'northwind.db' already exists. Skipping copy.
3 Initialising LLM and Database Connection¶
This cell sets up the two core components that power our agent: the language model (LLM) and the database connection.
The LLM's Role in a LangChain Agent
The LLM acts as the "brain" of the agent. It reasons about what tools to use and in what order. When a user asks a question, the LLM:
- Analyses the question to understand what information is needed.
- Decides which tool (RAG search or SQL query) is most appropriate.
- Formulates the input to pass to that tool (e.g. a search query or an SQL statement).
- Interprets the tool's output and decides whether to call another tool or provide a final answer.
Choosing a Model
LangChain allows us to select from a wide range of LLM providers and models. For this proof of concept, we use gpt-4o. In production environments, model selection should be based on:
- Task performance: Some models excel at reasoning and tool use, while others are better suited to summarisation or code generation. Benchmarking against the specific use case is important.
- Token costs: Models vary significantly in price per token. For high-volume applications, a smaller or more efficient model may be more cost-effective.
The temperature parameter controls the randomness of the model's output. A temperature of 0 produces more focused, deterministic responses (the model picks the most likely next token each time). Higher values (e.g. 0.7 or 1.0) introduce more variation and creativity. For agentic tasks where consistency matters, a low temperature is typically preferred.
The Database Connection
LangChain's SQLDatabase wrapper provides a standardised interface for the agent to interact with our SQLite database. It handles the connection details and allows the SQL tools to inspect the schema, run queries, and return results in a format the LLM can interpret.
# Initialise the LLM
# This assumes your OPENAI_API_KEY is loaded in your environment
llm = ChatOpenAI(model="gpt-4o", temperature=0)
# Initialise the SQLDatabase connection
db_uri = f"sqlite:///{target_db_name}"
db = SQLDatabase.from_uri(db_uri)
4 Creating the Agent's Tools (RAG + SQL)¶
Tools are functions that the agent can call to perform specific tasks. The agent cannot directly search documents or query databases on its own. Instead, it decides which tool to use based on the question, calls that tool, and then interprets the result.
We will create two types of tools:
- A RAG tool for searching company policy documents stored in the vector database.
- SQL tools for querying the Northwind database to retrieve business data.
The agent framework we are using expects tools to be provided as a list of callable functions. Each function must include a description that tells the agent when to use it.
4.1 The RAG Tool¶
This tool allows the agent to search the company policy documents we ingested earlier into the FAISS vector store.
The
@tooldecorator: This converts a standard Python function into a LangChain tool. The decorator reads the function's docstring and uses it as the tool's description. The agent uses this description to decide when to call the tool.The function signature: The function takes a
querystring as input. This is the search term the agent will provide when it calls the tool.The similarity search: Inside the function, we call
vector_store.similarity_search(query, k=3). This finds the three document chunks whose vector representations are closest to the query's vector. In simpler terms, it finds the three pieces of text that are most relevant to the question.Formatting the output: The function returns the matching text chunks as a formatted string. The agent will read this string and use the information to answer the user's question.
The functions docstring is critical, as it explicitly mentions topics like "vacation", "sick leave", and "hardware requests". This helps the agent understand that questions about these topics should be directed to this tool rather than the SQL tools.
# Defining the RAG tool
@tool
def search_company_policies(query: str) -> str:
"""
Searches the company knowledge base for policies on HR, IT, and general company guidelines.
Use this for any questions about vacation, sick leave, hardware requests, or work-from-home policies.
"""
docs = vector_store.similarity_search(query, k=3) # Get top 3 results
if not docs:
return "No relevant company policies were found."
# Format the results
return "\n---\n".join([f"Source: {doc.metadata.get('source', 'N/A')}\nContent: {doc.page_content}" for doc in docs])
4.2 The SQL Tool¶
Querying a database requires several steps: inspecting the available tables, understanding the column names, writing a valid SQL query, and executing it. Rather than building a single tool to handle all of this, LangChain provides a pre-built toolkit that creates multiple specialised tools automatically.
Initialise the toolkit: We create an
SQLDatabaseToolkitobject by passing it our database connection (db) and the language model (llm). The toolkit needs the LLM because some of its tools use the model to help generate or validate SQL queries.Extract the tools: Calling
sql_toolkit.get_tools()returns a list of tool objects. Each tool handles a different part of the database interaction process. Typical tools include:- A tool to list the tables in the database.
- A tool to describe the schema (columns and data types) of a specific table.
- A tool to execute a SQL query and return the results.
- A tool to check whether a query is valid before running it.
Inspect the tool names: The
printstatement displays the names of the tools that were created. This is useful for debugging and for understanding what capabilities the agent will have.
By using the toolkit, we avoid writing boilerplate code for each database operation. The agent can call these tools in sequence to explore the database schema and construct accurate queries.
# Initialise the toolkit
sql_toolkit = SQLDatabaseToolkit(db=db, llm=llm)
# Get the list of tools from the toolkit
sql_tools = sql_toolkit.get_tools()
# Inspecting the created tools
print(f"SQL Tools Created: {[tool.name for tool in sql_tools]}")
SQL Tools Created: ['sql_db_query', 'sql_db_schema', 'sql_db_list_tables', 'sql_db_query_checker']
5 Creating the Main Enterprise Agent¶
In the previous sections, we created two separate capabilities for our agent:
- The RAG tool (
search_company_policies): A single function that searches our company policy documents. - The SQL tools (
sql_tools): A list of several functions that allow the agent to explore and query the Northwind database.
Before we can create the agent, we need to combine these into a single list. This will give the agent access to one toolbox containing all available tools, and it will decide which tool to pick up based on the question it receives.
Merge the tools into one list: We add our RAG tool to the beginning of the SQL tools list using the
+operator. The result is a single list containing all tools the agent can use.Create the agent executor: The
create_agentfunction builds the agent by combining three components:- The LLM (
llm): The language model that acts as the agent's reasoning engine. It reads the user's question, decides which tools to call, and interprets the results. - The tools list (
tools): The combined list of all available tools. - The system prompt: A short instruction that tells the agent what role it should play. This prompt is included at the start of every conversation to set the agent's behaviour.
- The LLM (
Print confirmation: The final lines confirm that the agent was created successfully and display the names of all available tools.
The agent_executor object returned by this function is what we will use to send questions to the agent. It handles the entire loop of reasoning, tool calling, and response generation.
# Merging the tools into one list
tools = [search_company_policies] + sql_tools
# Creating the agent
agent_executor = create_agent(
llm,
tools,
system_prompt="You are a helpful Enterprise Assistant. You have access to two types of tools: "
"1. A tool for searching company policies (HR, IT, etc.). "
"2. A set of tools for querying the company's Northwind SQL database."
)
print("Enterprise Assistant agent created successfully!")
print(f"Agent has access to {len(tools)} tools: {[tool.name for tool in tools]}")
Enterprise Assistant agent created successfully! Agent has access to 5 tools: ['search_company_policies', 'sql_db_query', 'sql_db_schema', 'sql_db_list_tables', 'sql_db_query_checker']
6 Testing the Agent¶
To confirm that our agent works correctly, we need to test it with three distinct types of questions. Each test targets a different capability:
RAG-only test: A question that requires searching the company policy documents. This verifies that the agent correctly identifies when to use the vector store tool and can retrieve relevant information from unstructured text.
SQL-only test: A question that requires querying the Northwind database. This verifies that the agent can inspect the database schema, write valid SQL, and return accurate results from structured data.
Hybrid test: A question that requires information from both sources. This is the most challenging scenario because the agent must decide to call multiple tools and combine their outputs into a coherent answer.
If all three tests pass, we have evidence that the agent can route questions to the correct data source based on the question's content.
6.1 First Test: RAG Query (Policy Lookup)¶
This test asks about sick leave entitlement. The answer exists in our HR policy document, not in the database.
When the agent receives this question, it should:
- Recognise that "sick days" relates to company policy rather than business data.
- Call the
search_company_policiestool with an appropriate search query. - Read the retrieved document chunks and extract the relevant figure.
- Return a clear answer based on the policy text.
If successful, this confirms that the agent can retrieve information from unstructured documents stored in the vector database.
# Test 1: Ask a RAG (policy) question
question_rag = "How many sick days do I get per year?"
response_rag = agent_executor.invoke({
"messages": [
{"role": "user", "content": question_rag}
]
})
print("--- RAG Response ---")
print(f"Question: {question_rag}")
print(f"Answer: {response_rag['messages'][-1].content}")
--- RAG Response --- Question: How many sick days do I get per year? Answer: You are entitled to up to 10 working days of full pay for sick leave in a rolling 12-month period, provided you have passed your probation period. After that, Statutory Sick Pay (SSP) is payable for up to 28 weeks, subject to qualifying conditions.
Looking at the hr_policy.md file, we can verify if the agent's answer is correct:
We can see that the policy states employees are entitled to 10 sick days per year, confirming that our agent has passed the RAG Query test.
6.2 Second Test: SQL Query (Database Lookup)¶
This test asks which employee has the highest total sales. The answer requires aggregating data from multiple tables in the Northwind database.
When the agent receives this question, it should:
- Recognise that "sales" and "employee" refer to business data stored in the database.
- Use the SQL tools to inspect the available tables and their columns.
- Write a SQL query that joins the Employee, Order, and OrderDetail tables.
- Execute the query and interpret the results.
- Return the employee's name and their total sales figure.
If successful, this confirms that the agent can construct and execute multi-table SQL queries to answer business questions.
# Test 2: Ask a SQL (database) question
question_sql = "Which employee has the most sales? Show their name and total sales amount."
response_sql = agent_executor.invoke({
"messages": [
{"role": "user", "content": question_sql}
]
})
print("--- SQL Response ---")
print(f"Question: {question_sql}")
print(f"Answer: {response_sql['messages'][-1].content}")
--- SQL Response --- Question: Which employee has the most sales? Show their name and total sales amount. Answer: The employee with the most sales is Margaret Peacock, with a total sales amount of 232,890.85.
To confirm the agent's answer is correct, we run the same query manually against the database. This gives us a ground truth to compare against.
How the SQL query works:
SELECT ... AS: We combine the first and last name into a single column calledEmployeeName. TheSUM(...)calculates the total sales for each employee, accounting for discounts.FROM "Employee" e: We start with the Employee table and give it a short aliaseso we can reference it easily.JOIN "Order" o ON ...: We connect the Employee table to the Order table. TheONclause specifies that an employee'sIdmust match theEmployeeIdin the order. This links each order to the employee who made it.JOIN "OrderDetail" od ON ...: We then connect each order to its line items in the OrderDetail table. Each order can have multiple products, and this table stores the price and quantity for each.GROUP BY e."Id": We group all rows by employee, so theSUMfunction adds up all sales for each person rather than across the entire table.ORDER BY TotalSales DESC: We sort the results so the highest sales figure appears first.LIMIT 1: We only return the top row, giving us the single employee with the most sales.
# Verifying SQL Answer
sql_query = """
SELECT
e."FirstName" || ' ' || e."LastName" AS EmployeeName,
SUM(od."UnitPrice" * od."Quantity" * (1 - od."Discount")) AS TotalSales
FROM "Employee" e
JOIN "Order" o ON e."Id" = o."EmployeeId"
JOIN "OrderDetail" od ON o."Id" = od."OrderId"
GROUP BY e."Id"
ORDER BY TotalSales DESC
LIMIT 1;
"""
print(f"Executing manual query on '{target_db_name}'...")
conn = None
try:
conn = sqlite3.connect(target_db_name)
cursor = conn.cursor()
cursor.execute(sql_query)
result = cursor.fetchone()
print("\n---Manual SQL: Top Salesperson---")
if result:
print(f"Employee: {result[0]}, Total Sales: {result[1]:.2f}")
else:
print("No data returned from manual query.")
except sqlite3.Error as e:
print(f"An error occurred: {e}")
finally:
if conn:
conn.close()
Executing manual query on 'northwind.db'... ---Manual SQL: Top Salesperson--- Employee: Margaret Peacock, Total Sales: 232890.85
We can see that the manual SQL query returns the same top salesperson and sales figure as the agent, confirming it has passed the SQL Query test.
6.3 Third Test: Hybrid Query (Combined Sources)¶
This test asks about the work-from-home policy for a specific employee. It mentions both a named individual (database) and a company policy (documents).
When the agent receives this question, it should:
- Recognise that the question spans two data sources.
- Optionally verify whether "Margaret Peacock" exists in the database (though the question already names her).
- Call the
search_company_policiestool to find work-from-home policy information. - Combine the findings into a single response.
This test is important because real-world questions often require information from multiple systems. An effective enterprise agent must handle these cross-domain queries without requiring the user to ask separate questions.
# Test 3: Ask a complex, hybrid question
question_hybrid = "What is the work-from-home policy for our top salesperson, Margaret Peacock?"
response_hybrid = agent_executor.invoke({
"messages": [
{"role": "user", "content": question_hybrid}
]
})
print("--- Hybrid Response ---")
print(f"Question: {question_hybrid}")
print(f"Answer: {response_hybrid['messages'][-1].content}")
--- Hybrid Response --- Question: What is the work-from-home policy for our top salesperson, Margaret Peacock? Answer: The work-from-home policy at Northwind Traders allows roles deemed suitable for hybrid working, such as administrative, finance, HR, and IT, to work from home up to two days per week. Core office days are Tuesday, Wednesday, and Thursday, which are designated for collaboration. Employees must ensure they have a suitable, private workspace and a stable internet connection when working remotely. Margaret Peacock, our top salesperson, holds the title of "Sales Representative." As a sales representative, she may be eligible for the hybrid working model, allowing her to work from home up to two days per week, depending on departmental policies and her specific role requirements.
In order to verify the agent's answer, first we can run an SQL query to confirm Margaret Peacock's job title.
# --- SQL Query: Find Margaret Peacock's title ---
print("---SQL Verification: Margaret Peacock's Title---")
sql_query_margaret = """
SELECT "FirstName", "LastName", "Title"
FROM "Employee"
WHERE "FirstName" = 'Margaret' AND "LastName" = 'Peacock';
"""
conn = None
try:
conn = sqlite3.connect(target_db_name)
cursor = conn.cursor()
cursor.execute(sql_query_margaret)
result = cursor.fetchone()
if result:
print(f"Employee: {result[0]} {result[1]}")
print(f"Title: {result[2]}")
else:
print("No employee found with name 'Margaret Peacock'.")
except sqlite3.Error as e:
print(f"An error occurred: {e}")
finally:
if conn:
conn.close()
---SQL Verification: Margaret Peacock's Title--- Employee: Margaret Peacock Title: Sales Representative
Next, we can check the HR policy document to see what it says about work-from-home arrangements.
Checking the agent's answer against both sources, we can see that Margaret Peacock is indeed a Sales Representative, and the policy allows work-from-home arrangements. This confirms that our agent has passed the Hybrid Query test.
7 Conclusion¶
This notebook demonstrated how to build an AI-powered enterprise assistant capable of answering questions from both structured and unstructured data sources. By combining a FAISS vector store for document retrieval with LangChain's SQL toolkit for database queries, we created an agent that can route questions to the appropriate source without manual intervention. The three test cases confirmed that the agent handles policy lookups, database queries, and hybrid questions requiring information from both systems.
The architecture presented here is intentionally straightforward. In a production environment, several enhancements would be worth considering: adding authentication and access controls, implementing query caching for frequently asked questions, and fine-tuning the retrieval parameters based on user feedback. The same pattern could be extended to incorporate additional data sources such as APIs, spreadsheets, or real-time data feeds.
Whilst this proof of concept uses a small sample database and a handful of policy documents, the underlying approach scales to enterprise environments where data is scattered across dozens of systems that employees must navigate daily.
8 References¶
- LangChain Documentation: https://docs.langchain.com/oss/python/langchain/overview
- LangChain SQL Toolkit: https://docs.langchain.com/oss/python/integrations/tools/sql_database
- FAISS (Facebook AI Similarity Search): https://github.com/facebookresearch/faiss
- HuggingFace Sentence Transformers: https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2
- Northwind Database: https://github.com/jpwhite3/northwind-SQLite3
- OpenAI API: https://platform.openai.com/docs/