Last active
February 2, 2024 02:10
-
-
Save heaversm/4fefbf62b01ac3e0eb35d31a30eeaf58 to your computer and use it in GitHub Desktop.
RAGFusion Node / Javascript Implementation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//RRF App Backend | |
//import node / express dependencies | |
import express from "express"; | |
import path from "path"; | |
//you'll need a '.dotenv' file in the root with the following variable: | |
//OPENAI_API_KEY="[YOUR_OPENAI_API_KEY_HERE]" | |
import dotenv from "dotenv"; | |
//import openAI for generating query results | |
import { Configuration, OpenAIApi } from "openai"; | |
import { OpenAIEmbeddings } from "langchain/embeddings/openai"; | |
//import langchain for vector store / search / summarization / orchestration | |
import { Document } from "langchain/document"; | |
//faiss performs a similarity search among documents | |
import { FaissStore } from "langchain/vectorstores/faiss"; | |
import { RetrievalQAChain, loadSummarizationChain } from "langchain/chains"; | |
//set up OpenAI | |
const configuration = new Configuration({ | |
apiKey: process.env.OPENAI_API_KEY, | |
temperature: 0.8, //higher = more creative | |
}); | |
const openai = new OpenAIApi(configuration); | |
const llm = new OpenAI(); //the llm model to use within openAI (currently only openai) | |
//set up langchain. We will assume you have some text documents you want to query | |
import { OpenAI } from "langchain/llms/openai"; | |
let finalDocs; //will hold the output docs (shuffled or unshuffled) | |
//instantiate node app / express | |
const PORT = process.env.PORT || 3001; | |
const app = express(); | |
app.use(express.json()); | |
app.post("/api/performUserQuery", async (req, res) => { | |
//receive the query from the frontend fetch request... | |
const { query } = req.body; | |
//generate multiple queries based on this request... | |
const generated_queries = await generateQueries(query); | |
//get the documents that most closely relate to each alternate generated query... | |
const altQueryDocs = {}; | |
const promises = generatedQueries.map(async (generatedQuery) => { | |
const docsFromAltQuery = await vectorSearch(generatedQuery); | |
// console.log("docsFromAltQuery", docsFromAltQuery); | |
altQueryDocs[`${generatedQuery}`] = docsFromAltQuery; | |
}); | |
//once we have all the documents for each query... | |
Promise.all(promises).then(async () => { | |
//apply the reciprocal rank fusion algorithm... | |
const rankedResults = reciprocalRankFusion(altQueryDocs); | |
//compile a new array of just the documents from the top reciprocal rank fusion results... | |
const finalDocArray = []; | |
for (const key in rankedResults) { | |
const matchingDoc = finalDocs.find((doc) => doc.id === key); | |
if (matchingDoc) { | |
finalDocArray.push(matchingDoc); | |
} | |
} | |
//create a new vector store for these... | |
const rrfVectorStore = await FaissStore.fromDocuments( | |
finalDocArray, | |
new OpenAIEmbeddings() | |
); | |
//compose a new retrieval chain to be able to query just these documents... | |
const rrfRetriever = rrfVectorStore.asRetriever(); | |
const rrfChain = RetrievalQAChain.fromLLM(llm, rrfRetriever); | |
//and perform the query... | |
const rrfChainResponse = await rrfChain.call({ | |
query: query, | |
}); | |
//then return the results back to the frontend | |
return res.status(200).json({ | |
text: rrfChainResponse.text, | |
}); | |
}); | |
}); | |
//supporting functions: | |
//generate alternate queries based on the user's original query: | |
async function generateQueries(originalQuery) { | |
//prompt engineering to help generate a response in the proper form: | |
const query = `You are a helpful assistant that generates alternative queries that could be asked to a large language model related to the users original query: ${originalQuery}. OUTPUT A COMMA SEPARATED LIST (CSV) of 4 alternative queries. The queries themselves should not be listed by number. Do not include the original query in the array`; | |
//pass the query to the llm and receive the new queries | |
const response = await chain.call({ | |
query: query, | |
}); | |
//divide the queries up into individual items | |
const generatedQueries = response.text.trim().split(", "); | |
//output the queries to the console if you want to view them | |
console.log(response.text); | |
//return the queries to the API function | |
return generatedQueries; | |
} | |
//retrieve the most relevant documents based on each alternate query | |
async function vectorSearch(query) { | |
const docs = await vectorStore.similaritySearch(query); | |
docs.forEach((documentObject, index) => { | |
//my docs did not have a unique identifier. | |
//If this is the case for you, you need to assign a unique id to each doc so you can reference them later. | |
//If you already have a unique id, assign it to the 'id' key for each document object | |
documentObject.id = '[YOUR_DOC_ID]' | |
}); | |
//return the docs with their ids to the API function | |
return docs; | |
} | |
//apply the RRF algorithm to the docs, merge any similarities, and return the top results in a single object (fusedScores) | |
function reciprocalRankFusion(altQueryDocs, k = 60) { | |
//will hold the final docs with RRF applied | |
const fusedScores = {}; | |
//for each alternate query we generated, retrieve its docs | |
for (const query in altQueryDocs) { | |
if (altQueryDocs.hasOwnProperty(query)) { | |
//the document itself is docObj: | |
const docObj = altQueryDocs[query]; | |
for (let rank = 0; rank < Object.keys(docObj).length; rank++) { | |
//sort the docs by score | |
const sortedDocs = Object.entries(docObj).sort((a, b) => b[1] - a[1]); | |
//separate the doc into its document object and its score | |
const [score, doc] = sortedDocs[rank]; | |
//get the documentID | |
const docID = doc.id; | |
//find out whether the doc already exists in our fused array: | |
const fusedDoc = fusedScores[docID]; | |
if (!fusedDoc) { | |
//if not, its score is zero | |
fusedScores[docID] = 0; | |
} | |
//get the current score for the doc from the fused array | |
const previousScore = fusedScores[docID]; | |
//increment it by 1. | |
fusedScores[docID] += 1 / (rank + k); | |
} | |
} | |
} | |
} | |
//end rrf function | |
//here's where you will pass in your documents - you'll need to figure out how to do this given your use case. | |
//For me, my 'document' is an audio transcript ('transcript') - one big long string of text | |
//that I break into smaller chunks to be more easily and better queried by the LLM: | |
const prepareDocs = async function (transcript) { | |
const splitter = new RecursiveCharacterTextSplitter({ | |
chunkSize: 256, | |
chunkOverlap: 0, | |
}); | |
const output = await splitter.splitDocuments([ | |
new Document({ pageContent: transcript }), | |
]); | |
//I do a random shuffle so that the beginning and end docs don't end up being more heavily favored than the middle, | |
//but you could skip this: | |
finalDocs = output | |
.map((value) => ({ value, sort: Math.random() })) | |
.sort((a, b) => a.sort - b.sort) | |
.map(({ value }) => value); | |
finalDocs.forEach((documentObject, index) => { | |
//again - your document ID needs to be assigned so that we can compare the results of RRF to our overall list of documents. | |
documentObject.id = ['YOUR_DOCUMENT_ID_HERE']; | |
}); | |
}; | |
//with all our functionality in place, kick off the app: | |
//start our node server: | |
app.listen(PORT, () => { | |
//once our app has started, prepare our documents for retrieval augmented generation | |
prepareDocs(); | |
console.log(`Server listening on ${PORT}`); | |
}); | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!--add the HTML markup to input and submit our query, and output the result--> | |
<label for="query-field">Query:</label> | |
<input type="text" name="query-field" id="query-field"> | |
<button id="submit-button">Submit Query</button> | |
<div id="output"> | |
<!--will hold the output text that comes back from our llm--> | |
</div> | |
<script> | |
//RRF App Frontend | |
//listen for query submission | |
const addEventListeners = ()=>{ | |
document.getElementById('submit-button').addEventListener('click',(e)=>{ | |
e.preventDefault; | |
//get the query from the input field | |
const queryField = document.getElementById('query-field'); | |
const query = queryField.value; | |
//pass the query to our submit function | |
handleQuerySubmit(query); | |
}); | |
} | |
//submit the query to the backend llm and wait for a response | |
const handleQuerySubmit = (query)=>{ | |
//our backend API endpoint route: | |
fetch("/api/performUserQuery", { | |
method: "POST", | |
headers: { | |
"Content-Type": "application/json", | |
}, | |
body: JSON.stringify({ query }), | |
}) | |
//response received, turn it into json | |
.then((res) => res.json()) | |
.then((data) => { | |
//with the json data, output the text to our HTML output div | |
document.getElementById('output').innerText = data.text; | |
}); | |
} | |
//kick the app off by adding the event listeners to listen for form submission | |
addEventListeners(); | |
</script> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment