AI Tutorial
Home All Courses Blog Interview Prep ↗ 0%
1
Curious builder 0 XP earned · 300 to level 2
0 days Finish a lesson to begin
Badge collection
0 of 5 unlocked
small wins to finish your path
Next lesson →

Files and paths

Every retrieval-augmented generation (RAG) system, fine-tuning job, and evaluation run starts with files on disk: PDFs converted to text, Markdown notes, CSV exports, JSONL datasets. This lesson teaches you to locate, read, write, and organise those files reliably with Python's modern pathlib module, and finishes by splitting a document into overlapping chunks ready for embedding.

Last updated: 01 Nov, 2026 · Python 3.11+

Core concepts

Definition · File

A named sequence of bytes stored on disk. Text files (.txt, .md, .csv, .json) contain bytes that represent characters in some encoding; binary files (.pdf, .png, .npy) contain bytes in a format-specific layout.

Definition · Path

The address of a file or folder, such as data/raw/intro.txt. An absolute path starts at the root of the filesystem (/Users/you/ai-lab/data or C:\Users\you\ai-lab\data); a relative path is interpreted from the current working directory.

Definition · Current working directory (cwd)

The folder your program considers “here” when it resolves relative paths. It is the folder you were in when you ran python, not necessarily the folder that contains the script.

Definition · Encoding

The rule that maps characters to bytes. UTF-8 is the universal standard: it can represent every language and emoji, and it is what you should always use for text in AI projects.

Why pathlib?

Older Python code builds paths by gluing strings together with os.path.join. The pathlib module, part of the standard library, represents a path as a Path object with methods for everything you need. It works identically on macOS, Linux, and Windows, and it reads naturally.

TaskOld style (os / open)pathlib
Join pathsos.path.join("data", "a.txt")Path("data") / "a.txt"
File nameos.path.basename(p)p.name
Extensionos.path.splitext(p)[1]p.suffix
Exists?os.path.exists(p)p.exists()
Read textopen(p).read()p.read_text(encoding="utf-8")
Find filesglob.glob("data/*.md")Path("data").glob("*.md")

Building and inspecting paths

The / operator joins path segments. Creating a Path does not touch the disk — it is just an address until you read or write.

Python
from pathlib import Path

p = Path("data") / "raw" / "report.final.txt"

print(p)               # the full path
print(p.name)          # file name with extension
print(p.stem)          # file name without the last extension
print(p.suffix)        # last extension
print(p.suffixes)      # all extensions
print(p.parent)        # containing folder
print(p.parts)         # every segment
print(p.with_suffix(".md"))
data/raw/report.final.txt report.final.txt report.final .txt ['.final', '.txt'] data/raw ('data', 'raw', 'report.final.txt') data/raw/report.final.md

On Windows the printed path uses backslashes (data\raw\report.final.txt); your code stays exactly the same.

The working directory trap

A relative path like Path("data/docs.txt") is resolved from the current working directory. If you run python scripts/prep.py from the project root it works, but run it from inside scripts/ and it fails with FileNotFoundError. The robust fix is to build paths relative to the script file itself:

scripts/prep.py
from pathlib import Path

print("cwd:", Path.cwd())

SCRIPT_DIR = Path(__file__).resolve().parent     # folder containing this file
PROJECT_ROOT = SCRIPT_DIR.parent                 # one level up
DATA_DIR = PROJECT_ROOT / "data"

print("data dir:", DATA_DIR)
cwd: /Users/you/ai-lab data dir: /Users/you/ai-lab/data

__file__ is the path of the current script, and .resolve() turns it into an absolute path with any .. segments and symbolic links resolved. Now DATA_DIR is correct no matter where you launch the script from.

Note

__file__ does not exist inside Jupyter notebooks. In a notebook, use Path.cwd(), which is the notebook's folder by default.

Checking and creating folders

Python
from pathlib import Path

out_dir = Path("output") / "chunks"
print(out_dir.exists())

out_dir.mkdir(parents=True, exist_ok=True)   # create it and any missing parents
print(out_dir.exists(), out_dir.is_dir(), out_dir.is_file())
False True True False
  • parents=True creates every missing folder along the way, like mkdir -p.
  • exist_ok=True means “do not complain if the folder is already there”, so your script can run many times safely.

Reading and writing whole files

For small and medium files, write_text() and read_text() are the simplest tools. They open the file, do the work, and close it for you.

Python
from pathlib import Path

note = Path("notes") / "intro.txt"
note.parent.mkdir(parents=True, exist_ok=True)

note.write_text("Retrieval needs clean chunks.\nChunks need good boundaries.\n", encoding="utf-8")

