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.
Core concepts
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.
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.
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.
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.
| Task | Old style (os / open) | pathlib |
|---|---|---|
| Join paths | os.path.join("data", "a.txt") | Path("data") / "a.txt" |
| File name | os.path.basename(p) | p.name |
| Extension | os.path.splitext(p)[1] | p.suffix |
| Exists? | os.path.exists(p) | p.exists() |
| Read text | open(p).read() | p.read_text(encoding="utf-8") |
| Find files | glob.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.
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"))
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:
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)
__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.
__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
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())
parents=Truecreates every missing folder along the way, likemkdir -p.exist_ok=Truemeans “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.
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")
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
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.
| Mode | Meaning | If the file exists | If it does not exist |
|---|---|---|---|
"r" | Read text (default) | Reads it | FileNotFoundError |
"w" | Write text | Erases it first | Creates it |
"a" | Append text | Adds to the end | Creates it |
"x" | Exclusive create | FileExistsError | Creates it |
"rb" / "wb" | Read / write raw bytes | — | — |
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"))
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.
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)
Finding files
iterdir() lists a folder's direct children. glob(pattern) matches names with wildcards, and rglob(pattern) searches every subfolder recursively.
| Pattern | Matches |
|---|---|
*.md | All .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 |
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()))
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
from pathlib import Path
for p in sorted(Path("kb").rglob("*.md")):
print(f"{p.as_posix():<22} {p.stat().st_size:>4} bytes")
CSV files
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.
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"]))
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.
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"))
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
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.
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}")
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.
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"))
Renaming, moving, and deleting
| Operation | pathlib | Notes |
|---|---|---|
| Rename / move a file | p.rename(new_path) | Returns the new Path. |
| Replace a file | p.replace(target) | Overwrites target if it exists. |
| Delete a file | p.unlink(missing_ok=True) | missing_ok avoids an error if it is already gone. |
| Delete an empty folder | p.rmdir() | Fails if the folder is not empty. |
| Delete a folder tree | shutil.rmtree(p) | Permanent — no recycle bin. |
| Copy a file | shutil.copy2(src, dst) | Also copies timestamps. |
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.
- Create a
kb/folder with a few.mdfiles in nested subfolders. - Write a script that uses
rglobto find every Markdown file and prints its relative path, line count, and size in bytes, sorted by size. - Chunk each file with
chunk_words(size=50, overlap=10)and write all chunks tochunks.jsonlwith fieldssource,chunk_index, andtext.
- Represent locations with
pathlib.Pathand join them with/. - Build paths from
Path(__file__).resolve().parentso scripts work from any working directory. - Always read and write text with
encoding="utf-8"; useerrors="replace"for messy legacy data. - Use
with open(...)for appending and line-by-line streaming of large files. - Use the
csvmodule for CSV and wrapglobresults insorted()for reproducible pipelines. - Chunk long documents with overlap, and write important outputs atomically.