I Learned Rust by Rebuilding My RAG Pipeline — Here's What Python Was Hiding From You
The standard Rust advice: read the book, do the exercises, build a todo app. I tried something different — I took a RAG pipeline I'd already built in Python and rebuilt the core of it in Rust. Same operations, same payload schema, same re-ranking logic. Familiar problem, unfamiliar language.
The bet: learning is faster when you already know what you're trying to build.
The pipeline was the retrieval layer from AskS1.com — a tool for querying the SpaceX S-1 filing. It embeds a user's question, searches a Qdrant vector store for the most relevant chunks, applies a re-ranking penalty to summary pages, and returns the top results. About 50 lines of Python. I rebuilt it in Rust across 8 hours.
Here's what the borrow checker taught me that 4 years of Python didn't.
The Three Things Python Was Hiding
1. String ownership
In Python, passing a string to a function and using it afterward is unremarkable:
python
def takes(s):
print(s)
s = "hello"
takes(s)
print(s) # works fineThe same code in Rust is a compile error:
rust
fn takes(s: String) {
println!("{}", s);
}
let s = String::from("hello");
takes(s);
println!("{}", s); // error: value borrowed after moveThe error message:
value borrowed here after move
move occurs because `s` has type `String`,
which does not implement the `Copy` traitWhat Python was hiding: when you pass a String to a function, ownership transfers. The original variable is gone. Python's garbage collector tracks references automatically — you never see this. Rust makes it explicit at compile time.
The fix is either to clone (takes(s.clone())) or borrow (takes(&s)). Clone creates a new heap allocation. Borrow passes a reference — no allocation, no ownership transfer, cheaper. In Python, every string argument is effectively a borrow without you knowing it. In Rust, you choose.
This took 20 minutes to understand. It will probably change how I read Python code for the rest of my career.
2. Mutation while borrowed
Python lets you do this without complaint:
python
v = [1, 2, 3]
first = v[0] # get a reference to the first element
v.append(4) # mutate the list
print(first) # still worksRust blocks it:
rust
let mut v = vec![1, 2, 3];
let first = &v[0];
v.push(4); // error: cannot borrow `v` as mutable
// because it is also borrowed as immutable
println!("{}", first);At first this feels like the borrow checker being pedantic. It isn't. The reason: push might reallocate the Vec's memory if it grows beyond its current capacity. If that happens, first would be pointing at freed memory — a use-after-free bug. Python's runtime handles this by tracking all references and preventing deallocation. Rust catches it at compile time with zero runtime cost.
The fix: use first before mutating.
rust
let mut v = vec![1, 2, 3];
let first = &v[0];
println!("{}", first); // use borrow here — ends after this line
v.push(4); // now safeWhat Python was hiding: every time you modify a list while holding a reference to one of its elements, Python's runtime is doing work to keep that safe. Rust eliminates that work by making the constraint explicit.
3. Reference vs. value
This one is subtle and shows up constantly in real Qdrant code.
When you iterate over a Vec of tuples, the iterator yields references to each tuple. If the tuple contains an integer, destructuring gives you a reference to that field — &i64 — not the integer itself:
rust
let chunks = vec![
("Starlink revenue was $11.4B", 89_i64),
("SpaceX launched 165 times in 2025", 125_i64),
];
for (text, page) in chunks.iter() {
// page is &i64 here, not i64 — iterating with .iter() borrows
// each tuple, and destructuring binds a reference to each field
// rather than moving the value out. To use page as a plain i64,
// dereference it with *page.
println!("Page: {}", *page);
}In Python this is invisible — iteration just gives you the value. In Rust, .iter() borrows rather than moves, so destructuring a tuple reference still yields references to its fields, not owned values. Forgetting the * when you need the actual value is a type error:
error: expected `i64`, found `&i64`This appeared in the actual Qdrant payload code when building the re-ranking function. Payload values come back from the vector store as references, and extracting integers requires pattern matching through the reference layer:
rust
let page = point.payload
.get("page")
.and_then(|v| match &v.kind {
Some(Kind::IntegerValue(i)) => Some(*i), // *i dereferences &i64 to i64
_ => None,
})
.unwrap_or(0);Python never shows you this. The interpreter handles dereferencing automatically at every step. Rust shows you every level of indirection explicitly, which is verbose but makes memory layout legible.
The Same RAG Operation in Both Languages
Here's the core of the retrieve() function in Python — the version running on AskS1:
python
def retrieve(query, top_k=5):
query_vector = embed_model.encode([query])[0].tolist()
results = qdrant.query_points(
collection_name=COLLECTION,
query=query_vector,
limit=15
)
def score(r):
page = r.payload.get('page', 0)
penalty = 0.15 if page < 30 else 0
return r.score - penalty
reranked = sorted(results.points, key=score, reverse=True)
return [
{"text": r.payload["text"], "page": r.payload["page"]}
for r in reranked[:top_k]
]The Rust equivalent:
rust
async fn retrieve(
client: &Qdrant,
query_vector: Vec<f32>,
) -> Result<Vec<(f32, i64, String)>, Box<dyn std::error::Error>> {
let results = client.query(
QueryPointsBuilder::new("rust_warmup")
.query(query_vector)
.limit(15)
.with_payload(true)
).await?;
let mut scored: Vec<(f32, i64, String)> = results.result
.iter()
.map(|point| {
let page = point.payload
.get("page")
.and_then(|v| match &v.kind {
Some(Kind::IntegerValue(i)) => Some(*i),
_ => None,
})
.unwrap_or(0);
let text = point.payload
.get("text")
.and_then(|v| match &v.kind {
Some(Kind::StringValue(s)) => Some(s.clone()),
_ => None,
})
.unwrap_or_default();
let penalty = if page < 30 { 0.15 } else { 0.0 };
(point.score - penalty, page, text)
})
.collect();
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap());
Ok(scored.into_iter().take(5).collect())
}The Rust version is twice as long. Every line is explicit about what it does — ownership of query_vector transfers into the builder, payload values are pattern-matched through their type variants, the sort is explicit about comparison direction.
The Python version is more readable. The Rust version has no hidden allocations, no silent failures, and the compiler guarantees the payload extraction logic is correct before the program ever runs.
Neither is better. They're optimized for different things.
Where Both Languages Converge
The actual business logic is identical:
python
# Python
penalty = 0.15 if page < 30 else 0.0
score = similarity - penaltyrust
// Rust
let penalty = if page < 30 { 0.15 } else { 0.0 };
let adjusted = score - penalty;Once you get past the ownership layer, the domain logic looks the same. Rust's complexity is front-loaded — it makes you think about memory once, at the type system level, so you never think about it again at runtime. Python defers that complexity to the garbage collector, which handles it silently every time your code runs.
Neither approach is free. Python pays at runtime. Rust pays at development time.
Should Python AI Engineers Learn Rust?
Honest answer: not for application code. Python wins on velocity, ecosystem, and AI tooling integration for the foreseeable future. If you're building a RAG pipeline, a fine-tuning script, or an agent — use Python.
But two cases where it's worth the investment:
You're building infrastructure. Vector databases, embedding pipelines, inference servers. Qdrant is Rust. The new generation of AI agent frameworks — Rig, AutoAgents, OpenFANG — are Rust. If you're building the engine others build on, Rust's performance and memory safety matter in ways Python can't match. Per the 2025 State of Rust Survey (as reported by The New Stack), 48.8% of organizations now report non-trivial Rust usage in production, up from 38.7% in 2023.The language is no longer experimental.
You want to understand what Python is doing. The borrow checker makes explicit what Python's GC hides. Even if you never ship Rust in production, 8 hours with the borrow checker will change how you think about Python memory, reference semantics, and mutation. The three things Python was hiding — ownership transfer, aliasing rules, reference indirection — are things Python engineers benefit from understanding, even if they never write a line of Rust again.
The "should I learn Rust?" question is slowly becoming "when should I learn Rust?" The answer for most Python AI engineers is probably: not now, but sooner than you think.

