Build a semantic search engine
This tutorial will familiarize you with LangChain’s document loader, embedding, and vector store abstractions. These abstractions are designed to support retrieval of data– from (vector) databases and other sources– for integration with LLM workflows. They are important for applications that fetch data to be reasoned over as part of model inference, as in the case of retrieval-augmented generation, or RAG (see our RAG tutorial here).
Here we will build a search engine over a PDF document. This will allow us to retrieve passages in the PDF that are similar to an input query.
Concepts
This guide focuses on retrieval of text data. We will cover the following concepts:
- Documents and document loaders;
- Text splitters;
- Embeddings;
- Vector stores and retrievers.
Setup
Jupyter Notebook
This and other tutorials are perhaps most conveniently run in a Jupyter notebook. See here for instructions on how to install.
Installation
This guide requires @langchain/community
and pdf-parse
:
- npm
- yarn
- pnpm
npm i @langchain/community pdf-parse
yarn add @langchain/community pdf-parse
pnpm add @langchain/community pdf-parse
For more details, see our Installation guide.
LangSmith
Many of the applications you build with LangChain will contain multiple steps with multiple invocations of LLM calls. As these applications get more and more complex, it becomes crucial to be able to inspect what exactly is going on inside your chain or agent. The best way to do this is with LangSmith.
After you sign up at the link above, make sure to set your environment variables to start logging traces:
export LANGCHAIN_TRACING_V2="true"
export LANGCHAIN_API_KEY="..."
# Reduce tracing latency if you are not in a serverless environment
# export LANGCHAIN_CALLBACKS_BACKGROUND=true
Documents and Document Loaders
LangChain implements a Document abstraction, which is intended to represent a unit of text and associated metadata. It has three attributes:
pageContent
: a string representing the content;metadata
: records of arbitrary metadata;id
: (optional) a string identifier for the document.
The metadata
attribute can capture information about the source of the
document, its relationship to other documents, and other information.
Note that an individual Document
object often represents a chunk of a
larger document.
We can generate sample documents when desired:
import { Document } from "@langchain/core/documents";
const documents = [
new Document({
pageContent:
"Dogs are great companions, known for their loyalty and friendliness.",
metadata: { source: "mammal-pets-doc" },
}),
new Document({
pageContent: "Cats are independent pets that often enjoy their own space.",
metadata: { source: "mammal-pets-doc" },
}),
];
However, the LangChain ecosystem implements document loaders that integrate with hundreds of common sources. This makes it easy to incorporate data from these sources into your AI application.
Loading documents
Let’s load a PDF into a sequence of Document
objects. There is a
sample PDF in the LangChain repo
here
– a 10-k filing for Nike from 2023. LangChain implements a
PDFLoader that
we can use to parse the PDF:
import { PDFLoader } from "@langchain/community/document_loaders/fs/pdf";
const loader = new PDFLoader("../../data/nke-10k-2023.pdf");
const docs = await loader.load();
console.log(docs.length);
107
See this guide for more detail on PDF document loaders.
PDFLoader
loads one Document
object per PDF page. For each, we can
easily access:
- The string content of the page;
- Metadata containing the file name and page number.
docs[0].pageContent.slice(0, 200);
Table of Contents
UNITED STATES
SECURITIES AND EXCHANGE COMMISSION
Washington, D.C. 20549
FORM 10-K
(Mark One)
☑ ANNUAL REPORT PURSUANT TO SECTION 13 OR 15(D) OF THE SECURITIES EXCHANGE ACT OF 1934
FO
docs[0].metadata;
{
source: '../../data/nke-10k-2023.pdf',
pdf: {
version: '1.10.100',
info: {
PDFFormatVersion: '1.4',
IsAcroFormPresent: false,
IsXFAPresent: false,
Title: '0000320187-23-000039',
Author: 'EDGAR Online, a division of Donnelley Financial Solutions',
Subject: 'Form 10-K filed on 2023-07-20 for the period ending 2023-05-31',
Keywords: '0000320187-23-000039; ; 10-K',
Creator: 'EDGAR Filing HTML Converter',
Producer: 'EDGRpdf Service w/ EO.Pdf 22.0.40.0',
CreationDate: "D:20230720162200-04'00'",
ModDate: "D:20230720162208-04'00'"
},
metadata: null,
totalPages: 107
},
loc: { pageNumber: 1 }
}
Splitting
For both information retrieval and downstream question-answering
purposes, a page may be too coarse a representation. Our goal in the end
will be to retrieve Document
objects that answer an input query, and
further splitting our PDF will help ensure that the meanings of relevant
portions of the document are not “washed out” by surrounding text.
We can use text splitters for this purpose. Here we will use a simple text splitter that partitions based on characters. We will split our documents into chunks of 1000 characters with 200 characters of overlap between chunks. The overlap helps mitigate the possibility of separating a statement from important context related to it. We use the RecursiveCharacterTextSplitter, which will recursively split the document using common separators like new lines until each chunk is the appropriate size. This is the recommended text splitter for generic text use cases.
We set add_start_index=True
so that the character index where each
split Document starts within the initial Document is preserved as
metadata attribute “start_index”.
See this guide for more detail about working with PDFs.
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 1000,
chunkOverlap: 200,
});
const allSplits = await textSplitter.splitDocuments(docs);
allSplits.length;
513
Embeddings
Vector search is a common way to store and search over unstructured data (such as unstructured text). The idea is to store numeric vectors that are associated with the text. Given a query, we can embed it as a vector of the same dimension and use vector similarity metrics (such as cosine similarity) to identify related text.
LangChain supports embeddings from dozens of providers. These models specify how text should be converted into a numeric vector. Let’s select a model.
Pick your embedding model:
- OpenAI
- Azure
- AWS
- VertexAI
- MistralAI
- Cohere
Install dependencies
- npm
- yarn
- pnpm
npm i @langchain/openai
yarn add @langchain/openai
pnpm add @langchain/openai
OPENAI_API_KEY=your-api-key
import { OpenAIEmbeddings } from "@langchain/openai";
const embeddings = new OpenAIEmbeddings({
model: "text-embedding-3-large"
});
Install dependencies
- npm
- yarn
- pnpm
npm i @langchain/openai
yarn add @langchain/openai
pnpm add @langchain/openai
AZURE_OPENAI_API_INSTANCE_NAME=<YOUR_INSTANCE_NAME>
AZURE_OPENAI_API_KEY=<YOUR_KEY>
AZURE_OPENAI_API_VERSION="2024-02-01"
import { AzureOpenAIEmbeddings } from "@langchain/openai";
const embeddings = new AzureOpenAIEmbeddings({
azureOpenAIApiEmbeddingsDeploymentName: "text-embedding-ada-002"
});
Install dependencies
- npm
- yarn
- pnpm
npm i @langchain/aws
yarn add @langchain/aws
pnpm add @langchain/aws
BEDROCK_AWS_REGION=your-region
import { BedrockEmbeddings } from "@langchain/aws";
const embeddings = new BedrockEmbeddings({
model: "amazon.titan-embed-text-v1"
});
Install dependencies
- npm
- yarn
- pnpm
npm i @langchain/google-vertexai
yarn add @langchain/google-vertexai
pnpm add @langchain/google-vertexai
GOOGLE_APPLICATION_CREDENTIALS=credentials.json
import { VertexAIEmbeddings } from "@langchain/google-vertexai";
const embeddings = new VertexAIEmbeddings({
model: "text-embedding-004"
});
Install dependencies
- npm
- yarn
- pnpm
npm i @langchain/mistralai
yarn add @langchain/mistralai
pnpm add @langchain/mistralai
MISTRAL_API_KEY=your-api-key
import { MistralAIEmbeddings } from "@langchain/mistralai";
const embeddings = new MistralAIEmbeddings({
model: "mistral-embed"
});
Install dependencies
- npm
- yarn
- pnpm
npm i @langchain/cohere
yarn add @langchain/cohere
pnpm add @langchain/cohere
COHERE_API_KEY=your-api-key
import { CohereEmbeddings } from "@langchain/cohere";
const embeddings = new CohereEmbeddings({
model: "embed-english-v3.0"
});
const vector1 = await embeddings.embedQuery(allSplits[0].pageContent);
const vector2 = await embeddings.embedQuery(allSplits[1].pageContent);
console.assert(vector1.length === vector2.length);
console.log(`Generated vectors of length ${vector1.length}\n`);
console.log(vector1.slice(0, 10));
Generated vectors of length 3072
[
0.014310152,
-0.01681044,
-0.0011537228,
0.010546423,
0.022808468,
-0.028327717,
-0.00058849837,
0.0419197,
-0.0012900416,
0.0661778
]
Armed with a model for generating text embeddings, we can next store them in a special data structure that supports efficient similarity search.
Vector stores
LangChain
VectorStore
objects contain methods for adding text and Document
objects to the
store, and querying them using various similarity metrics. They are
often initialized with embedding models,
which determine how text data is translated to numeric vectors.
LangChain includes a suite of integrations with different vector store technologies. Some vector stores are hosted by a provider (e.g., various cloud providers) and require specific credentials to use; some (such as Postgres) run in separate infrastructure that can be run locally or via a third-party; others can run in-memory for lightweight workloads.
Pick your vector store:
- Memory
- Chroma
- FAISS
- MongoDB
- PGVector
- Pinecone
- Qdrant
Install dependencies
- npm
- yarn
- pnpm
npm i langchain
yarn add langchain
pnpm add langchain
import { MemoryVectorStore } from "langchain/vectorstores/memory";
const vectorStore = new MemoryVectorStore(embeddings);
Install dependencies
- npm
- yarn
- pnpm
npm i @langchain/community
yarn add @langchain/community
pnpm add @langchain/community
import { Chroma } from "@langchain/community/vectorstores/chroma";
const vectorStore = new Chroma(embeddings, {
collectionName: "a-test-collection",
});
Install dependencies
- npm
- yarn
- pnpm
npm i @langchain/community
yarn add @langchain/community
pnpm add @langchain/community
import { FaissStore } from "@langchain/community/vectorstores/faiss";
const vectorStore = new FaissStore(embeddings, {});
Install dependencies
- npm
- yarn
- pnpm
npm i @langchain/mongodb
yarn add @langchain/mongodb
pnpm add @langchain/mongodb
import { MongoDBAtlasVectorSearch } from "@langchain/mongodb"
import { MongoClient } from "mongodb";
const client = new MongoClient(process.env.MONGODB_ATLAS_URI || "");
const collection = client
.db(process.env.MONGODB_ATLAS_DB_NAME)
.collection(process.env.MONGODB_ATLAS_COLLECTION_NAME);
const vectorStore = new MongoDBAtlasVectorSearch(embeddings, {
collection: collection,
indexName: "vector_index",
textKey: "text",
embeddingKey: "embedding",
});
Install dependencies
- npm
- yarn
- pnpm
npm i @langchain/community
yarn add @langchain/community
pnpm add @langchain/community
import PGVectorStore from "@langchain/community/vectorstores/pgvector";
const vectorStore = await PGVectorStore.initialize(embeddings, {})
Install dependencies
- npm
- yarn
- pnpm
npm i @langchain/pinecone
yarn add @langchain/pinecone
pnpm add @langchain/pinecone
import { PineconeStore } from "@langchain/pinecone";
import { Pinecone as PineconeClient } from "@pinecone-database/pinecone";
const pinecone = new PineconeClient();
const vectorStore = new PineconeStore(embeddings, {
pineconeIndex,
maxConcurrency: 5,
});
Install dependencies
- npm
- yarn
- pnpm
npm i @langchain/qdrant
yarn add @langchain/qdrant
pnpm add @langchain/qdrant
import { QdrantVectorStore } from "@langchain/qdrant";
const vectorStore = await QdrantVectorStore.fromExistingCollection(embeddings, {
url: process.env.QDRANT_URL,
collectionName: "langchainjs-testing",
});
Having instantiated our vector store, we can now index the documents.
await vectorStore.addDocuments(allSplits);
Note that most vector store implementations will allow you to connect to an existing vector store– e.g., by providing a client, index name, or other information. See the documentation for a specific integration for more detail.
Once we’ve instantiated a VectorStore
that contains documents, we can
query it.
VectorStore
includes methods for querying: - Synchronously and asynchronously; - By
string query and by vector; - With and without returning similarity
scores; - By similarity and maximum marginal
relevance
(to balance similarity with query to diversity in retrieved results).
The methods will generally include a list of Document objects in their outputs.
Usage
Embeddings typically represent text as a “dense” vector such that texts with similar meanings are gemoetrically close. This lets us retrieve relevant information just by passing in a question, without knowledge of any specific key-terms used in the document.
Return documents based on similarity to a string query:
const results1 = await vectorStore.similaritySearch(
"When was Nike incorporated?"
);
results1[0];
Document {
pageContent: 'Table of Contents\n' +
'PART I\n' +
'ITEM 1. BUSINESS\n' +
'GENERAL\n' +
'NIKE, Inc. was incorporated in 1967 under the laws of the State of Oregon. As used in this Annual Report on Form 10-K (this "Annual Report"), the terms "we," "us," "our,"\n' +
'"NIKE" and the "Company" refer to NIKE, Inc. and its predecessors, subsidiaries and affiliates, collectively, unless the context indicates otherwise.\n' +
'Our principal business activity is the design, development and worldwide marketing and selling of athletic footwear, apparel, equipment, accessories and services. NIKE is\n' +
'the largest seller of athletic footwear and apparel in the world. We sell our products through NIKE Direct operations, which are comprised of both NIKE-owned retail stores\n' +
'and sales through our digital platforms (also referred to as "NIKE Brand Digital"), to retail accounts and to a mix of independent distributors, licensees and sales',
metadata: {
source: '../../data/nke-10k-2023.pdf',
pdf: {
version: '1.10.100',
info: [Object],
metadata: null,
totalPages: 107
},
loc: { pageNumber: 4, lines: [Object] }
},
id: undefined
}
Return scores:
const results2 = await vectorStore.similaritySearchWithScore(
"What was Nike's revenue in 2023?"
);
results2[0];
[
Document {
pageContent: 'Table of Contents\n' +
'FISCAL 2023 NIKE BRAND REVENUE HIGHLIGHTS\n' +
'The following tables present NIKE Brand revenues disaggregated by reportable operating segment, distribution channel and major product line:\n' +
'FISCAL 2023 COMPARED TO FISCAL 2022\n' +
'•NIKE, Inc. Revenues were $51.2 billion in fiscal 2023, which increased 10% and 16% compared to fiscal 2022 on a reported and currency-neutral basis, respectively.\n' +
'The increase was due to higher revenues in North America, Europe, Middle East & Africa ("EMEA"), APLA and Greater China, which contributed approximately 7, 6,\n' +
'2 and 1 percentage points to NIKE, Inc. Revenues, respectively.\n' +
'•NIKE Brand revenues, which represented over 90% of NIKE, Inc. Revenues, increased 10% and 16% on a reported and currency-neutral basis, respectively. This\n' +
"increase was primarily due to higher revenues in Men's, the Jordan Brand, Women's and Kids' which grew 17%, 35%,11% and 10%, respectively, on a wholesale\n" +
'equivalent basis.',
metadata: {
source: '../../data/nke-10k-2023.pdf',
pdf: [Object],
loc: [Object]
},
id: undefined
},
0.6992287611800424
]
Return documents based on similarity to an embedded query:
const embedding = await embeddings.embedQuery(
"How were Nike's margins impacted in 2023?"
);
const results3 = await vectorStore.similaritySearchVectorWithScore(
embedding,
1
);
results3[0];
[
Document {
pageContent: 'Table of Contents\n' +
'GROSS MARGIN\n' +
'FISCAL 2023 COMPARED TO FISCAL 2022\n' +
'For fiscal 2023, our consolidated gross profit increased 4% to $22,292 million compared to $21,479 million for fiscal 2022. Gross margin decreased 250 basis points to\n' +
'43.5% for fiscal 2023 compared to 46.0% for fiscal 2022 due to the following:\n' +
'*Wholesale equivalent\n' +
'The decrease in gross margin for fiscal 2023 was primarily due to:\n' +
'•Higher NIKE Brand product costs, on a wholesale equivalent basis, primarily due to higher input costs and elevated inbound freight and logistics costs as well as\n' +
'product mix;\n' +
'•Lower margin in our NIKE Direct business, driven by higher promotional activity to liquidate inventory in the current period compared to lower promotional activity in\n' +
'the prior period resulting from lower available inventory supply;\n' +
'•Unfavorable changes in net foreign currency exchange rates, including hedges; and\n' +
'•Lower off-price margin, on a wholesale equivalent basis.\n' +
'This was partially offset by:',
metadata: {
source: '../../data/nke-10k-2023.pdf',
pdf: [Object],
loc: [Object]
},
id: undefined
},
0.7368815472158006
]
Learn more:
Retrievers
LangChain VectorStore
objects do not subclass
Runnable.
LangChain
Retrievers
are Runnables, so they implement a standard set of methods (e.g.,
synchronous and asynchronous invoke
and batch
operations). Although
we can construct retrievers from vector stores, retrievers can interface
with non-vector store sources of data, as well (such as external APIs).
Vectorstores implement an as
retriever
method that will generate a Retriever, specifically a
VectorStoreRetriever.
These retrievers include specific search_type
and search_kwargs
attributes that identify what methods of the underlying vector store to
call, and how to parameterize them.
const retriever = vectorStore.asRetriever({
searchType: "mmr",
searchKwargs: {
fetchK: 1,
},
});
await retriever.batch([
"When was Nike incorporated?",
"What was Nike's revenue in 2023?",
]);
[
[
Document {
pageContent: 'Table of Contents\n' +
'PART I\n' +
'ITEM 1. BUSINESS\n' +
'GENERAL\n' +
'NIKE, Inc. was incorporated in 1967 under the laws of the State of Oregon. As used in this Annual Report on Form 10-K (this "Annual Report"), the terms "we," "us," "our,"\n' +
'"NIKE" and the "Company" refer to NIKE, Inc. and its predecessors, subsidiaries and affiliates, collectively, unless the context indicates otherwise.\n' +
'Our principal business activity is the design, development and worldwide marketing and selling of athletic footwear, apparel, equipment, accessories and services. NIKE is\n' +
'the largest seller of athletic footwear and apparel in the world. We sell our products through NIKE Direct operations, which are comprised of both NIKE-owned retail stores\n' +
'and sales through our digital platforms (also referred to as "NIKE Brand Digital"), to retail accounts and to a mix of independent distributors, licensees and sales',
metadata: [Object],
id: undefined
}
],
[
Document {
pageContent: 'Table of Contents\n' +
'FISCAL 2023 NIKE BRAND REVENUE HIGHLIGHTS\n' +
'The following tables present NIKE Brand revenues disaggregated by reportable operating segment, distribution channel and major product line:\n' +
'FISCAL 2023 COMPARED TO FISCAL 2022\n' +
'•NIKE, Inc. Revenues were $51.2 billion in fiscal 2023, which increased 10% and 16% compared to fiscal 2022 on a reported and currency-neutral basis, respectively.\n' +
'The increase was due to higher revenues in North America, Europe, Middle East & Africa ("EMEA"), APLA and Greater China, which contributed approximately 7, 6,\n' +
'2 and 1 percentage points to NIKE, Inc. Revenues, respectively.\n' +
'•NIKE Brand revenues, which represented over 90% of NIKE, Inc. Revenues, increased 10% and 16% on a reported and currency-neutral basis, respectively. This\n' +
"increase was primarily due to higher revenues in Men's, the Jordan Brand, Women's and Kids' which grew 17%, 35%,11% and 10%, respectively, on a wholesale\n" +
'equivalent basis.',
metadata: [Object],
id: undefined
}
]
]
VectorStoreRetriever
supports search types of "similarity"
(default)
and "mmr"
(maximum marginal relevance, described above).
Retrievers can easily be incorporated into more complex applications, such as retrieval-augmented generation (RAG) applications that combine a given question with retrieved context into a prompt for a LLM. To learn more about building such an application, check out the RAG tutorial tutorial.
Learn more:
Retrieval strategies can be rich and complex. For example:
- We can infer hard rules and filters from a query (e.g., “using documents published after 2020”);
- We can return documents that are linked to the retrieved context in some way (e.g., via some document taxonomy);
- We can generate multiple embeddings for each unit of context;
- We can ensemble results from multiple retrievers;
- We can assign weights to documents, e.g., to weigh recent documents higher.
The retrievers section of the how-to guides covers these and other built-in retrieval strategies.
It is also straightforward to extend the BaseRetriever class in order to implement custom retrievers. See our how-to guide here.
Next steps
You’ve now seen how to build a semantic search engine over a PDF document.
For more on document loaders:
For more on embeddings:
For more on vector stores:
For more on RAG, see: