The Problem With the "Old Way"
If you learned RAG (Retrieval-Augmented Generation) from a 2023-era tutorial, you probably wrote something like this:
from langchain.chains import RetrievalQA
qa = RetrievalQA.from_chain_type(llm=llm, retriever=retriever)
result = qa.run("What is RAG?")
Those three lines wire up a retriever, a prompt, and a model, and hand back an answer. But everything downstream was still an opaque .run() call: you couldn't see what got retrieved, stream partial answers, inspect intermediate steps, or run any of this in parallel. It's also deprecated now, moved into a legacy langchain-classic package for backward compatibility only.
What Replaced It: LCEL
LCEL (LangChain Expression Language) is a different philosophy: instead of one big class doing everything internally, you compose small, visible pieces together using the | (pipe) operator, similar to Unix pipes.
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
rag_chain = (
{"context": retriever, "question": RunnablePassthrough()}
| prompt
| llm
| StrOutputParser()
)
answer = rag_chain.invoke("What is RAG?")
At a glance, this looks similar in line count to the old version, but the shape of the system is fundamentally different. You can see every step. You can test each piece independently. You can swap the retriever, the prompt, or the model without touching anything else.
Why the New Way Is Actually Better (Not Just Newer)
1. The chain is as many or as few pieces as you want it to be With the old chain, retriever, the prompt, and the LLM go in as constructor arguments to RetrievalQA.from_chain_type(). You could still inspect each one before building, but once it's built you're left with one object and one qa.run() call, so if the answer was wrong you couldn't tell whether retrieval failed or the prompt was bad, and adding a step (a reranker, say) usually meant reaching for a different chain class if one existed at all. With LCEL, retriever | prompt | llm is just objects piped together, so retriever.invoke("What is RAG?") works mid-pipeline the same as it would standalone, and you can drop in a reranker as another link or collapse links back down, with no separate chain class per shape. That flexibility is what makes the next two advantages possible.
2. Streaming and async come for free Users expect answers to stream token-by-token, not appear all at once after a long pause. With RetrievalQA, you're stuck waiting for the whole thing:
answer = qa.run("What is RAG?") # blocks until the full answer is generated
print(answer)
With an LCEL chain, every Runnable in the pipeline, including the one you built by piping four pieces together, implements the same streaming interface automatically, so you get token-by-token output for free:
for chunk in rag_chain.stream("What is RAG?"):
print(chunk, end="", flush=True) # words appear as the model generates them
That's the difference between a chat UI where the answer pops in all at once after a pause, and one where words appear as the model writes them.
3. It scales up to more complex pipelines naturally Real RAG systems often need more than "retrieve, then generate"; for example, retrieving from multiple sources and merging results. Say your support bot searches both a vector store (semantic matches) and a keyword index (exact product codes, error messages). LCEL's dict-based composition runs both retrievers in parallel with no extra code:
rag_chain = (
{
"vector_context": vector_retriever,
"keyword_context": keyword_retriever,
"question": RunnablePassthrough(),
}
| prompt
| llm
| StrOutputParser()
)
Every entry in that dict is kicked off at the same time. If vector_retriever takes 200ms and keyword_retriever takes 150ms, the whole step takes ~200ms (the slowest branch, not the sum of both) because LCEL runs dict entries concurrently under the hood. RunnablePassthrough() finishes instantly regardless, so it never adds to that wait.
RetrievalQA has no equivalent: it takes exactly one retriever argument, full stop. To combine two sources you'd have to write a custom retriever class that calls both internally and merges the results, and unless you write that class as async yourself, the two calls run one after another, so you'd pay 200ms + 150ms instead of 200ms. LCEL gets you the concurrency by default; the old way makes you build it by hand.
Takeaway
Once you're comfortable composing steps this way, the natural next layer is agentic RAG: the model itself decides whether to retrieve, how many times, and whether the context is good enough to answer with, usually orchestrated with LangGraph instead of a linear chain. That's a big enough shift to warrant its own post, but it builds directly on the composability mindset here.
For today: RetrievalQA still runs, but it's on a deprecated path with no further investment, and you'll hit a wall the moment you need streaming or debugging visibility. If you're starting something new, build it with LCEL: a few more lines, but every one is something you can see, test, and change independently.
