Skip to content

Instantly share code, notes, and snippets.

@heaversm
Last active February 2, 2024 02:10
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save heaversm/4fefbf62b01ac3e0eb35d31a30eeaf58 to your computer and use it in GitHub Desktop.
Save heaversm/4fefbf62b01ac3e0eb35d31a30eeaf58 to your computer and use it in GitHub Desktop.
RAGFusion Node / Javascript Implementation
//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}`);
});
<!--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