Protein Sequence Embedding FAISS Search: Capturing Distant Homologs Missed by BLAST with Foundation Models
BLAST has been the standard for protein homology search for 30 years, but its performance degrades significantly when sequence similarity is below 30%. However, in reality, there are countless distant homologs, which are evolutionarily related but have significantly different sequences. Failing to identify these can hinder the prediction of new gene functions. This article builds a practical pipeline to solve this problem using the latent space learned by a protein foundation model (ESM3) and leverages FAISS to provide real-time, high-throughput search capabilities over a large sequence database.
📚 Recommended Prerequisite Readings (Strongly Recommended)
This article is an advanced, in-depth exploration of AI×Biology concepts. It is strongly recommended to review the following articles from DryBench before proceeding.
- DryBench ai-native #3 Transformers and Embeddings
- DryBench ai-native #5 Attention Mechanism
- DryBench ai-native #13 HuggingFace and Commercial APIs
Without reviewing the prerequisites, it will be difficult to follow the practical code presented in this article, as it will proceed without re-explaining the meaning of embedding vectors, the information-richness of attention, and the principles of loading HuggingFace models.
We Already Learned This in DryBench
In DryBench ai-native #3, we learned that transformers learn relationships between tokens and represent each token as a context-aware dense vector. In #5, we learned that attention learns how important any two positions in a sequence are to each other, capturing long-range dependencies. In #13, we learned that HuggingFace provides an ecosystem that standardizes the weights, tokenizers, and inference APIs of these models, allowing them to be loaded in just a few lines of code.
But what happens when we apply these three concepts to the protein domain? Unlike natural language, proteins have been refined by evolutionary pressures for 3 billion years, and contextual (neighboring amino acid) information determines their 3D folded structure. The ESM series from Meta AI (→ EvolutionaryScale spinout) is the first large-scale protein foundation model that learns this evolutionary information using transformers, and several papers have confirmed that the learned embeddings potentially contain protein 3D structure, function, and stability [1][2][5]. This article presents a pipeline that turns these embeddings into a practical and useful search tool.
Defining the Hardcore Problem
The Fundamental Limitations of BLAST
BLAST (Basic Local Alignment Search Tool, 1990) scores sequence similarity using a substitution matrix and gap penalty to return statistically significant hits. This approach is overwhelmingly accurate for close homologs, where sequences match by 30-40% or more, but it breaks down in the following two scenarios:
- Distant homologs: Evolutionarily related but with a sequence identity of 20-30%. The 3D structure and function are still conserved, but BLAST loses statistical significance. The 20-35% range, known as the "twilight zone," is particularly difficult, and the < 20% zone is virtually undetectable.
- Matching specific domains in multi-domain proteins: BLAST finds local alignments of the entire sequence, but if the overall alignment score is low, it can miss individual domains that are strongly homologous.
Traditional workarounds include PSI-BLAST (iterative profile), HHblits (HMM-HMM), and Foldseek (structure-based), but each has limitations in terms of computational cost and data dependency.
What Embedding Approaches Solve
Foundation models like ESM3 absorb evolutionary coupling into the latent space during the learning process. As a result, embeddings are close in vector space even when sequence similarity is low, if the structure and function are similar. By leveraging this property, we can:
- Retrieve remote homologs missed by BLAST using embedding KNN search (supported by literature [2][3]).
- Once the embedding is calculated for a sequence, all subsequent searches only require calculating vector similarity, which is advantageous for large datasets compared to BLAST's pairwise alignment.
- Function transfer (e.g., GO term) can be instantly predicted by majority voting of labels from the top-k nearest neighbors in the search results.
Goal Metrics for This Article
- Build an embedding index for a subset of 500,000 sequences from UniProt Swiss-Prot.
- Remote homolog detection rate for 1,000 query sequences: Improve recall by at least 25% compared to BLAST (based on CAFA benchmark [4]).
- Average search latency: Less than 100ms per query (FAISS HNSW tuning).
- GO term function transfer F1: Greater than 0.65 (CAFA-3 top level).
Tools, Stack, and Infrastructure Requirements
| Tool | Role | License |
|---|---|---|
ESM3 (EvolutionaryScale) esm3-sm-open-v1 (1.4B) | Extract protein sequence embeddings | Academic/non-commercial open, commercial use requires separate agreement |
ESM2 esm2_t33_650M_UR50D (alternative baseline) | Use as an alternative if ESM3 is inaccessible | MIT |
HuggingFace transformers | Model loading and inference | Apache 2.0 |
| FAISS (Facebook AI Similarity Search) | High-dimensional vector KNN index | MIT |
| Biopython | FASTA parsing and sequence manipulation | Biopython License |
| UniProt Swiss-Prot | Curated protein sequences and GO labels | CC BY 4.0 |
| Chroma (optional) | Vector DB alternative (metadata filtering) | Apache 2.0 |
Infrastructure Requirements:
- Embedding extraction step: Small consumer GPU (RTX 4060 8GB or better). ESM3 1.4B consumes approximately 3GB of VRAM in fp16, with a batch size of 4-8 recommended.
- FAISS index building and search: CPU is sufficient. 16GB of RAM or more (500,000 sequences × 1024 dimensions × float32 = approximately 2GB).
- Disk: Compressed UniProt Swiss-Prot is approximately 200MB, and the embedding cache is approximately 2-4GB.
Estimated Cost for Learners: API cost is 0 (local execution). GPU time is approximately 2-4 hours (500,000 sequence embeddings, estimated based on RTX 4060). Data download is approximately 200MB.
Practical Implementation of the Pipeline
Overall Flow:
Step 1. Downloading and Parsing UniProt Swiss-Prot
Swiss-Prot is the manually curated section of UniProt, and its GO annotations are reliable labels.
import gzipfrom pathlib import Pathfrom typing import Iterator, NamedTuple
import requestsfrom Bio import SeqIO
SWISSPROT_URL = "https://ftp.uniprot.org/pub/databases/uniprot/current_release/knowledgebase/complete/uniprot_sprot.fasta.gz"
class ProteinRecord(NamedTuple): """Protein sequence record.""" accession: str sequence: str description: str go_terms: list[str]
def download_swissprot(dest: Path) -> Path: """Download UniProt Swiss-Prot FASTA.""" dest.parent.mkdir(parents=True, exist_ok=True) if dest.exists(): return dest with requests.get(SWISSPROT_URL, stream=True, timeout=300) as resp: resp.raise_for_status() with open(dest, "wb") as f: for chunk in resp.iter_content(chunk_size=8192): f.write(chunk) return dest
def parse_swissprot(fasta_gz: Path, max_len: int = 1024) -> Iterator[ProteinRecord]: """Streaming parsing of FASTA. Skip sequences longer than max_len (ESM context limit).""" with gzip.open(fasta_gz, "rt") as f: for record in SeqIO.parse(f, "fasta"): seq = str(record.seq).upper() if len(seq) > max_len or len(seq) < 30: continue # Extract accession from UniProt FASTA description (e.g., sp|P12345|GENE_ORG) parts = record.id.split("|") accession = parts[1] if len(parts) >= 2 else record.id yield ProteinRecord( accession=accession, sequence=seq, description=record.description, go_terms=[], # GO terms merged from a separate source, see Step below )GO annotations are merged from a separate file (goa_uniprot_all.gaf.gz [6]). In practice, it's convenient to join them using SQLite or DuckDB.
import sqlite3from collections import defaultdict
def build_go_index(gaf_path: Path, db_path: Path) -> None: """Index GAF (GO Annotation File) as accession → [GO IDs] mapping.""" conn = sqlite3.connect(db_path) conn.execute("CREATE TABLE IF NOT EXISTS go (accession TEXT, go_id TEXT)") conn.execute("CREATE INDEX IF NOT EXISTS idx_acc ON go(accession)") with gzip.open(gaf_path, "rt") as f: batch = [] for line in f: if line.startswith("!"): continue fields = line.split("\t") if len(fields) < 5: continue batch.append((fields[1], fields[4])) # DB_Object_ID, GO_ID if len(batch) >= 10000: conn.executemany("INSERT INTO go VALUES (?, ?)", batch) batch.clear() if batch: conn.executemany("INSERT INTO go VALUES (?, ?)", batch) conn.commit() conn.close()
def lookup_go_terms(accession: str, db_path: Path) -> list[str]: conn = sqlite3.connect(db_path) cursor = conn.execute("SELECT go_id FROM go WHERE accession = ?", (accession,)) terms = [row[0] for row in cursor] conn.close() return termsStep 2. Extracting ESM3 Embeddings
ESM3 Small Open Weights (1.4B, esm3-sm-open-v1) is a sequence-structure-function 3-track masked language model. Even if you only input the sequence, the last layer hidden state of the sequence track provides meaningful representations.
import torchfrom esm.models.esm3 import ESM3from esm.sdk.api import ESMProtein
class ESM3Embedder: """ESM3 sequence embedding extraction wrapper."""
def __init__(self, device: str = "cuda"): self.device = device self.model = ESM3.from_pretrained("esm3-sm-open-v1").to(device).eval()
@torch.no_grad() def embed(self, sequence: str) -> torch.Tensor: """Extract the sequence track last hidden state for a single sequence. Returns: A vector of shape (hidden_dim,). For the 1.4B model, hidden_dim=1536. """ protein = ESMProtein(sequence=sequence) encoded = self.model.encode(protein) # Extract the sequence track last hidden state output = self.model.forward(sequence_tokens=encoded.sequence.unsqueeze(0).to(self.device)) # (1, seq_len, hidden_dim) → average pooling → (hidden_dim,) return output.embeddings.squeeze(0).mean(dim=0).cpu()
def embed_batch(self, sequences: list[str], batch_size: int = 4) -> torch.Tensor: """Process a batch of sequences. Adjust batch_size to fit VRAM limits.""" embeddings = [] for i in range(0, len(sequences), batch_size): chunk = sequences[i:i + batch_size] for seq in chunk: embeddings.append(self.embed(seq)) return torch.stack(embeddings)ESM2 Alternative (If ESM3 is difficult to access, MIT license allows complete freedom):
from transformers import AutoTokenizer, AutoModel
class ESM2Embedder: """ESM2 baseline. hidden_dim=1280 (t33_650M)."""
MODEL_ID = "facebook/esm2_t33_650M_UR50D"
def __init__(self, device: str = "cuda"): self.device = device self.tokenizer = AutoTokenizer.from_pretrained(self.MODEL_ID) self.model = AutoModel.from_pretrained(self.MODEL_ID).to(device).eval()
@torch.no_grad() def embed(self, sequence: str) -> torch.Tensor: inputs = self.tokenizer(sequence, return_tensors="pt", truncation=True, max_length=1024).to(self.device) outputs = self.model(**inputs) # last_hidden_state: (1, seq_len, 1280) → average pooling → (1280,) return outputs.last_hidden_state.squeeze(0).mean(dim=0).cpu()Step 3. FAISS Index Build – Flat vs IVF vs HNSW Tuning
FAISS has three representative index types, each with different accuracy-speed-memory trade-offs.
- IndexFlatIP: exact search. Accuracy 100%, speed O(N), memory O(N × d).
- IndexIVFFlat: cluster-based approximate. Tune
nlist(number of clusters),nprobe(number of clusters to visit during search). Accuracy 95-99%, speed O(nprobe/nlist × N). - IndexHNSWFlat: graph-based approximate. Tune
M(number of graph neighbors),efConstruction,efSearch. Accuracy 98%+, very fast, memory somewhat large.
import faissimport numpy as np
def build_flat_index(embeddings: np.ndarray) -> faiss.Index: """Accuracy 100%, for benchmarking.""" d = embeddings.shape[1] index = faiss.IndexFlatIP(d) # inner product (equivalent to cosine similarity after embedding normalization) faiss.normalize_L2(embeddings) index.add(embeddings) return index
def build_ivf_index(embeddings: np.ndarray, nlist: int = 4096) -> faiss.Index: """IVF: nlist=sqrt(N) is an empirical optimum. Recommended nlist~700 for 500k sequences.""" d = embeddings.shape[1] quantizer = faiss.IndexFlatIP(d) index = faiss.IndexIVFFlat(quantizer, d, nlist, faiss.METRIC_INNER_PRODUCT) faiss.normalize_L2(embeddings) index.train(embeddings) index.add(embeddings) return index
def build_hnsw_index(embeddings: np.ndarray, M: int = 32) -> faiss.Index: """HNSW: M=16~64, higher means accuracy↑, memory↑. efConstruction=200 is standard.""" d = embeddings.shape[1] index = faiss.IndexHNSWFlat(d, M, faiss.METRIC_INNER_PRODUCT) index.hnsw.efConstruction = 200 faiss.normalize_L2(embeddings) index.add(embeddings) return indexStep 4. Query Sequence KNN Search
def search( index: faiss.Index, query_embedding: np.ndarray, k: int = 10, ef_search: int | None = None, nprobe: int | None = None,) -> tuple[np.ndarray, np.ndarray]: """Top-k neighbor search. HNSW efSearch, IVF nprobe can be adjusted during search.""" if ef_search is not None and hasattr(index, "hnsw"): index.hnsw.efSearch = ef_search if nprobe is not None and hasattr(index, "nprobe"): index.nprobe = nprobe faiss.normalize_L2(query_embedding.reshape(1, -1)) distances, indices = index.search(query_embedding.reshape(1, -1), k) return distances[0], indices[0]Step 5. Function Transfer – GO Term Majority Vote
Aggregate the GO annotations of the top-k neighbors to predict the GO terms of the query sequence.
from collections import Counter
def transfer_go_annotations( neighbor_accessions: list[str], neighbor_distances: list[float], db_path: Path, min_votes: int = 2, distance_threshold: float = 0.75,) -> dict[str, float]: """Aggregate GO terms of neighbors using distance-weighted majority vote. Returns: A dictionary of {go_id: confidence_score}. """ weighted_votes: Counter = Counter() total_weight = 0.0 for acc, dist in zip(neighbor_accessions, neighbor_distances): if dist < distance_threshold: continue weight = dist # Use the cosine similarity itself as the weight total_weight += weight for go_id in lookup_go_terms(acc, db_path): weighted_votes[go_id] += weight if total_weight == 0: return {} # Return only GO terms that appear at least min_votes times return { go_id: score / total_weight for go_id, score in weighted_votes.items() if score >= min_votes * (total_weight / len(neighbor_accessions)) }Step 6. BLAST Comparison Benchmark (PR curve)
The gold standard for comparing distant homology detection is structure-based databases like SCOP and CATH. Here, we refer to the CAFA-3 benchmark set [4].
from sklearn.metrics import precision_recall_curve, auc
def evaluate_pr_curve( query_pairs: list[tuple[str, str]], # (query_acc, target_acc) ground_truth: dict[str, set[str]], # query_acc → set of true homolog acc method: callable, # method(query_acc) → [(target_acc, score)]) -> dict: """Quantify detection performance using the PR curve.""" y_true, y_score = [], [] for query_acc, target_acc in query_pairs: preds = method(query_acc) # [(acc, score), ...] scored = {acc: score for acc, score in preds} y_true.append(1 if target_acc in ground_truth.get(query_acc, set()) else 0) y_score.append(scored.get(target_acc, 0.0)) precision, recall, _ = precision_recall_curve(y_true, y_score) return { "auprc": auc(recall, precision), "recall_at_p90": max( (r for p, r in zip(precision, recall) if p >= 0.9), default=0.0 ), }Integrated Pipeline
def full_pipeline( query_sequence: str, embedder: ESM3Embedder, index: faiss.Index, accession_map: list[str], # index order → accession go_db: Path, k: int = 20,) -> dict: """For a new sequence, perform search + function transfer.""" query_emb = embedder.embed(query_sequence).numpy() distances, indices = search(index, query_emb, k=k, ef_search=128) neighbor_accs = [accession_map[i] for i in indices] go_predictions = transfer_go_annotations(neighbor_accs, distances.tolist(), go_db) return { "query_length": len(query_sequence), "top_neighbors": list(zip(neighbor_accs, distances.tolist())), "predicted_go_terms": go_predictions, }
## Performance, Cost, and Known Failure Cases
### Performance Reference (Using Public Benchmarks)
| Approach | Dataset | Remote Homolog Recall@P=0.9 | Function Transfer F1 | Source ||---|---|:---:|:---:|---|| BLAST (E ≤ 1e-5) | SCOPe 40 twilight | 0.32 | 0.51 | Rives et al., PNAS 2021 [1] || PSI-BLAST 3 iter | SCOPe 40 | 0.44 | 0.58 | Rost 1999 (Classic) || HHblits | SCOPe 40 | 0.53 | 0.61 | Remmert et al., Nat Methods 2012 || ESM2 650M + KNN | CATH 20 | 0.61 | 0.64 | Rao et al., ICML 2021 [2] || ESM3 1.4B + KNN (HNSW) | CATH 20 | ~0.67 (Estimated) | ~0.68 (Estimated) | EvolutionaryScale 2024 [5] || Foldseek (Structure-based) | CATH 20 | 0.78 | — | van Kempen et al., Nat Biotech 2024 [3] |
Foldseek is still powerful, but requires pre-computed structure predictions (e.g., AlphaFold), while ESM embeddings have the advantage of being immediately searchable using only the sequence.
### Measured Performance by Index (Based on Official FAISS Benchmark)
| Index | 500k Vectors (d=1536) Search Latency | Accuracy (recall@10) | Memory ||---|:---:|:---:|:---:|| IndexFlatIP | 150~200ms | 100% | 3.0 GB || IndexIVFFlat (nlist=4096, nprobe=32) | 8~15ms | 96~98% | 3.1 GB || IndexHNSWFlat (M=32, efSearch=128) | 3~6ms | 98~99% | 4.5 GB |
**Recommendation:** HNSW for real-time services, IVF for batch analysis, Flat for benchmarking.
### Estimated Cost for Learner Reproduction
- API cost: 0 (fully local).- Embedding extraction: ~2-4 hours for 500k sequences using RTX 4060 8GB (40-60 sequences per second).- FAISS index building: ~20-30 minutes (CPU) for HNSW M=32.- Search: 3-6ms per query.
### 3 Known Failure Cases (Community/Paper Collection)
1. **Loss of domain information due to truncation of long sequences (>1024 AA)** Symptom: When embedding only the first 1024 AA of a multi-domain protein, the later domains are not reflected in the embedding. Cause: ESM context length limit. Solution: (a) Embed each domain separately and then average or concatenate, (b) generate multiple embeddings using a sliding window and then max-pool. Source: HuggingFace ESM Community — GitHub Issues [7].
2. **Remote homologs missed due to insufficient nprobe in IVF index** Symptom: Recall drops sharply when nprobe is set to the default value (1). This is because remote homologs are likely to be clustered in a different cluster. Cause: IVF only visits clusters near the quantizer. Distant homologs are assigned to different clusters due to differences in their evolutionary lineage. Solution: Set nprobe to at least 1% of nlist (50-100). An adaptive nprobe logic is recommended for production services. Source: FAISS Wiki — "Guidelines for choosing an index" [8].
3. **Confusion between cosine and L2 similarity due to missing embedding normalization** Symptom: When using IndexFlatIP without normalizing the embeddings, the inner product deviates from cosine similarity, distorting comparisons between proteins of different lengths. Cause: ESM embedding norms vary depending on sequence length and composition. Solution: Always call `faiss.normalize_L2()` before adding and querying the index. Source: FAISS FAQ — "Normalization for cosine similarity" [9].
## Expansion Ideas
- **Chroma Vector DB Integration:** Use Chroma/Qdrant instead of FAISS when metadata filtering is needed (e.g., search only for specific taxa or specific GO terms).- **Hybrid Search:** Ensemble embedding KNN + BLAST HSP score. Advantageous in situations requiring high precision.- **Combine Structural Embeddings:** Combine ESM3's structure track + Foldseek 3Di alphabet to perform dual sequence-structure search.- **Korean UI Service:** Build an in-house service using FastAPI + Streamlit. Input a new sequence, and the system immediately returns the top-10 similar sequences + GO predictions.
## Next Chapter
- Chapter 08 `docking-hybrid-diffusion`: Use embedding search to list candidate target proteins, then validate with DiffDock-Glide.- Chapter 11 `structure-affinity-boltz`: Find similar proteins using embeddings, then predict binding affinity with Boltz-2 (drug repurposing pipeline).- Chapter 12 `single-cell-perturbation`: Select target candidates for single-cell gene perturbation using embedding similarity.- Chapter 13 `protein-design-multimodal`: Utilize ESM3's structure/function tracks, corresponding to the sequence track in this chapter.- Chapter 14 `bio-mcp-agent`: Expose the search pipeline in this chapter as an MCP tool for autonomous agents to use.
## References
1. Rives A, Meier J, Sercu T, et al. "Biological structure and function emerge from scaling unsupervised learning to 250 million protein sequences." PNAS 2021. `https://www.pnas.org/doi/10.1073/pnas.2016239118`2. Rao R, Meier J, Sercu T, Ovchinnikov S, Rives A. "Transformer protein language models are unsupervised structure learners." ICLR 2021. `https://openreview.net/forum?id=fylclEqgvgd`3. van Kempen M, Kim S, Tumescheit C, et al. "Fast and accurate protein structure search with Foldseek." Nature Biotechnology 2024. `https://www.nature.com/articles/s41587-023-01773-0`4. CAFA (Critical Assessment of Function Annotation): `https://www.biofunctionprediction.org/cafa/`5. Hayes T, Rao R, Akin H, et al. "Simulating 500 million years of evolution with a language model." bioRxiv 2024 (ESM3). `https://www.biorxiv.org/content/10.1101/2024.07.01.600583v1`6. UniProt-GOA: `https://www.ebi.ac.uk/GOA/downloads`7. HuggingFace ESM Community Discussion: `https://huggingface.co/facebook/esm2_t33_650M_UR50D/discussions`8. FAISS Wiki — Guidelines for choosing an index: `https://github.com/facebookresearch/faiss/wiki/Guidelines-to-choose-an-index`9. FAISS FAQ — Normalization: `https://github.com/facebookresearch/faiss/wiki/FAQ`10. EvolutionaryScale ESM3 GitHub: `https://github.com/evolutionaryscale/esm`11. FAISS GitHub: `https://github.com/facebookresearch/faiss`12. UniProt Swiss-Prot: `https://www.uniprot.org/`13. Biopython: `https://biopython.org/`14. SCOPe (Structural Classification of Proteins extended): `https://scop.berkeley.edu/`15. CATH (Class · Architecture · Topology · Homology) database: `https://www.cathdb.info/`