· 5 min read

LangChain Confusion


I built a RAG chatbot with LangChain, but the more I look at langchain code, the more I keep confusing myself, specifically with the RunnableSequence. I just had a breakthrough on why I find it confusing and how I could write it in a way that makes more sense to me.

This example is from Scrimba’s Learn LangChain.js course, which I highly recommend, and it’s free! Note: This is just a portion of the full example code, so you’ll see some things are not defined.

/**
 *  More code above, just not relevant to this example
 */

const standaloneQuestionTemplate = `Given some conversation history (if any) and a question, convert the question to a standalone question.
conversation history: {conv_history}
question: {question}
standalone question:`
const standaloneQuestionPrompt = PromptTemplate.fromTemplate(
    standaloneQuestionTemplate
)

const retrieverChain = '' // Similar chain, not important here
const answerChain = '' // Similar chain, not important here

const standaloneQuestionChain = standaloneQuestionPrompt
    .pipe(llm)
    .pipe(new StringOutputParser())

const chain = RunnableSequence.from([
    {
        standalone_question: standaloneQuestionChain,
        original_input: new RunnablePassthrough(),
    },
    {
        context: retrieverChain,
        question: ({ original_input }) => original_input.question,
        conv_history: ({ original_input }) => original_input.conv_history,
    },
    answerChain,
])

// get user input
// store conversation history

const response = await chain.invoke({
    question: question,
    conv_history: formatConvHistory(convHistory),
})

Let’s walk through this portion of the code. The general idea here is that we want to take the user’s input and convert it into a ‘standalone question’, which just means we want to remove the fluff. So if a user says “I’m looking at buying a jacket from your site, but sometimes they don’t fit me well, so I need to know what your return policy is in case I need to return it. I don’t want to spend money unless I know I can return.” That’s not a super helpful input to use for retrieval, so we can have an LLM convert it to a succinct question like “What is your return policy?”

When we call chain.invoke and pass it an object, LangChain is doing some magic for us (which I find really hurts my understanding of what’s going on). It’s taking that object we pass it:

{
    question: question,
    conv_history: formatConvHistory(convHistory),
}

and automatically passing it to the first step in the chain (where we have const chain = RunnableSequence.from).

The RunnableSequence.from call chains multiple steps together, piping each result as input to the next step.

This is where the magic happens. So far, it’s like we gave the sequence its initial input (again, this happens implicitly), and then gave it a list of steps to call with the result of the previous step. If you’ve used Unix pipes or Elixir, this follows the same pattern.

You’ll notice new RunnablePassthrough() in the code below - it’s a simple tool that just passes its input through unchanged; useful when you want to preserve data for later steps.

const chain = RunnableSequence.from([
    {
        standalone_question: standaloneQuestionChain,
        original_input: new RunnablePassthrough(),
    },
    {
        context: retrieverChain,
        question: ({ original_input }) => original_input.question,
        conv_history: ({ original_input }) => original_input.conv_history,
    },
    answerChain,
])

Let’s look at the first object in that array. Here’s what’s happening: LangChain takes the input object { question, conv_history } and passes it to both standaloneQuestionChain (which needs those fields) and RunnablePassthrough() (which just passes it through unchanged). Then it collects both results into a new object with the keys standalone_question and original_input.

Not only that, but when a RunnableSequence step is an object literal, something special happens: each key’s value gets invoked in parallel with the same input. LangChain collects all the results into a new object. I had Claude make a little diagram to show this:

 Input: { question: "...", conv_history: "..." }

           ┌──────────┴──────────┐
           ↓                     ↓
  standaloneQuestionChain    RunnablePassthrough
           ↓                     ↓
      "standalone Q"      { question, conv_history }
           ↓                     ↓
           └──────────┬──────────┘

  Output: {
      standalone_question: "standalone Q",
      original_input: { question, conv_history }
  }

My big unlock was that you can rewrite this to be completely explicit about what’s flowing where. Here’s the same logic, but with no magic, just clear function inputs and outputs:

const chain = RunnableSequence.from([
    // Step 1: Explicitly create the object we want
    async (input) => ({
        standalone_question: await standaloneQuestionChain.invoke(input),
        original_input: input, // Just pass through directly
    }),

    // Step 2: Build output using previous results
    async (prevResult) => ({
        context: await retrieverChain.invoke(prevResult),
        question: prevResult.original_input.question,
        conv_history: prevResult.original_input.conv_history,
    }),

    // Step 3: Generate answer
    answerChain,
    // Receives: { context, question, conv_history }
    // Could also be written as: async (prevResult) => await answerChain.invoke(prevResult)
])

Here, I’m using arrow functions to show that we’re getting the input from somewhere (that part is still magic, but at least it’s simple), then crafting an object literal, where the value of standalone_question is us explicitly invoking another chain.

This makes a TON more sense to me, and hopefully it illustrates what’s actually going on a little better for you too!