Skip to content

Commit

Permalink
Feat: added new advanced convo qa runnables use case doc (#2889)
Browse files Browse the repository at this point in the history
  • Loading branch information
bracesproul authored Oct 12, 2023
1 parent 2039f07 commit ab00ff3
Show file tree
Hide file tree
Showing 2 changed files with 482 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
# Advanced Conversational QA

import CodeBlock from "@theme/CodeBlock";
import AdvancedConversationalQAExample from "@examples/use_cases/advanced/conversational_qa.ts";

Conversing with LLMs is a great way to demonstrate their capabilities. Adding chat history and external context can exponentially increase the complexity of the conversation.
In this example, we'll show how to use `Runnables` to construct a conversational QA system that can answer questions, remember previous chats, and utilize external context.

The first step is to load our context (in this example we'll use the State Of The Union speech from 2022). This is also a good place to instantiate our retriever, and memory classes.
```typescript
import { ChatOpenAI } from "langchain/chat_models/openai";
import { HNSWLib } from "langchain/vectorstores/hnswlib";
import { OpenAIEmbeddings } from "langchain/embeddings/openai";
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
import { BufferMemory } from "langchain/memory";
import * as fs from "fs";

/* Initialize the LLM to use to answer the question */
const model = new ChatOpenAI({});
/* Load in the file we want to do question answering over */
const text = fs.readFileSync("state_of_the_union.txt", "utf8");
/* Split the text into chunks */
const textSplitter = new RecursiveCharacterTextSplitter({ chunkSize: 1000 });
const docs = await textSplitter.createDocuments([text]);
/* Create the vectorstore and initialize it as a retriever */
const vectorStore = await HNSWLib.fromDocuments(docs, new OpenAIEmbeddings());
const retriever = vectorStore.asRetriever();
/* Initialize our BufferMemory store */
const memory = new BufferMemory({
memoryKey: "chatHistory",
});
```

Next, we will need some helper utils to serialize our context (converting inputs to strings).

```typescript
import { Document } from "langchain/document";

/* Ensure our chat history is always passed in as a string */
const serializeChatHistory = (chatHistory: string | Array<string>) => {
if (Array.isArray(chatHistory)) {
return chatHistory.join("\n");
}
return chatHistory;
};

/* Extract the content from Documents */
const serializeDocs = (docs: Array<Document>): string =>
docs.map((doc) => doc.pageContent).join("\n");
```

Our conversational system performs two main LLM queries.
The first is the question answering: given some context and chat history, answer the user's question.
The second is when the LLM is passed chat history. In that case, the LLM will respond with a better formatted question,
utilizing past history and the current question.
Let's create our prompts for both.

```typescript
import { PromptTemplate } from "langchain/prompts";

/**
* Create a prompt template for generating an answer based on context and
* a question.
*
* Chat history will be an empty string if it's the first question.
*
* inputVariables: ["chatHistory", "context", "question"]
*/
const questionPrompt = PromptTemplate.fromTemplate(
`Use the following pieces of context to answer the question at the end. If you don't know the answer, just say that you don't know, don't try to make up an answer.
----------------
CHAT HISTORY: {chatHistory}
----------------
CONTEXT: {context}
----------------
QUESTION: {question}
----------------
Helpful Answer:`);

/**
* Creates a prompt template for __generating a question__ to then ask an LLM
* based on previous chat history, context and the question.
*
* inputVariables: ["chatHistory", "question"]
*/
const questionGeneratorTemplate =
PromptTemplate.fromTemplate(`Given the following conversation and a follow up question, rephrase the follow up question to be a standalone question.
----------------
CHAT HISTORY: {chatHistory}
----------------
FOLLOWUP QUESTION: {question}
----------------
Standalone question:`);
```

Now we can start writing our main question answering sequence. For this, we'll put everything together using a `RunnableSequence` and one helper function
that abstracts the last processing step.

```typescript
import { RunnableSequence } from "langchain/schema/runnable";
import { StringOutputParser } from "langchain/schema/output_parser";
import { LLMChain } from "langchain/chains";

/**
* A helper function which performs the LLM call and saves the context to memory.
*/
const handleProcessQuery = async (input: {
question: string;
context: string;
chatHistory?: string | Array<string>;
}) => {
const chain = new LLMChain({
llm: model,
prompt: questionPrompt,
outputParser: new StringOutputParser(),
});

const { text } = await chain.call({
...input,
chatHistory: serializeChatHistory(input.chatHistory ?? ""),
});

await memory.saveContext(
{
human: input.question,
},
{
ai: text,
}
);

return text;
};

const answerQuestionChain = RunnableSequence.from([
{
question: (input: {
question: string;
chatHistory?: string | Array<string>;
}) => input.question,
},
{
question: (previousStepResult: {
question: string;
chatHistory?: string | Array<string>;
}) => previousStepResult.question,
chatHistory: (previousStepResult: {
question: string;
chatHistory?: string | Array<string>;
}) => serializeChatHistory(previousStepResult.chatHistory ?? ""),
context: async (previousStepResult: {
question: string;
chatHistory?: string | Array<string>;
}) => {
// Fetch relevant docs and serialize to a string.
const relevantDocs = await retriever.getRelevantDocuments(
previousStepResult.question
);
const serialized = serializeDocs(relevantDocs);
return serialized;
},
},
handleProcessQuery,
]);
```

In the above code we're using a `RunnableSequence` which takes in one `question` input.
This input then gets piped to the next step where we perform the following operations:
1. Pass the question through unchanged.
2. Serialize the chat history into a string, if it's been passed in.
3. Fetch relevant documents from the retriever and serialize them into a string.

After this we can create a `RunnableSequence` for generating questions based on past history and the current question.

```typescript
const generateQuestionChain = RunnableSequence.from([
{
question: (input: {
question: string;
chatHistory: string | Array<string>;
}) => input.question,
chatHistory: async () => {
const memoryResult = await memory.loadMemoryVariables({});
return serializeChatHistory(memoryResult.chatHistory ?? "");
},
},
questionGeneratorTemplate,
model,
// Take the result of the above model call, and pass it through to the
// next RunnableSequence chain which will answer the question
{
question: (previousStepResult: { text: string }) =>
previousStepResult.text,
},
answerQuestionChain,
]);
```

The steps taken here are largely the same. We're taking a `question` as an input, and querying our memory store for the `chatHistory`.
Next we pipe those values into our prompt template, and then the LLM model for performing the request.
Finally, we take the result of the LLM query (unparsed) and pass it to the `answerQuestionChain` as a key-value pair where the key is `question`.


Now that we have our two main operations defined, we can create a `RunnableBranch` which given two inputs, it performs the first `Runnable` where the check function returns true.
We also have to pass a fallback `Runnable` for cases where all checks return false (this should never occur in practice with our specific example).

```typescript
import { RunnableBranch } from "langchain/schema/runnable";

const branch = RunnableBranch.from([
[
async () => {
const memoryResult = await memory.loadMemoryVariables({});
const isChatHistoryPresent = !memoryResult.chatHistory.length;

return isChatHistoryPresent;
},
answerQuestionChain,
],
[
async () => {
const memoryResult = await memory.loadMemoryVariables({});
const isChatHistoryPresent =
!!memoryResult.chatHistory && memoryResult.chatHistory.length;

return isChatHistoryPresent;
},
generateQuestionChain,
],
answerQuestionChain,
]);
```

The checks are fairly simple. We're just checking if the chat history is present or not.

Lastly we create our full chain which takes in a question, runs the `RunnableBranch` to determine which `Runnable` to use, and then returns the result!

```typescript
/* Define our chain which calls the branch with our input. */
const fullChain = RunnableSequence.from([
{
question: (input: { question: string }) => input.question,
},
branch,
]);

/* Invoke our `Runnable` with the first question */
const resultOne = await fullChain.invoke({
question: "What did the president say about Justice Breyer?",
});

console.log({ resultOne });
/**
* {
* resultOne: 'The president thanked Justice Breyer for his service and described him as an Army veteran, Constitutional scholar, and retiring Justice of the United States Supreme Court.'
* }
*/

/* Invoke our `Runnable` again with a followup question */
const resultTwo = await fullChain.invoke({
question: "Was it nice?",
});

console.log({ resultTwo });
/**
* {
* resultTwo: "Yes, the president's description of Justice Breyer was positive."
* }
*/
```

That's it! Now we can have a full contextual conversation with our LLM.

The full code for this example can be found below.

<CodeBlock language="typescript">{AdvancedConversationalQAExample}</CodeBlock>
Loading

0 comments on commit ab00ff3

Please sign in to comment.