RAG Implementation with Hybrid Search
A comprehensive guide to building production-ready Retrieval-Augmented Generation (RAG) systems using Pinecone, OpenSearch, Sanity.io, and OpenAI.
Tech Stack
Pinecone • Vector database (1024-dim)
OpenSearch • Full-text search
Sanity.io • Content CMS
OpenAI • Embeddings & LLM
Why Hybrid Search?
Combining vector search with keyword search provides:
- ✨ Better semantic understanding
- 🎯 Precise term matching
- 🔍 Metadata filtering
- 📊 Improved relevance scoring
Setup
Install Dependencies
pip install pinecone-client opensearch-py openai sanity python-dotenvnpm install @pinecone-database/pinecone @opensearch-project/opensearch openai @sanity/client<!-- Add to pom.xml -->
<dependencies>
<dependency>
<groupId>io.pinecone</groupId>
<artifactId>pinecone-client</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>org.opensearch.client</groupId>
<artifactId>opensearch-rest-high-level-client</artifactId>
<version>2.11.0</version>
</dependency>
</dependencies>Environment Variables
PINECONE_API_KEY=your_key
OPENSEARCH_ENDPOINT=https://your-endpoint
OPENSEARCH_USERNAME=admin
OPENSEARCH_PASSWORD=your_password
SANITY_PROJECT_ID=your_project
SANITY_DATASET=production
SANITY_TOKEN=your_token
OPENAI_API_KEY=your_keyComplete Implementation
1. Initialize Clients
from pinecone import Pinecone, ServerlessSpec
from opensearchpy import OpenSearch
from openai import OpenAI
from sanity import Client as SanityClient
import os
# Pinecone
pc = Pinecone(api_key=os.getenv("PINECONE_API_KEY"))
if "surya-portfolio" not in pc.list_indexes().names():
pc.create_index(
name="surya-portfolio",
dimension=1024,
metric="cosine",
spec=ServerlessSpec(cloud="aws", region="us-east-1")
)
pinecone_index = pc.Index("surya-portfolio")
# OpenSearch
opensearch = OpenSearch(
hosts=[os.getenv("OPENSEARCH_ENDPOINT")],
http_auth=(os.getenv("OPENSEARCH_USERNAME"), os.getenv("OPENSEARCH_PASSWORD")),
use_ssl=True
)
# Create index
if not opensearch.indices.exists("portfolio"):
opensearch.indices.create(
index="portfolio",
body={
"mappings": {
"properties": {
"text": {"type": "text"},
"title": {"type": "text"},
"type": {"type": "keyword"},
"technologies": {"type": "keyword"}
}
}
}
)
# OpenAI & Sanity
openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
sanity = SanityClient(
project=os.getenv("SANITY_PROJECT_ID"),
dataset=os.getenv("SANITY_DATASET"),
token=os.getenv("SANITY_TOKEN")
)import { Pinecone } from '@pinecone-database/pinecone';
import { Client } from '@opensearch-project/opensearch';
import OpenAI from 'openai';
import { createClient } from '@sanity/client';
const pinecone = new Pinecone({ apiKey: process.env.PINECONE_API_KEY! });
// Create index if needed
const indexes = await pinecone.listIndexes();
if (!indexes.indexes?.find(i => i.name === 'surya-portfolio')) {
await pinecone.createIndex({
name: 'surya-portfolio',
dimension: 1024,
metric: 'cosine',
spec: { serverless: { cloud: 'aws', region: 'us-east-1' } }
});
}
const index = pinecone.index('surya-portfolio');
// OpenSearch
const opensearch = new Client({
node: process.env.OPENSEARCH_ENDPOINT!,
auth: {
username: process.env.OPENSEARCH_USERNAME!,
password: process.env.OPENSEARCH_PASSWORD!
}
});
// Create index
const exists = await opensearch.indices.exists({ index: 'portfolio' });
if (!exists.body) {
await opensearch.indices.create({
index: 'portfolio',
body: {
mappings: {
properties: {
text: { type: 'text' },
title: { type: 'text' },
type: { type: 'keyword' },
technologies: { type: 'keyword' }
}
}
}
});
}
// OpenAI & Sanity
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });
const sanity = createClient({
projectId: process.env.SANITY_PROJECT_ID!,
dataset: process.env.SANITY_DATASET!,
token: process.env.SANITY_TOKEN!
});import io.pinecone.*;
import org.opensearch.client.*;
import com.theokanning.openai.service.OpenAiService;
public class RAGSystem {
PineconeClient pinecone;
RestHighLevelClient opensearch;
OpenAiService openai;
public RAGSystem() {
// Pinecone
this.pinecone = new PineconeClient(
new PineconeClientConfig()
.withApiKey(System.getenv("PINECONE_API_KEY"))
);
// OpenSearch
this.opensearch = new RestHighLevelClient(
RestClient.builder(
HttpHost.create(System.getenv("OPENSEARCH_ENDPOINT"))
)
);
// OpenAI
this.openai = new OpenAiService(System.getenv("OPENAI_API_KEY"));
}
}2. Fetch & Chunk Data
# Fetch from Sanity
docs = sanity.fetch('''
*[_type in ["resume", "project", "experience"]] {
_id, title, description, content, technologies
}
''')
# Chunk documents
def chunk_text(text, size=400, overlap=50):
words = text.split()
chunks = []
for i in range(0, len(words), size - overlap):
chunks.append(' '.join(words[i:i + size]))
return chunks
chunked = []
for doc in docs:
text = f"{doc.get('title', '')} {doc.get('content', '')}"
for idx, chunk in enumerate(chunk_text(text)):
chunked.append({
'id': f"{doc['_id']}_{idx}",
'text': chunk,
'metadata': {
'source_id': doc['_id'],
'title': doc.get('title', ''),
'type': doc['_type'],
'technologies': doc.get('technologies', [])
}
})
print(f"Created {len(chunked)} chunks from {len(docs)} documents")// Fetch from Sanity
const docs = await sanity.fetch(`
*[_type in ["resume", "project", "experience"]] {
_id, title, description, content, technologies
}
`);
// Chunk function
function chunkText(text: string, size = 400, overlap = 50) {
const words = text.split(/\s+/);
const chunks: string[] = [];
for (let i = 0; i < words.length; i += size - overlap) {
chunks.push(words.slice(i, i + size).join(' '));
}
return chunks;
}
// Create chunks
const chunked = docs.flatMap((doc, docIdx) => {
const text = `${doc.title || ''} ${doc.content || ''}`;
return chunkText(text).map((chunk, idx) => ({
id: `${doc._id}_${idx}`,
text: chunk,
metadata: {
source_id: doc._id,
title: doc.title || '',
type: doc._type,
technologies: doc.technologies || []
}
}));
});
console.log(`Created ${chunked.length} chunks from ${docs.length} documents`);// Fetch from Sanity
List<JsonNode> docs = fetchFromSanity();
// Chunk method
public List<String> chunkText(String text, int size, int overlap) {
String[] words = text.split("\\s+");
List<String> chunks = new ArrayList<>();
for (int i = 0; i < words.length; i += size - overlap) {
int end = Math.min(i + size, words.length);
chunks.add(String.join(" ",
Arrays.copyOfRange(words, i, end)));
}
return chunks;
}
// Process documents
List<Chunk> chunked = new ArrayList<>();
for (JsonNode doc : docs) {
String text = doc.get("title").asText() + " " +
doc.get("content").asText();
List<String> chunks = chunkText(text, 400, 50);
for (int i = 0; i < chunks.size(); i++) {
chunked.add(new Chunk(
doc.get("_id").asText() + "_" + i,
chunks.get(i),
createMetadata(doc)
));
}
}3. Generate Embeddings & Ingest
# Generate embeddings
texts = [c['text'] for c in chunked]
response = openai_client.embeddings.create(
model="text-embedding-3-large",
input=texts,
dimensions=1024
)
embeddings = [item.embedding for item in response.data]
# Ingest to Pinecone
vectors = [
{
'id': c['id'],
'values': emb,
'metadata': {**c['metadata'], 'text': c['text'][:1000]}
}
for c, emb in zip(chunked, embeddings)
]
pinecone_index.upsert(vectors=vectors)
# Ingest to OpenSearch
from opensearchpy.helpers import bulk
actions = [
{
'_index': 'portfolio',
'_id': c['id'],
'_source': {**c['metadata'], 'text': c['text']}
}
for c in chunked
]
bulk(opensearch, actions)
print("✅ Ingestion complete!")// Generate embeddings
const texts = chunked.map(c => c.text);
const response = await openai.embeddings.create({
model: 'text-embedding-3-large',
input: texts,
dimensions: 1024
});
const embeddings = response.data.map(d => d.embedding);
// Ingest to Pinecone
await index.upsert(
chunked.map((c, i) => ({
id: c.id,
values: embeddings[i],
metadata: { ...c.metadata, text: c.text.substring(0, 1000) }
}))
);
// Ingest to OpenSearch
const body = chunked.flatMap(c => [
{ index: { _index: 'portfolio', _id: c.id } },
{ ...c.metadata, text: c.text }
]);
await opensearch.bulk({ body });
console.log('✅ Ingestion complete!');// Generate embeddings
List<String> texts = chunked.stream()
.map(c -> c.text)
.collect(Collectors.toList());
EmbeddingResult result = openai.createEmbeddings(
EmbeddingRequest.builder()
.model("text-embedding-3-large")
.input(texts)
.dimensions(1024)
.build()
);
List<List<Double>> embeddings = result.getData().stream()
.map(e -> e.getEmbedding())
.collect(Collectors.toList());
// Ingest to Pinecone
List<Map<String, Object>> vectors = new ArrayList<>();
for (int i = 0; i < chunked.size(); i++) {
Map<String, Object> vector = new HashMap<>();
vector.put("id", chunked.get(i).id);
vector.put("values", embeddings.get(i));
vector.put("metadata", chunked.get(i).metadata);
vectors.add(vector);
}
pinecone.upsert("surya-portfolio", vectors);
// Ingest to OpenSearch
BulkRequest bulk = new BulkRequest();
for (Chunk c : chunked) {
bulk.add(new IndexRequest("portfolio")
.id(c.id)
.source(c.toMap(), XContentType.JSON));
}
opensearch.bulk(bulk, RequestOptions.DEFAULT);
System.out.println("✅ Ingestion complete!");4. Hybrid Search Query
def hybrid_search(query: str, top_k: int = 5):
# Generate query embedding
query_emb = openai_client.embeddings.create(
model="text-embedding-3-large",
input=[query],
dimensions=1024
).data[0].embedding
# Vector search (Pinecone)
vector_results = pinecone_index.query(
vector=query_emb,
top_k=top_k,
include_metadata=True
)
# Keyword search (OpenSearch)
keyword_results = opensearch.search(
index="portfolio",
body={
"query": {
"multi_match": {
"query": query,
"fields": ["text", "title^2"]
}
},
"size": top_k
}
)
# Combine results (simple fusion)
results = {}
for match in vector_results['matches']:
results[match['id']] = {
'text': match['metadata']['text'],
'score': match['score'],
'source': 'vector'
}
for hit in keyword_results['hits']['hits']:
doc_id = hit['_id']
if doc_id in results:
results[doc_id]['score'] += hit['_score']
else:
results[doc_id] = {
'text': hit['_source']['text'],
'score': hit['_score'],
'source': 'keyword'
}
# Sort by combined score
ranked = sorted(results.items(),
key=lambda x: x[1]['score'],
reverse=True)
return ranked[:top_k]
# Test query
results = hybrid_search("Python machine learning projects")
for doc_id, data in results:
print(f"Score: {data['score']:.3f} | {data['text'][:100]}...")async function hybridSearch(query: string, topK = 5) {
// Generate query embedding
const response = await openai.embeddings.create({
model: 'text-embedding-3-large',
input: [query],
dimensions: 1024
});
const queryEmb = response.data[0].embedding;
// Vector search
const vectorResults = await index.query({
vector: queryEmb,
topK,
includeMetadata: true
});
// Keyword search
const keywordResults = await opensearch.search({
index: 'portfolio',
body: {
query: {
multi_match: {
query,
fields: ['text', 'title^2']
}
},
size: topK
}
});
// Combine results
const results = new Map();
vectorResults.matches?.forEach(match => {
results.set(match.id, {
text: match.metadata?.text,
score: match.score,
source: 'vector'
});
});
keywordResults.body.hits.hits.forEach(hit => {
const existing = results.get(hit._id);
if (existing) {
existing.score += hit._score;
} else {
results.set(hit._id, {
text: hit._source.text,
score: hit._score,
source: 'keyword'
});
}
});
// Sort and return
return Array.from(results.entries())
.sort((a, b) => b[1].score - a[1].score)
.slice(0, topK);
}
// Test
const results = await hybridSearch('Python machine learning projects');
results.forEach(([id, data]) => {
console.log(`Score: ${data.score.toFixed(3)} | ${data.text.substring(0, 100)}...`);
});public class SearchResult {
String id;
String text;
double score;
String source;
}
public List<SearchResult> hybridSearch(String query, int topK) {
// Generate query embedding
EmbeddingResult embResult = openai.createEmbeddings(
EmbeddingRequest.builder()
.model("text-embedding-3-large")
.input(List.of(query))
.dimensions(1024)
.build()
);
List<Double> queryEmb = embResult.getData().get(0).getEmbedding();
// Vector search
QueryResponse vectorResults = pinecone.query(
"surya-portfolio",
QueryRequest.builder()
.vector(queryEmb)
.topK(topK)
.includeMetadata(true)
.build()
);
// Keyword search
SearchRequest searchRequest = new SearchRequest("portfolio");
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
sourceBuilder.query(QueryBuilders.multiMatchQuery(query, "text", "title"));
sourceBuilder.size(topK);
searchRequest.source(sourceBuilder);
SearchResponse keywordResults = opensearch.search(
searchRequest, RequestOptions.DEFAULT);
// Combine results
Map<String, SearchResult> combined = new HashMap<>();
for (Match match : vectorResults.getMatches()) {
SearchResult sr = new SearchResult();
sr.id = match.getId();
sr.text = (String) match.getMetadata().get("text");
sr.score = match.getScore();
sr.source = "vector";
combined.put(sr.id, sr);
}
for (SearchHit hit : keywordResults.getHits()) {
String id = hit.getId();
if (combined.containsKey(id)) {
combined.get(id).score += hit.getScore();
} else {
SearchResult sr = new SearchResult();
sr.id = id;
sr.text = (String) hit.getSourceAsMap().get("text");
sr.score = hit.getScore();
sr.source = "keyword";
combined.put(id, sr);
}
}
return combined.values().stream()
.sorted((a, b) -> Double.compare(b.score, a.score))
.limit(topK)
.collect(Collectors.toList());
}5. Generate Response with LLM
def generate_response(query: str):
# Get relevant context
search_results = hybrid_search(query, top_k=3)
context = "\n\n".join([data['text'] for _, data in search_results])
# Generate response
response = openai_client.chat.completions.create(
model="gpt-4",
messages=[
{"role": "system", "content": "You are a helpful assistant that answers questions based on the provided context."},
{"role": "user", "content": f"Context:\n{context}\n\nQuestion: {query}"}
]
)
return response.choices[0].message.content
# Test
answer = generate_response("What Python projects have you worked on?")
print(answer)async function generateResponse(query: string) {
const searchResults = await hybridSearch(query, 3);
const context = searchResults
.map(([_, data]) => data.text)
.join('\n\n');
const response = await openai.chat.completions.create({
model: 'gpt-4',
messages: [
{
role: 'system',
content: 'You are a helpful assistant that answers questions based on the provided context.'
},
{
role: 'user',
content: `Context:\n${context}\n\nQuestion: ${query}`
}
]
});
return response.choices[0].message.content;
}
const answer = await generateResponse('What Python projects have you worked on?');
console.log(answer);public String generateResponse(String query) {
List<SearchResult> results = hybridSearch(query, 3);
String context = results.stream()
.map(r -> r.text)
.collect(Collectors.joining("\n\n"));
ChatCompletionRequest request = ChatCompletionRequest.builder()
.model("gpt-4")
.messages(List.of(
new ChatMessage("system",
"You are a helpful assistant."),
new ChatMessage("user",
"Context:\n" + context + "\n\nQuestion: " + query)
))
.build();
ChatCompletionResult result = openai.createChatCompletion(request);
return result.getChoices().get(0).getMessage().getContent();
}
String answer = generateResponse("What Python projects have you worked on?");
System.out.println(answer);Best Practices
Performance Tips
- Batch operations: Process embeddings and upserts in batches of 100
- Caching: Cache frequent queries to reduce API calls
- Monitoring: Track search latency and relevance metrics
- Reranking: Consider using a reranker for better result quality
Optimization Strategies
-
Chunking Strategy
- Aim for 200-500 tokens per chunk
- Use 50-100 token overlap
- Preserve sentence boundaries
-
Metadata Design
- Index important fields for filtering
- Keep metadata under 40KB in Pinecone
- Use keywords for categorical data
-
Search Tuning
- Adjust
top_kbased on use case (3-10 typical) - Experiment with score fusion methods
- Boost title matches with
^2multiplier
- Adjust
-
Error Handling
- Implement retry logic for API calls
- Validate embeddings dimensions
- Handle rate limits gracefully
Testing Your RAG System
# Example test queries
test_queries = [
"What technologies do you use?",
"Tell me about your machine learning experience",
"What projects involve Python?",
"Describe your resume"
]
for query in test_queries:
print(f"\n🔍 Query: {query}")
results = hybrid_search(query, top_k=2)
for doc_id, data in results:
print(f" Score: {data['score']:.3f}")
print(f" Text: {data['text'][:100]}...")Next Steps
- Add query expansion for better recall
- Implement user feedback loops
- Set up monitoring and analytics
- Consider fine-tuning embeddings for your domain