Web Sitesi Verilerini LangChain ile Çekme ve İşleme
Modern yapay zeka uygulamaları geliştirirken en sık karşılaştığımız ihtiyaçlardan biri web sitelerindeki verileri akıllıca çekip işlemek. Özellikle RAG (Retrieval-Augmented Generation) sistemleri kuruyorsan ya da bir chatbot’a belirli bir web sitesinin içeriğini öğretmek istiyorsan, bu işi doğru yapmak kritik önem taşıyor. LangChain bu noktada hem web scraping hem de veri işleme konusunda oldukça güçlü araçlar sunuyor. Bugün bu araçları nasıl kullanacağımızı, gerçek dünya senaryolarıyla birlikte ele alacağız.
Ortamı Hazırlamak
Başlamadan önce gerekli paketleri kurmamız gerekiyor. Python sanal ortamı kullanmayı kesinlikle tavsiye ederim, yoksa proje bağımlılıkları sistemi kasıp kavurur.
# Sanal ortam oluştur ve aktif et
python3 -m venv langchain-web-env
source langchain-web-env/bin/activate # Linux/macOS
# Windows için: langchain-web-envScriptsactivate
# Gerekli paketleri kur
pip install langchain langchain-community langchain-openai
pip install beautifulsoup4 requests playwright
pip install chromadb tiktoken unstructured
pip install python-dotenv
# Playwright tarayıcılarını kur (dinamik siteler için)
playwright install chromium
Kurulum tamamlandıktan sonra API anahtarlarını yönetmek için bir .env dosyası oluşturalım:
# .env dosyası oluştur
cat > .env << 'EOF'
OPENAI_API_KEY=sk-your-key-here
LANGCHAIN_TRACING_V2=true
LANGCHAIN_API_KEY=your-langsmith-key
EOF
# Dosyayı sadece sen okuyabilsin
chmod 600 .env
Temel Web Yükleme: WebBaseLoader
LangChain’in en basit web yükleme aracı WebBaseLoader. Statik HTML içerik için gayet iyi çalışıyor. Özellikle belgelendirme siteleri, blog yazıları veya haber siteleri için ideal.
from langchain_community.document_loaders import WebBaseLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
import bs4
# Tek bir URL'den veri çek
loader = WebBaseLoader(
web_paths=["https://docs.python.org/3/library/os.html"],
bs_kwargs={
"parse_only": bs4.SoupStrainer(
class_=("section", "body")
)
}
)
documents = loader.load()
print(f"Yüklenen döküman sayısı: {len(documents)}")
print(f"İlk dökümanın karakter uzunluğu: {len(documents[0].page_content)}")
print(f"Metadata: {documents[0].metadata}")
Burada bs_kwargs parametresiyle BeautifulSoup’a hangi HTML elementlerini parse etmesini istediğimizi söylüyoruz. Bu çok önemli, çünkü navigation menüler, footer’lar ve reklamlar gibi gürültülü içerikleri filtreleyerek sadece işe yarar metni alıyoruz.
Birden Fazla URL’den Veri Çekmek
Gerçek projelerde genellikle tek bir sayfa değil, bir web sitesinin belirli bölümlerini toplu olarak işlememiz gerekiyor. Diyelim ki bir şirketin tüm destek dökümanlarını bir chatbot’a öğretmek istiyorsun:
from langchain_community.document_loaders import WebBaseLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
import time
# Birden fazla URL tanımla
urls = [
"https://docs.ansible.com/ansible/latest/getting_started/index.html",
"https://docs.ansible.com/ansible/latest/installation_guide/index.html",
"https://docs.ansible.com/ansible/latest/playbook_guide/index.html",
]
# Rate limiting ile yükle (sunucuyu patlatmamak için)
all_documents = []
for url in urls:
try:
loader = WebBaseLoader(url)
docs = loader.load()
all_documents.extend(docs)
print(f"Yüklendi: {url} ({len(docs)} döküman)")
time.sleep(1) # 1 saniye bekle
except Exception as e:
print(f"Hata oluştu {url}: {e}")
print(f"nToplam yüklenen döküman: {len(all_documents)}")
# Metni parçalara böl
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=200,
length_function=len,
separators=["nn", "n", " ", ""]
)
splits = text_splitter.split_documents(all_documents)
print(f"Toplam chunk sayısı: {len(splits)}")
chunk_size ve chunk_overlap parametrelerini açıklayalım:
- chunk_size: Her parçanın maksimum karakter sayısı. 1000 genellikle iyi bir başlangıç noktası.
- chunk_overlap: Ardışık parçalar arasındaki örtüşme miktarı. Bağlamın korunması için kritik.
- separators: Bölme öncelik sırası. Önce paragraflardan, sonra satırlardan böler.
Dinamik JavaScript İçerikleri: PlaywrightURLLoader
Bazı modern web siteleri içeriklerini JavaScript ile render ediyor. LinkedIn, Twitter gibi sosyal medya platformları veya SPA (Single Page Application) yapısındaki siteler buna örnek. Bu durumda standart HTTP request’leri işe yaramıyor, gerçek bir tarayıcı simülasyonu gerekiyor.
from langchain_community.document_loaders import PlaywrightURLLoader
import asyncio
# JavaScript gerektiren siteler için Playwright kullan
urls = [
"https://nextjs.org/docs/getting-started/installation",
]
loader = PlaywrightURLLoader(
urls=urls,
remove_selectors=["nav", "footer", ".sidebar", ".cookie-banner"],
headless=True # Tarayıcı penceresini gösterme
)
# Async olarak çalıştır
async def load_dynamic_content():
documents = await loader.aload()
for doc in documents:
print(f"URL: {doc.metadata['source']}")
print(f"İçerik uzunluğu: {len(doc.page_content)} karakter")
print(f"İlk 500 karakter: {doc.page_content[:500]}")
return documents
# Event loop üzerinde çalıştır
documents = asyncio.run(load_dynamic_content())
remove_selectors parametresi çok güçlü bir özellik. CSS seçicileri kullanarak istemediğimiz elementleri parse etmeden önce DOM’dan çıkarıyoruz. Navigation menüler, çerez uyarıları, reklamlar gibi gürültüyü bu şekilde temizleyebilirsin.
Sitemap ile Akıllıca URL Keşfi
Bir web sitesinin tüm sayfalarını çekmek istiyorsan ve sitenin bir sitemap.xml dosyası varsa, tek tek URL girmene gerek yok. LangChain’in SitemapLoader‘ı bu işi otomatik hallediyor.
from langchain_community.document_loaders.sitemap import SitemapLoader
import re
# Sadece belirli URL pattern'larını çek
def filter_blog_posts(url: str) -> bool:
"""Sadece blog yazılarını filtrele"""
return bool(re.match(r"https://example.com/blog/d{4}/.*", url))
loader = SitemapLoader(
web_path="https://www.langchain.com/sitemap.xml",
filter_urls=["https://www.langchain.com/blog"],
parsing_function=None, # Özel parse fonksiyonu eklenebilir
continue_on_failure=True, # Hata olursa devam et
requests_per_second=2 # Saniyede maksimum 2 istek
)
# Meta tag'ları da çek
loader.requests_kwargs = {
"headers": {
"User-Agent": "Mozilla/5.0 (compatible; MyBot/1.0)"
}
}
docs = loader.load()
print(f"Sitemap'ten yüklenen döküman sayısı: {len(docs)}")
# Her dökümanın URL'ini listele
for doc in docs[:5]:
print(f"- {doc.metadata.get('source', 'Bilinmiyor')}")
continue_on_failure=True parametresi production ortamları için hayat kurtarıcı. Bir URL çekilemezse script tamamen durmak yerine devam ediyor ve sadece log yazıyor.
İçerik Temizleme ve Metadata Zenginleştirme
Ham web verisi her zaman temiz gelmiyor. Gereksiz boşluklar, özel karakterler, JavaScript kalıntıları sıklıkla karşılaştığımız sorunlar. Ayrıca daha sonra filtreleme yapabilmek için metadata eklemek de önemli.
from langchain_community.document_loaders import WebBaseLoader
from langchain.schema import Document
import re
from datetime import datetime
from urllib.parse import urlparse
def clean_text(text: str) -> str:
"""Web içeriğini temizle"""
# Fazla boşlukları temizle
text = re.sub(r'n{3,}', 'nn', text)
text = re.sub(r' {2,}', ' ', text)
# Özel karakterleri temizle
text = re.sub(r'[x00-x08x0bx0cx0e-x1fx7f]', '', text)
# JavaScript kalıntılarını temizle
text = re.sub(r'<script[^>]*>.*?</script>', '', text, flags=re.DOTALL)
return text.strip()
def enrich_metadata(doc: Document, category: str = "general") -> Document:
"""Dökümanın metadata'sını zenginleştir"""
url = doc.metadata.get("source", "")
parsed_url = urlparse(url)
doc.metadata.update({
"domain": parsed_url.netloc,
"path": parsed_url.path,
"category": category,
"ingestion_date": datetime.now().isoformat(),
"char_count": len(doc.page_content),
"word_count": len(doc.page_content.split())
})
# İçeriği temizle
doc.page_content = clean_text(doc.page_content)
return doc
# Kullanım örneği
urls = {
"https://kubernetes.io/docs/concepts/overview/": "kubernetes-docs",
"https://kubernetes.io/docs/tasks/tools/": "kubernetes-tools",
}
enriched_documents = []
for url, category in urls.items():
loader = WebBaseLoader(url)
docs = loader.load()
for doc in docs:
enriched_doc = enrich_metadata(doc, category)
if enriched_doc.metadata["word_count"] > 50: # Çok kısa sayfaları atla
enriched_documents.append(enriched_doc)
print(f"İşlenen döküman sayısı: {len(enriched_documents)}")
for doc in enriched_documents:
print(f"Domain: {doc.metadata['domain']} | Kategori: {doc.metadata['category']} | Kelime: {doc.metadata['word_count']}")
Vektör Veritabanına Kaydetme ve Sorgulama
Veriler hazır olduğunda bunları bir vektör veritabanına kaydetmemiz gerekiyor. Bu sayede anlamsal arama yapabiliriz. Burada ChromaDB kullanacağız, hem local hem de production için uygun.
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings
from langchain.text_splitter import RecursiveCharacterTextSplitter
from dotenv import load_dotenv
import os
load_dotenv()
# Embedding modeli hazırla
embeddings = OpenAIEmbeddings(
model="text-embedding-3-small", # Daha ucuz ve hızlı
openai_api_key=os.getenv("OPENAI_API_KEY")
)
# Dökümanları parçala
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=200
)
splits = text_splitter.split_documents(enriched_documents)
# ChromaDB'ye kaydet
vectorstore = Chroma.from_documents(
documents=splits,
embedding=embeddings,
persist_directory="./chroma_db",
collection_name="web_content"
)
print(f"Vektör veritabanına {len(splits)} chunk kaydedildi")
# Anlamsal arama testi
query = "Kubernetes pod nasıl oluşturulur?"
results = vectorstore.similarity_search_with_score(
query=query,
k=3,
filter={"category": "kubernetes-docs"} # Metadata ile filtrele
)
print(f"n'{query}' sorgusu için sonuçlar:")
for doc, score in results:
print(f"nBenzerlik skoru: {score:.4f}")
print(f"Kaynak: {doc.metadata.get('source')}")
print(f"İçerik önizleme: {doc.page_content[:200]}...")
Tam RAG Pipeline’ı Kurma
Artık her şeyi bir araya getirecek zaman geldi. Gerçek bir kullanım senaryosu olarak şunu düşün: Şirketin kendi iç wiki sitesi var ve çalışanlar bu wiki’yi sorgulayabileceği bir chatbot istiyor.
from langchain_openai import ChatOpenAI
from langchain.chains import RetrievalQA
from langchain.prompts import PromptTemplate
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings
from dotenv import load_dotenv
import os
load_dotenv()
# Mevcut vektör veritabanını yükle
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectorstore = Chroma(
persist_directory="./chroma_db",
embedding_function=embeddings,
collection_name="web_content"
)
# Retriever ayarla
retriever = vectorstore.as_retriever(
search_type="mmr", # Maximum Marginal Relevance - çeşitlilik için
search_kwargs={
"k": 5,
"fetch_k": 20,
"lambda_mult": 0.7
}
)
# Özel prompt template
prompt_template = """Sen bir teknik asistansın. Aşağıdaki bağlamı kullanarak soruyu Türkçe olarak yanıtla.
Eğer bağlamda yeterli bilgi yoksa, "Bu konuda yeterli bilgi bulamadım" de.
Kesinlikle uydurma bilgi verme.
Bağlam:
{context}
Soru: {question}
Yanıt:"""
PROMPT = PromptTemplate(
template=prompt_template,
input_variables=["context", "question"]
)
# LLM tanımla
llm = ChatOpenAI(
model="gpt-4o-mini",
temperature=0,
max_tokens=1000
)
# RAG zincirini oluştur
qa_chain = RetrievalQA.from_chain_type(
llm=llm,
chain_type="stuff",
retriever=retriever,
return_source_documents=True,
chain_type_kwargs={"prompt": PROMPT}
)
# Sorgula
def ask_question(question: str):
result = qa_chain.invoke({"query": question})
print(f"Soru: {question}")
print(f"nYanıt: {result['result']}")
print("nKaynaklar:")
seen_sources = set()
for doc in result["source_documents"]:
source = doc.metadata.get("source", "Bilinmiyor")
if source not in seen_sources:
seen_sources.add(source)
print(f" - {source}")
# Test et
ask_question("Kubernetes'te namespace nedir ve nasıl kullanılır?")
Periyodik Güncelleme: Veriyi Taze Tutmak
Web siteleri sürekli güncelleniyor. Kurduğun RAG sisteminin güncel kalması için periyodik yenileme mekanizması şart. Bunu bir cron job ya da systemd timer ile çalıştırabilirsin.
# update_vectordb.py scriptini çalıştıracak shell wrapper
cat > /opt/langchain-bot/update_knowledge.sh << 'EOF'
#!/bin/bash
set -euo pipefail
LOG_FILE="/var/log/langchain-bot/update.log"
VENV_PATH="/opt/langchain-bot/venv"
SCRIPT_PATH="/opt/langchain-bot/update_vectordb.py"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Güncelleme başlıyor..." >> "$LOG_FILE"
source "$VENV_PATH/bin/activate"
if python3 "$SCRIPT_PATH" >> "$LOG_FILE" 2>&1; then
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Güncelleme başarılı" >> "$LOG_FILE"
else
echo "[$(date '+%Y-%m-%d %H:%M:%S')] HATA: Güncelleme başarısız!" >> "$LOG_FILE"
# Slack'e bildirim gönder (opsiyonel)
curl -s -X POST "$SLACK_WEBHOOK_URL"
-H 'Content-type: application/json'
-d '{"text": "LangChain bot güncelleme hatası! Log kontrol edin."}' || true
fi
EOF
chmod +x /opt/langchain-bot/update_knowledge.sh
# Cron job ekle (her gün gece 02:00'de çalıştır)
(crontab -l 2>/dev/null; echo "0 2 * * * /opt/langchain-bot/update_knowledge.sh") | crontab -
# Systemd timer alternatifi
cat > /etc/systemd/system/langchain-update.timer << 'EOF'
[Unit]
Description=LangChain Knowledge Base Update Timer
[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true
[Install]
WantedBy=timers.target
EOF
systemctl enable --now langchain-update.timer
Sık Karşılaşılan Sorunlar ve Çözümleri
Rate limiting sorunları: Bir web sitesi çok fazla istek geldiğinde seni engelleyebilir. Bunu aşmak için istekler arasına bekleme süresi ekle, User-Agent header’ını değiştir veya proxy kullan. LangChain’in requests_per_second parametresi bu işi kolaylaştırıyor.
JavaScript ile render edilen içerik: Eğer WebBaseLoader ile boş veya eksik içerik geliyorsa, büyük ihtimalle JavaScript sonradan DOM’u değiştiriyor. Bu durumda PlaywrightURLLoader ya da SeleniumURLLoader‘a geç.
Çok büyük chunk boyutları: LLM modellerin token limitleri var. GPT-4o-mini için maksimum context window 128K token olsa da, her sorgu için çok fazla token kullanmak maliyeti artırır. Chunk boyutunu 500-1500 karakter arasında tutmak genellikle iyi bir denge.
Encoding sorunları: Türkçe içeriklerle çalışırken encoding sorunları çıkabilir. Script başına # -- coding: utf-8 -- ekle ve loader’a encoding="utf-8" parametresini geç.
Yinelenen içerik: Aynı metin farklı URL’lerde bulunabilir. Dökümanları kaydetmeden önce içerik hash’i ile tekilleştirme yapman önemli. Şöyle bir kontrol ekleyebilirsin:
import hashlib
def deduplicate_documents(documents):
"""İçerik hash'ine göre tekilleştir"""
seen_hashes = set()
unique_docs = []
for doc in documents:
content_hash = hashlib.md5(
doc.page_content.encode('utf-8')
).hexdigest()
if content_hash not in seen_hashes:
seen_hashes.add(content_hash)
doc.metadata["content_hash"] = content_hash
unique_docs.append(doc)
removed = len(documents) - len(unique_docs)
print(f"Tekilleştirme: {removed} yinelenen döküman kaldırıldı")
return unique_docs
unique_documents = deduplicate_documents(enriched_documents)
Güvenlik ve Etik Konular
Web scraping yaparken dikkat etmen gereken bazı önemli noktalar var:
- robots.txt kontrolü: Bir siteyi scrape etmeden önce
robots.txtdosyasını kontrol et. İzin verilmeyen alanları scrape etme. - Kullanım şartları: Bazı siteler ToS’larında scraping’i yasaklıyor. Hukuki sorun yaşamamak için dikkatli ol.
- Rate limiting: Sunucuyu aşırı yükleme. Saniyede 1-2 istek genellikle makul bir limit.
- Veri gizliliği: Çektiğin verilerde kişisel bilgi varsa KVKK ve GDPR gerekliliklerini göz ardı etme.
- API alternatifleri: Eğer bir sitenin API’si varsa, scraping yerine API kullan. Hem daha kararlı hem de daha etik.
Sonuç
LangChain’in web yükleme araçları, ham web içeriğini işlenebilir ve sorgulanabilir bilgi tabanlarına dönüştürmek için güçlü bir ekosistem sunuyor. WebBaseLoader ile başla, dinamik içerik ihtiyacı varsa Playwright’a geç, büyük siteler için SitemapLoader’ı kullan. Metadata zenginleştirme ve içerik temizleme adımlarını asla atlama, çünkü garbage in garbage out kuralı LLM sistemlerinde de geçerli.
Kurduğun bu altyapı sayesinde şirkette dökümanlarla boğuşan herkes için gerçek anlamda faydalı bir araç ortaya çıkabilir. Periyodik güncelleme mekanizmasını da eklediğinde sistemi neredeyse self-servis bir hale getirmiş olursun. Bir sonraki adım olarak LangSmith ile bu pipeline’ın performansını izlemeyi ve sorgu kalitesini ölçmeyi öneririm. Başarılar!
