단백질 서열 임베딩 FAISS 검색 — BLAST가 놓치는 원거리 상동체를 파운데이션 모델로 잡기
BLAST는 30년째 단백질 상동성 검색의 표준이지만 서열 유사도 30% 이하에서 급격히 무너집니다. 그런데 진화적으로 관련 있으면서 서열은 크게 다른 원거리 상동체(remote homolog)가 실제 자연에는 무수히 많고, 이걸 놓치면 새 유전자의 기능 추정이 막힙니다. 이 편은 단백질 파운데이션 모델(ESM3)이 학습한 잠재 공간에서 이 문제를 풀고, FAISS로 대용량 서열 데이터베이스에 실시간 서비스 수준의 검색을 얹는 실전 파이프라인을 구축합니다.
📚 선수 편 권고 (강력 권고)
이 편은 AI×바이오 하드코어 심화 편입니다. 진입 전에 DryBench의 다음 편들을 먼저 듣고·보시길 강력히 권고드립니다.
- DryBench ai-native #3 트랜스포머와 임베딩
- DryBench ai-native #5 어텐션 메커니즘
- DryBench ai-native #13 HuggingFace와 상용 API
선수 편 없이 진입할 경우, 이 편에서 다루는 임베딩 벡터의 의미·어텐션의 정보 집약·HuggingFace 모델 로딩의 원리 재설명 없이 실전 코드부터 진행하므로 따라가기 어렵습니다.
우리 DryBench에서 이거 배웠잖아
DryBench ai-native #3에서 트랜스포머가 토큰들 사이의 관계를 학습해 각 토큰을 문맥이 반영된 밀집 벡터(dense embedding)로 표현한다는 것을, #5에서 어텐션이 시퀀스 내 임의의 두 위치가 서로에게 얼마나 중요한지를 학습해 장거리 의존을 잡는 메커니즘이라는 것을, #13에서 HuggingFace가 이런 모델의 가중치·토크나이저·추론 API를 표준화해 몇 줄로 로딩 가능하게 만든 생태계라는 것을 배웠습니다.
그런데 이 세 가지를 단백질 도메인에 적용하면 어떤 일이 벌어질까요. 자연어와 달리 단백질은 "문장"이 진화적 압력을 30억 년 동안 받으며 다듬어져 왔고, 문맥(이웃 아미노산) 정보가 3차원 접힘 구조를 결정합니다. Meta AI(→ EvolutionaryScale 스핀아웃)의 ESM 시리즈는 이 진화적 정보를 트랜스포머로 학습한 첫 대규모 단백질 파운데이션 모델이고, 학습된 임베딩이 놀랍게도 단백질의 3D 구조·기능·안정성을 잠재적으로 담고 있음이 여러 논문에서 확인됐습니다 [1][2][5]. 이 편은 그 임베딩을 실제 유용한 검색 도구로 만드는 파이프라인입니다.
하드코어 문제 정의
BLAST의 근본 한계
BLAST(Basic Local Alignment Search Tool, 1990)는 시퀀스 유사도를 substitution matrix + gap penalty로 점수화해 통계적으로 유의미한 hit을 반환합니다. 이 방식은 서열이 서로 30~40% 이상 일치하는 근거리 상동체(close homolog)에는 압도적으로 정확하지만, 아래 두 가지 상황에서 무너집니다.
- 원거리 상동체 (remote homolog): 진화적으로 관련 있으나 서열 identity가 20
30%로 떨어진 경우. 3D 구조와 기능은 여전히 보존되어 있지만 BLAST는 통계적 유의성을 잃습니다. Twilight zone(황혼 영역)이라 불리는 2035% 구간이 특히 어렵고, midnight zone(< 20%)은 사실상 검출 불가. - 다중 도메인 단백질의 특정 도메인 매칭: BLAST는 전체 서열의 local alignment를 찾지만, 전체 정렬 스코어가 낮으면 개별 도메인이 강하게 상동이어도 놓칩니다.
전통 우회책은 PSI-BLAST(iterative profile), HHblits(HMM-HMM), Foldseek(구조 기반) 등이지만 각각 계산 비용·데이터 의존성 문제가 있습니다.
임베딩 접근이 해결하는 것
ESM3 같은 파운데이션 모델은 학습 과정에서 진화적 상관(evolutionary coupling)을 잠재 공간에 흡수합니다. 결과 임베딩은 서열 유사도가 낮아도 구조·기능이 유사하면 벡터 공간에서 가깝습니다. 이 성질을 활용하면:
- BLAST가 놓친 remote homolog를 임베딩 KNN 검색으로 회수 가능 (문헌 근거 [2][3]).
- 서열 하나당 임베딩 1회 계산이면 이후 모든 검색은 벡터 유사도 계산만 필요 → BLAST의 pairwise alignment 대비 대용량에서 유리.
- Function transfer(예: GO term)는 검색된 top-k 이웃의 라벨 다수결로 즉시 예측 가능.
이 편의 목표 지표
- UniProt Swiss-Prot subset 50만 서열에 대해 임베딩 인덱스 빌드.
- Query 1000건 remote homolog 검출률: BLAST 대비 재현율(recall) +25%p 이상 향상 (CAFA 벤치 근거 [4]).
- 평균 검색 지연시간: query 당 100ms 이하 (FAISS HNSW 튜닝).
- GO term function transfer F1: 0.65 이상 (CAFA-3 상위 수준).
도구 스택과 인프라 요구
| 도구 | 역할 | 라이선스 |
|---|---|---|
ESM3 (EvolutionaryScale) esm3-sm-open-v1 (1.4B) | 단백질 서열 임베딩 추출 | 학술·비상업 오픈, 상업 이용 별도 협의 |
ESM2 esm2_t33_650M_UR50D (대안 baseline) | ESM3 접근 불가 시 대체 | MIT |
HuggingFace transformers | 모델 로딩·추론 | Apache 2.0 |
| FAISS (Facebook AI Similarity Search) | 고차원 벡터 KNN 인덱스 | MIT |
| Biopython | FASTA 파싱·서열 조작 | Biopython License |
| UniProt Swiss-Prot | 정제된 단백질 서열·GO 라벨 | CC BY 4.0 |
| Chroma (선택) | 벡터 DB 대안 (metadata 필터링) | Apache 2.0 |
인프라 요구:
- 임베딩 추출 단계: 소형 소비자 GPU (RTX 4060 8GB 이상). ESM3 1.4B는 fp16 기준 약 3GB VRAM 소모, batch 4~8 권장.
- FAISS 인덱스 빌드·검색: CPU만으로 충분. RAM 16GB 이상 (50만 서열 × 1024차원 float32 = 약 2GB).
- 디스크: UniProt Swiss-Prot 압축 약 200MB, 임베딩 캐시 약 2~4GB.
학습자 재현 예상 비용: API 비용 0(로컬 실행). GPU 시간 약 2~4시간(50만 서열 임베딩, RTX 4060 기준 추정). 데이터 다운로드 약 200MB.
파이프라인 실전 구현
전체 흐름:
Step 1. UniProt Swiss-Prot 다운로드와 파싱
Swiss-Prot은 UniProt의 수작업 큐레이션 부분으로, GO annotation이 신뢰 가능한 라벨로 붙어있습니다.
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): """단백질 서열 레코드.""" accession: str sequence: str description: str go_terms: list[str]
def download_swissprot(dest: Path) -> Path: """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]: """FASTA 스트리밍 파싱. max_len 이상 서열은 스킵 (ESM 컨텍스트 한계).""" 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 # UniProt FASTA description에서 accession 추출 (예: 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는 별도 소스에서 병합, 아래 Step 참조 )GO annotation은 별도 파일(goa_uniprot_all.gaf.gz [6])에서 병합합니다. 실전에서는 SQLite 또는 DuckDB로 조인해두면 편합니다.
import sqlite3from collections import defaultdict
def build_go_index(gaf_path: Path, db_path: Path) -> None: """GAF (GO Annotation File)을 accession → [GO IDs] 매핑으로 인덱싱.""" 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. ESM3 임베딩 추출
ESM3 소형 오픈 가중치(1.4B, esm3-sm-open-v1)는 서열-구조-기능 3-트랙 마스크 언어모델입니다. 서열만 입력해도 시퀀스 트랙의 마지막 층 hidden state가 유의미한 표현으로 나옵니다.
import torchfrom esm.models.esm3 import ESM3from esm.sdk.api import ESMProtein
class ESM3Embedder: """ESM3 서열 임베딩 추출 래퍼."""
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: """서열 하나의 시퀀스 트랙 last hidden state의 평균 pooling. Returns: (hidden_dim,) 벡터. 1.4B 모델 기준 hidden_dim=1536. """ protein = ESMProtein(sequence=sequence) encoded = self.model.encode(protein) # 시퀀스 트랙 last hidden state 추출 output = self.model.forward(sequence_tokens=encoded.sequence.unsqueeze(0).to(self.device)) # (1, seq_len, hidden_dim) → 평균 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: """여러 서열 배치 처리. VRAM 한계에 맞춰 batch_size 조정.""" 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 대안 (ESM3 접근 어려운 경우, MIT 라이선스로 완전 자유):
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) → 평균 pooling → (1280,) return outputs.last_hidden_state.squeeze(0).mean(dim=0).cpu()Step 3. FAISS 인덱스 빌드 — Flat vs IVF vs HNSW 튜닝
FAISS는 세 가지 대표 인덱스가 있고 각각 정확도-속도-메모리 trade-off가 다릅니다.
- IndexFlatIP: exact search. 정확도 100%, 속도 O(N), 메모리 O(N × d).
- IndexIVFFlat: cluster-based approximate.
nlist(cluster 수),nprobe(검색 시 방문 cluster 수) 튜닝. 정확도 95~99%, 속도 O(nprobe/nlist × N). - IndexHNSWFlat: graph-based approximate.
M(그래프 이웃 수),efConstruction,efSearch튜닝. 정확도 98%+, 매우 빠름, 메모리 다소 큼.
import faissimport numpy as np
def build_flat_index(embeddings: np.ndarray) -> faiss.Index: """정확도 100%, 벤치마크 기준용.""" d = embeddings.shape[1] index = faiss.IndexFlatIP(d) # inner product (임베딩 정규화 후 코사인 유사도와 동치) 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) 근처가 경험적 최적. 50만 서열이면 nlist~700 권장.""" 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, 크면 정확도↑ 메모리↑. efConstruction=200 표준.""" 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 서열 KNN 검색
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 이웃 검색. HNSW efSearch, IVF nprobe는 검색 시 조정 가능.""" 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 다수결
top-k 이웃의 GO annotation을 집계해 query 서열의 GO term을 예측합니다.
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]: """이웃의 GO term을 거리 가중 다수결로 집계. Returns: {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 # 코사인 유사도 자체를 가중치로 total_weight += weight for go_id in lookup_go_terms(acc, db_path): weighted_votes[go_id] += weight if total_weight == 0: return {} # 최소 min_votes 이상 등장한 GO term만 반환 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 비교 벤치 (PR curve)
원거리 상동체 검출력 비교의 gold standard는 SCOP·CATH 같은 구조 기반 데이터베이스입니다. 여기서는 CAFA-3 벤치 세트를 참고합니다 [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: """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 ), }통합 파이프라인
def full_pipeline( query_sequence: str, embedder: ESM3Embedder, index: faiss.Index, accession_map: list[str], # index 순서 → accession go_db: Path, k: int = 20,) -> dict: """새 서열 하나에 대해 검색 + 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, }성능·비용·알려진 실패 케이스
성능 참고 (공개 벤치 인용)
| 접근 | 데이터셋 | Remote homolog Recall@P=0.9 | Function Transfer F1 | 출처 |
|---|---|---|---|---|
| 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 (고전) |
| 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 (추정) | ~0.68 (추정) | EvolutionaryScale 2024 [5] |
| Foldseek (구조 기반) | CATH 20 | 0.78 | — | van Kempen et al., Nat Biotech 2024 [3] |
Foldseek이 여전히 강력하지만 구조 예측(AlphaFold 등) 선행 계산이 필요한 반면, ESM 임베딩은 서열만으로 즉시 검색 가능한 이점이 있습니다.
인덱스별 실측 참고 (공식 FAISS 벤치 기반 계산)
| 인덱스 | 50만 벡터 (d=1536) 검색 지연 | 정확도 (recall@10) | 메모리 |
|---|---|---|---|
| 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 |
권고: 실시간 서비스는 HNSW, 배치 분석은 IVF, 벤치마크 기준은 Flat.
학습자 재현 예상 비용
- API 비용 0 (완전 로컬).
- 임베딩 추출: RTX 4060 8GB 기준 50만 서열 약 2
4시간 (초당 4060 서열). - FAISS 인덱스 빌드: HNSW M=32 기준 CPU 20~30분.
- 검색: query 당 3~6ms.
알려진 실패 케이스 3건 (커뮤니티·논문 수집형)
-
긴 서열(> 1024 AA) truncation으로 도메인 정보 손실
증상: 다중 도메인 단백질에서 첫 1024 AA만 임베딩하면 뒤쪽 도메인이 임베딩에 반영 안 됨.
원인: ESM 컨텍스트 최대 길이 제한.
회피: (a) 도메인별 분할 임베딩 후 평균 or 연결, (b) sliding window로 여러 임베딩 생성 후 max-pool.
출처: HuggingFace ESM Community — GitHub Issues [7]. -
IVF 인덱스 nprobe 과소로 remote homolog 놓침
증상: nprobe를 기본값(1)로 두면 재현율이 급락. remote homolog가 다른 cluster에 잡혀있을 확률이 높기 때문.
원인: IVF는 quantizer 근처 cluster만 방문하는데, 원거리 상동체는 진화적 계보 자체가 달라 다른 cluster로 배정됨.
회피: nprobe를 nlist의 최소 1%(50~100) 이상으로. 실전 서비스에서는 nprobe adaptive 로직 권장.
출처: FAISS Wiki — "Guidelines for choosing an index" [8]. -
임베딩 정규화 누락으로 코사인 vs L2 혼동
증상: IndexFlatIP 사용하면서 임베딩을 정규화하지 않으면 inner product가 코사인 유사도와 어긋남. 서열 길이가 다른 단백질 간 비교가 왜곡.
원인: ESM 임베딩 norm이 서열 길이·조성에 따라 다름.
회피:faiss.normalize_L2()반드시 인덱스 add 전과 query 전 모두 호출.
출처: FAISS FAQ — "Normalization for cosine similarity" [9].
확장 아이디어
- Chroma 벡터 DB 연동: metadata 필터링(예: 특정 taxon 또는 특정 GO 분야만 검색) 필요할 때 FAISS 대신 Chroma / Qdrant.
- 하이브리드 검색: 임베딩 KNN + BLAST HSP score 앙상블. Precision이 요구되는 상황에서 유리.
- 구조 임베딩 결합: ESM3의 structure track + Foldseek 3Di 알파벳을 결합해 서열-구조 이중 검색.
- 한국어 UI 서비스화: FastAPI + Streamlit으로 실험실 내부 서비스 구축. 신규 서열 하나 입력하면 top-10 유사 서열 + GO 예측 즉시 반환.
다음 편
- 편 08
docking-hybrid-diffusion: 임베딩 검색으로 후보 표적 단백질 리스트업 → DiffDock-Glide로 도킹 검증. - 편 11
structure-affinity-boltz: 임베딩으로 유사 단백질 찾은 뒤 Boltz-2로 결합 친화도 예측 (drug repurposing 파이프라인). - 편 12
single-cell-perturbation: 단일세포 유전자 섭동의 표적 후보를 임베딩 유사도로 선별. - 편 13
protein-design-multimodal: ESM3의 structure/function 트랙 활용, 이 편의 sequence 트랙과 대응. - 편 14
bio-mcp-agent: 이 편의 검색 파이프라인을 MCP tool로 노출해 자율 에이전트가 활용.
참고 문헌
- 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 - 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 - 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 - CAFA (Critical Assessment of Function Annotation):
https://www.biofunctionprediction.org/cafa/ - 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 - UniProt-GOA:
https://www.ebi.ac.uk/GOA/downloads - HuggingFace ESM Community Discussion:
https://huggingface.co/facebook/esm2_t33_650M_UR50D/discussions - FAISS Wiki — Guidelines for choosing an index:
https://github.com/facebookresearch/faiss/wiki/Guidelines-to-choose-an-index - FAISS FAQ — Normalization:
https://github.com/facebookresearch/faiss/wiki/FAQ - EvolutionaryScale ESM3 GitHub:
https://github.com/evolutionaryscale/esm - FAISS GitHub:
https://github.com/facebookresearch/faiss - UniProt Swiss-Prot:
https://www.uniprot.org/ - Biopython:
https://biopython.org/ - SCOPe (Structural Classification of Proteins extended):
https://scop.berkeley.edu/ - CATH (Class · Architecture · Topology · Homology) database:
https://www.cathdb.info/