text = note.read_text(encoding="utf-8")
print(text)
print(len(text.splitlines()), "lines,", len(text), "characters")
Retrieval needs clean chunks. Chunks need good boundaries. 2 lines, 59 characters
Watch out

Always pass encoding="utf-8". Without it, Python uses the operating system's default — often UTF-8 on macOS and Linux but a legacy code page on many Windows machines — so the same script can corrupt Hindi, Chinese, or emoji text on one computer and work fine on another. Also note that write_text overwrites the file completely.

open() and context managers

Definition · Context manager

An object used with the with statement that sets something up and guarantees it is cleaned up afterwards. For files, with open(...) as f: opens the file and always closes it when the block ends — even if an error occurs inside the block.

Use open() when you need more control: appending, writing piece by piece, or reading a very large file line by line.

ModeMeaningIf the file existsIf it does not exist
"r"Read text (default)Reads itFileNotFoundError
"w"Write textErases it firstCreates it
"a"Append textAdds to the endCreates it
"x"Exclusive createFileExistsErrorCreates it
"rb" / "wb"Read / write raw bytes——
Python
from pathlib import Path

log = Path("run.log")

with log.open("w", encoding="utf-8") as f:
    f.write("started\n")

for step in ["embed", "index", "query"]:
    with log.open("a", encoding="utf-8") as f:
        f.write(f"done: {step}\n")

print(log.read_text(encoding="utf-8"))
started done: embed done: index done: query

Reading large files line by line

A file object is an iterator over its lines. Looping over it reads one line at a time, so even a 20 GB log or dataset uses almost no memory.

Python
from pathlib import Path

path = Path("corpus.txt")
path.write_text("first line\n\nsecond line\nthird line\n", encoding="utf-8")

non_empty = 0
with path.open(encoding="utf-8") as f:
    for line_no, line in enumerate(f, start=1):
        line = line.rstrip("\n")
        if not line:
            continue
        non_empty += 1
        print(line_no, line)

print("non-empty lines:", non_empty)
1 first line 3 second line 4 third line non-empty lines: 3

Finding files

iterdir() lists a folder's direct children. glob(pattern) matches names with wildcards, and rglob(pattern) searches every subfolder recursively.

PatternMatches
*.mdAll .md files in this folder
**/*.md or rglob("*.md")All .md files in this folder and every subfolder
chunk_??.txt? matches exactly one character: chunk_01.txt
*Everything in this folder
Python
from pathlib import Path

root = Path("kb")
for rel in ["intro.md", "guides/rag.md", "guides/agents.md", "guides/notes.txt"]:
    p = root / rel
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_text(f"# {p.stem}\n", encoding="utf-8")

print(sorted(p.name for p in root.glob("*.md")))
print(sorted(p.as_posix() for p in root.rglob("*.md")))
print(sorted(p.name for p in root.iterdir()))
['intro.md'] ['kb/guides/agents.md', 'kb/guides/rag.md', 'kb/intro.md'] ['guides', 'intro.md']

glob returns results in whatever order the filesystem provides, which differs between machines. Wrap it in sorted() so your pipeline produces the same output every run. as_posix() prints forward slashes on every operating system.

File size and metadata

Python
from pathlib import Path

for p in sorted(Path("kb").rglob("*.md")):
    print(f"{p.as_posix():<22} {p.stat().st_size:>4} bytes")
kb/guides/agents.md 9 bytes kb/guides/rag.md 6 bytes kb/intro.md 8 bytes

CSV files

Definition · CSV

Comma-Separated Values: a plain-text table where each line is a row and commas separate the columns. The first line usually holds the column names. Spreadsheets and labelling tools export CSV constantly.

Never split CSV lines with line.split(",") yourself — fields can contain commas inside quotes. Use the standard csv module. DictWriter and DictReader let you work with each row as a dict.

Python
import csv
from pathlib import Path

rows = [
    {"question": "What is RAG?", "answer": "Retrieval, then generation", "score": 0.9},
    {"question": "Define chunking", "answer": "Splitting text, with overlap", "score": 0.7},
]

path = Path("eval.csv")
with path.open("w", newline="", encoding="utf-8") as f:
    writer = csv.DictWriter(f, fieldnames=["question", "answer", "score"])
    writer.writeheader()
    writer.writerows(rows)

print(path.read_text(encoding="utf-8"))

with path.open(newline="", encoding="utf-8") as f:
    for row in csv.DictReader(f):
        print(row["question"], "->", float(row["score"]))
question,answer,score What is RAG?,"Retrieval, then generation",0.9 Define chunking,"Splitting text, with overlap",0.7 What is RAG? -> 0.9 Define chunking -> 0.7

Notice that the module automatically quoted answers containing commas. Also notice that DictReader returns every value as a string — convert numbers yourself with float() or int(). Passing newline="" stops blank lines appearing between rows on Windows.

Handling encoding problems

Real-world documents are messy. Exports from old systems may not be valid UTF-8, and reading them strictly raises UnicodeDecodeError. You have three options: fix the source, specify the correct encoding (for example encoding="latin-1"), or tell Python how to handle bad bytes.

Python
from pathlib import Path

path = Path("legacy.txt")
path.write_bytes("café ".encode("utf-8") + b"\xff\xfe broken")

try:
    path.read_text(encoding="utf-8")
except UnicodeDecodeError as err:
    print("strict read failed:", err.reason)

print(path.read_text(encoding="utf-8", errors="replace"))
strict read failed: invalid start byte café �� broken

errors="replace" swaps undecodable bytes for the replacement character � so a single bad byte does not crash a pipeline of thousands of files. Log which files needed it so you can investigate later.

Chunking a document for RAG

Definition · Chunking

Splitting a long document into smaller, overlapping pieces before embedding them. Embedding models have input limits, and smaller chunks make retrieval more precise; overlap repeats a few words between neighbouring chunks so a sentence cut at a boundary is still found intact in one of them.

Here is a simple, dependable word-based chunker. Production systems often split on sentences, headings, or tokens instead, but the sliding-window idea is the same.

Python
from pathlib import Path


def chunk_words(text: str, size: int = 8, overlap: int = 2) -> list[str]:
    if overlap >= size:
        raise ValueError("overlap must be smaller than size")
    words = text.split()
    step = size - overlap
    chunks = []
    for start in range(0, len(words), step):
        chunks.append(" ".join(words[start:start + size]))
        if start + size >= len(words):
            break
    return chunks


doc = Path("doc.txt")
doc.write_text(
    "Retrieval augmented generation first searches a knowledge base for "
    "relevant passages and then asks the language model to answer using "
    "only those passages as grounding context.",
    encoding="utf-8",
)

for i, chunk in enumerate(chunk_words(doc.read_text(encoding="utf-8")), start=1):
    print(f"[{i}] {chunk}")
[1] Retrieval augmented generation first searches a knowledge base [2] knowledge base for relevant passages and then asks [3] then asks the language model to answer using [4] answer using only those passages as grounding context.

Look at the joins: each chunk starts with the last two words of the previous one. That is the overlap at work. Typical real settings are 200–800 tokens per chunk with 10–20% overlap.

Writing files safely

If a script crashes halfway through writing an output file, you are left with a corrupted, half-written file. The standard fix is an atomic write: write to a temporary file in the same folder, then rename it over the target in one step. A rename is atomic, so readers see either the old file or the complete new one — never a partial file.

Python
import json
from pathlib import Path


def write_json_atomic(path: Path, data) -> None:
    tmp = path.with_suffix(path.suffix + ".tmp")
    tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
    tmp.replace(path)          # atomic rename over the destination


target = Path("index_meta.json")
write_json_atomic(target, {"chunks": 5, "model": "text-embedding-3-small"})
print(target.read_text(encoding="utf-8"))
{ "chunks": 5, "model": "text-embedding-3-small" }

Renaming, moving, and deleting

OperationpathlibNotes
Rename / move a filep.rename(new_path)Returns the new Path.
Replace a filep.replace(target)Overwrites target if it exists.
Delete a filep.unlink(missing_ok=True)missing_ok avoids an error if it is already gone.
Delete an empty folderp.rmdir()Fails if the folder is not empty.
Delete a folder treeshutil.rmtree(p)Permanent — no recycle bin.
Copy a fileshutil.copy2(src, dst)Also copies timestamps.
Watch out

unlink() and shutil.rmtree() delete permanently. In scripts that clean up generated data, print the paths first or add a --dry-run flag, and never build a deletion path from unchecked user input.

Try it
  1. Create a kb/ folder with a few .md files in nested subfolders.
  2. Write a script that uses rglob to find every Markdown file and prints its relative path, line count, and size in bytes, sorted by size.
  3. Chunk each file with chunk_words(size=50, overlap=10) and write all chunks to chunks.jsonl with fields source, chunk_index, and text.
Key takeaways
  • Represent locations with pathlib.Path and join them with /.
  • Build paths from Path(__file__).resolve().parent so scripts work from any working directory.
  • Always read and write text with encoding="utf-8"; use errors="replace" for messy legacy data.
  • Use with open(...) for appending and line-by-line streaming of large files.
  • Use the csv module for CSV and wrap glob results in sorted() for reproducible pipelines.
  • Chunk long documents with overlap, and write important outputs atomically.