LangChain ile Kendi Belgelerine Soru Sorma Uygulaması

Şirket içi dokümantasyonu okumak yerine ona soru sorabilseydiniz nasıl olurdu? “Onboarding rehberinin 47. sayfasında ne yazıyor?” diye saatlerce uğraşmak yerine direkt “Yeni çalışan için VPN kurulumu nasıl yapılır?” diye sorabilmek. İşte LangChain’in RAG (Retrieval-Augmented Generation) mimarisi tam olarak bunu yapıyor. Bu yazıda kendi belgelerinizi bir yapay zeka ile konuşturabilmek için gereken her şeyi adım adım anlatacağım.

Temel Kavramları Anlamak

Koda girmeden önce ne yaptığımızı anlaşılır bir şekilde ortaya koymak gerekiyor. RAG sistemi üç ana adımdan oluşuyor:

  • Belge yükleme: PDF, Word, Markdown gibi dosyaları okumak
  • Parçalama ve vektörleştirme: Metni anlamlı parçalara bölüp matematiksel vektörlere dönüştürmek
  • Sorgulama: Kullanıcı sorusuna en yakın parçaları bulup LLM’e göndermek

LangChain bu sürecin tamamını modüler bir şekilde yönetmenizi sağlıyor. OpenAI, Anthropic ya da yerel modeller (Ollama üzerinden) kullanabiliyorsunuz. Ben bu yazıda hem OpenAI hem de yerel model seçeneğini göstereceğim, çünkü gerçek dünyada her şirkette farklı ihtiyaçlar var.

Ortam Hazırlığı

Python 3.10 veya üzeri bir ortamda çalışıyorsunuz diye varsayıyorum. Önce virtual environment oluşturalım ve gerekli paketleri kuralım:

python3 -m venv rag-env
source rag-env/bin/activate  # Windows: rag-envScriptsactivate

pip install langchain langchain-community langchain-openai
pip install chromadb sentence-transformers
pip install pypdf python-docx unstructured
pip install tiktoken openai
pip install fastapi uvicorn  # API katmanı için

Eğer OpenAI yerine yerel model kullanmak istiyorsanız Ollama kurmanız gerekiyor:

# Ollama kurulumu (Ubuntu/Debian)
curl -fsSL https://ollama.ai/install.sh | sh

# Llama3 modelini çek
ollama pull llama3

# Embedding için özel model
ollama pull nomic-embed-text

# Servisin çalıştığını kontrol et
curl http://localhost:11434/api/tags

Kurulum tamamsa artık .env dosyamızı oluşturalım. API anahtarlarını kod içine yazmak kötü alışkanlık, bunu en baştan doğru yapalım:

cat > .env << 'EOF'
OPENAI_API_KEY=sk-proj-xxxxxxxxxxxxx
OPENAI_MODEL=gpt-4o-mini
EMBEDDING_MODEL=text-embedding-3-small
CHROMA_PERSIST_DIR=./chroma_db
DOCS_DIR=./documents
CHUNK_SIZE=1000
CHUNK_OVERLAP=200
EOF

Belge Yükleme Modülü

Gerçek dünyada belgeler farklı formatlarda olur. Şirkette PDF politika dökümanları, Markdown wiki sayfaları, Word sözleşmeleri, hatta plain text log özetleri olabilir. Hepsini handle eden bir loader yazalım:

# document_loader.py
import os
from pathlib import Path
from typing import List
from langchain.schema import Document
from langchain_community.document_loaders import (
    PyPDFLoader,
    Docx2txtLoader,
    TextLoader,
    UnstructuredMarkdownLoader,
    DirectoryLoader
)
from langchain.text_splitter import RecursiveCharacterTextSplitter
from dotenv import load_dotenv

load_dotenv()

CHUNK_SIZE = int(os.getenv("CHUNK_SIZE", 1000))
CHUNK_OVERLAP = int(os.getenv("CHUNK_OVERLAP", 200))

def load_single_document(file_path: str) -> List[Document]:
    """Tek bir belgeyi uzantısına göre yükle"""
    ext = Path(file_path).suffix.lower()
    
    loaders = {
        ".pdf": PyPDFLoader,
        ".docx": Docx2txtLoader,
        ".doc": Docx2txtLoader,
        ".txt": TextLoader,
        ".md": UnstructuredMarkdownLoader,
    }
    
    loader_class = loaders.get(ext)
    if not loader_class:
        print(f"[UYARI] Desteklenmeyen format: {ext} -> {file_path}")
        return []
    
    try:
        loader = loader_class(file_path)
        docs = loader.load()
        # Metadata'ya kaynak bilgisi ekle
        for doc in docs:
            doc.metadata["source_file"] = Path(file_path).name
            doc.metadata["file_type"] = ext
        print(f"[OK] Yüklendi: {file_path} ({len(docs)} sayfa/bölüm)")
        return docs
    except Exception as e:
        print(f"[HATA] {file_path} yüklenemedi: {e}")
        return []

def load_documents_from_directory(docs_dir: str) -> List[Document]:
    """Dizindeki tüm desteklenen belgeleri yükle"""
    docs_path = Path(docs_dir)
    if not docs_path.exists():
        docs_path.mkdir(parents=True)
        print(f"[INFO] Dizin oluşturuldu: {docs_dir}")
        return []
    
    all_documents = []
    supported_extensions = [".pdf", ".docx", ".doc", ".txt", ".md"]
    
    for ext in supported_extensions:
        files = list(docs_path.rglob(f"*{ext}"))
        for file_path in files:
            docs = load_single_document(str(file_path))
            all_documents.extend(docs)
    
    print(f"n[TOPLAM] {len(all_documents)} döküman parçası yüklendi")
    return all_documents

def split_documents(documents: List[Document]) -> List[Document]:
    """Belgeleri anlamlı parçalara böl"""
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=CHUNK_SIZE,
        chunk_overlap=CHUNK_OVERLAP,
        separators=["nn", "n", ".", "!", "?", " ", ""],
        length_function=len
    )
    
    chunks = splitter.split_documents(documents)
    print(f"[SPLIT] {len(documents)} döküman -> {len(chunks)} parçaya bölündü")
    return chunks

Burada chunk_overlap değeri kritik. 200 karakter overlap sayesinde bir sorunun cevabı iki chunk’ın sınırında olsa bile yakalanabiliyor.

Vektör Veritabanı Kurulumu

ChromaDB’yi kalıcı modda kullanacağız. Böylece her uygulama başlatılışında belgeleri tekrar işlemenize gerek kalmıyor. Binlerce sayfalık PDF’i her seferinde embedding’lemek hem zaman hem para kaybı:

# vector_store.py
import os
from typing import List, Optional
from langchain.schema import Document
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings
from langchain_community.embeddings import OllamaEmbeddings
from dotenv import load_dotenv

load_dotenv()

CHROMA_PERSIST_DIR = os.getenv("CHROMA_PERSIST_DIR", "./chroma_db")
USE_LOCAL_MODEL = os.getenv("USE_LOCAL_MODEL", "false").lower() == "true"

def get_embeddings():
    """Embedding modelini yapılandırmaya göre seç"""
    if USE_LOCAL_MODEL:
        return OllamaEmbeddings(
            model="nomic-embed-text",
            base_url="http://localhost:11434"
        )
    else:
        return OpenAIEmbeddings(
            model=os.getenv("EMBEDDING_MODEL", "text-embedding-3-small"),
            openai_api_key=os.getenv("OPENAI_API_KEY")
        )

def create_or_load_vectorstore(chunks: Optional[List[Document]] = None) -> Chroma:
    """
    Vektör veritabanını yükle veya yoksa oluştur.
    chunks parametresi verilirse yeni belgeler eklenir.
    """
    embeddings = get_embeddings()
    
    # Mevcut DB varsa yükle
    if os.path.exists(CHROMA_PERSIST_DIR) and chunks is None:
        print(f"[DB] Mevcut veritabanı yükleniyor: {CHROMA_PERSIST_DIR}")
        vectorstore = Chroma(
            persist_directory=CHROMA_PERSIST_DIR,
            embedding_function=embeddings,
            collection_name="documents"
        )
        count = vectorstore._collection.count()
        print(f"[DB] {count} vektör yüklendi")
        return vectorstore
    
    # Yeni oluştur veya güncelle
    if chunks:
        print(f"[DB] {len(chunks)} chunk vektörleştiriliyor...")
        vectorstore = Chroma.from_documents(
            documents=chunks,
            embedding=embeddings,
            persist_directory=CHROMA_PERSIST_DIR,
            collection_name="documents"
        )
        print(f"[DB] Vektör veritabanı kaydedildi: {CHROMA_PERSIST_DIR}")
        return vectorstore
    
    raise ValueError("Veritabanı bulunamadı. Önce belge indekslemesi yapın.")

def add_documents_to_existing(chunks: List[Document]) -> None:
    """Mevcut veritabanına yeni belgeler ekle (incremental update)"""
    embeddings = get_embeddings()
    vectorstore = Chroma(
        persist_directory=CHROMA_PERSIST_DIR,
        embedding_function=embeddings,
        collection_name="documents"
    )
    vectorstore.add_documents(chunks)
    print(f"[DB] {len(chunks)} yeni chunk eklendi")

Soru-Cevap Zinciri

Şimdi asıl iş burada. LangChain’in RetrievalQA zinciri yerine daha kontrol edilebilir bir yapı kuracağız. Bu sayede “kaynak hangi belgeydi?” sorusuna da cevap verebileceksiniz:

# qa_chain.py
import os
from typing import Dict, Any
from langchain_openai import ChatOpenAI
from langchain_community.llms import Ollama
from langchain.chains import ConversationalRetrievalChain
from langchain.memory import ConversationBufferWindowMemory
from langchain.prompts import PromptTemplate
from langchain_community.vectorstores import Chroma
from dotenv import load_dotenv

load_dotenv()

USE_LOCAL_MODEL = os.getenv("USE_LOCAL_MODEL", "false").lower() == "true"

SYSTEM_PROMPT = """Sen şirket belgelerini analiz eden bir asistansın. 
Sana verilen bağlam bilgisini kullanarak soruları Türkçe olarak yanıtla.

Önemli kurallar:
- Sadece verilen belgelerden elde ettiğin bilgileri kullan
- Eğer cevabı bilmiyorsan "Bu konuda belgelerimde yeterli bilgi bulamadım" de
- Cevabında hangi belgeden faydalandığını belirt
- Teknik terimleri açık ve anlaşılır şekilde açıkla

Bağlam:
{context}

Sohbet geçmişi:
{chat_history}

Soru: {question}

Cevap:"""

def get_llm():
    """LLM modelini yapılandırmaya göre seç"""
    if USE_LOCAL_MODEL:
        return Ollama(
            model=os.getenv("LOCAL_MODEL", "llama3"),
            base_url="http://localhost:11434",
            temperature=0.1
        )
    else:
        return ChatOpenAI(
            model=os.getenv("OPENAI_MODEL", "gpt-4o-mini"),
            temperature=0.1,
            max_tokens=2000,
            openai_api_key=os.getenv("OPENAI_API_KEY")
        )

def create_qa_chain(vectorstore: Chroma) -> ConversationalRetrievalChain:
    """Sohbet geçmişini destekleyen QA zinciri oluştur"""
    llm = get_llm()
    
    # Son 5 mesajı hafızada tut
    memory = ConversationBufferWindowMemory(
        k=5,
        memory_key="chat_history",
        return_messages=True,
        output_key="answer"
    )
    
    retriever = vectorstore.as_retriever(
        search_type="mmr",  # Maximum Marginal Relevance - tekrarlı sonuçları engeller
        search_kwargs={
            "k": 5,           # Top 5 sonuç getir
            "fetch_k": 20,    # MMR için 20 aday değerlendir
            "lambda_mult": 0.7  # Çeşitlilik/alaka dengesi
        }
    )
    
    qa_prompt = PromptTemplate(
        template=SYSTEM_PROMPT,
        input_variables=["context", "chat_history", "question"]
    )
    
    chain = ConversationalRetrievalChain.from_llm(
        llm=llm,
        retriever=retriever,
        memory=memory,
        return_source_documents=True,
        combine_docs_chain_kwargs={"prompt": qa_prompt},
        verbose=False
    )
    
    return chain

def ask_question(chain: ConversationalRetrievalChain, question: str) -> Dict[str, Any]:
    """Soru sor ve sonucu formatla"""
    result = chain.invoke({"question": question})
    
    # Kaynak belgeleri unique olarak topla
    sources = []
    seen = set()
    for doc in result.get("source_documents", []):
        source = doc.metadata.get("source_file", "Bilinmeyen")
        if source not in seen:
            seen.add(source)
            sources.append({
                "file": source,
                "snippet": doc.page_content[:200] + "..."
            })
    
    return {
        "answer": result["answer"],
        "sources": sources,
        "source_count": len(sources)
    }

İndeksleme ve Ana Uygulama

Tüm parçaları bir araya getirelim. Komut satırından çalışan bir indexer ve interaktif chat arayüzü yapalım:

# main.py
import sys
import os
from document_loader import load_documents_from_directory, split_documents
from vector_store import create_or_load_vectorstore, add_documents_to_existing
from qa_chain import create_qa_chain, ask_question
from dotenv import load_dotenv

load_dotenv()

DOCS_DIR = os.getenv("DOCS_DIR", "./documents")

def index_documents(force_rebuild: bool = False):
    """Belgeleri yükle ve vektörleştir"""
    print("=== Belge İndeksleme Başlıyor ===")
    
    chroma_dir = os.getenv("CHROMA_PERSIST_DIR", "./chroma_db")
    
    if force_rebuild and os.path.exists(chroma_dir):
        import shutil
        shutil.rmtree(chroma_dir)
        print("[INFO] Mevcut vektör veritabanı silindi")
    
    documents = load_documents_from_directory(DOCS_DIR)
    
    if not documents:
        print("[HATA] Hiç belge bulunamadı. documents/ dizinini kontrol edin.")
        return False
    
    chunks = split_documents(documents)
    create_or_load_vectorstore(chunks)
    
    print("n[HAZIR] İndeksleme tamamlandı!")
    return True

def interactive_chat():
    """Interaktif sohbet modu"""
    print("=== Belge Asistanı Başlatılıyor ===")
    
    try:
        vectorstore = create_or_load_vectorstore()
    except ValueError as e:
        print(f"[HATA] {e}")
        print("Önce 'python main.py index' komutunu çalıştırın.")
        return
    
    chain = create_qa_chain(vectorstore)
    
    print("nHazır! Sorularınızı yazın (çıkmak için 'quit' yazın)n")
    print("-" * 50)
    
    while True:
        question = input("nSoru: ").strip()
        
        if question.lower() in ["quit", "exit", "q", "çıkış"]:
            print("Görüşmek üzere!")
            break
        
        if not question:
            continue
        
        print("nAranıyor...")
        result = ask_question(chain, question)
        
        print(f"nCevap: {result['answer']}")
        
        if result['sources']:
            print(f"nKaynaklar ({result['source_count']} belge):")
            for i, src in enumerate(result['sources'], 1):
                print(f"  {i}. {src['file']}")

if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("Kullanım:")
        print("  python main.py index          - Belgeleri indeksle")
        print("  python main.py index --rebuild - Veritabanını sıfırdan oluştur")
        print("  python main.py chat           - Sohbet modunu başlat")
        sys.exit(1)
    
    command = sys.argv[1]
    
    if command == "index":
        force = "--rebuild" in sys.argv
        index_documents(force_rebuild=force)
    elif command == "chat":
        interactive_chat()
    else:
        print(f"Bilinmeyen komut: {command}")

FastAPI ile REST API Katmanı

Terminalde çalışmak güzel ama ekibinizle paylaşmak için bir API gerekiyor. Basit ama production-ready bir FastAPI servisi:

# api.py
from fastapi import FastAPI, HTTPException, BackgroundTasks
from pydantic import BaseModel
from typing import List, Optional
import os
from vector_store import create_or_load_vectorstore, add_documents_to_existing
from qa_chain import create_qa_chain, ask_question
from document_loader import load_single_document, split_documents
from dotenv import load_dotenv

load_dotenv()

app = FastAPI(
    title="Belge Asistanı API",
    description="Şirket belgelerinizi sorgulayan AI asistan",
    version="1.0.0"
)

# Global chain nesnesi (startup'ta oluştur)
qa_chain = None

class QuestionRequest(BaseModel):
    question: str
    session_id: Optional[str] = "default"

class QuestionResponse(BaseModel):
    answer: str
    sources: List[dict]
    source_count: int

class IndexRequest(BaseModel):
    file_path: str

@app.on_event("startup")
async def startup_event():
    global qa_chain
    chroma_dir = os.getenv("CHROMA_PERSIST_DIR", "./chroma_db")
    if os.path.exists(chroma_dir):
        vectorstore = create_or_load_vectorstore()
        qa_chain = create_qa_chain(vectorstore)
        print("[API] Servis hazır, vektör veritabanı yüklendi")
    else:
        print("[API] Vektör veritabanı bulunamadı. /index endpoint'i kullanın.")

@app.get("/health")
async def health_check():
    return {
        "status": "ok",
        "chain_ready": qa_chain is not None,
        "model": os.getenv("OPENAI_MODEL", "local")
    }

@app.post("/ask", response_model=QuestionResponse)
async def ask(request: QuestionRequest):
    if qa_chain is None:
        raise HTTPException(
            status_code=503,
            detail="Vektör veritabanı henüz yüklenmedi. Önce belge indeksleyin."
        )
    
    if not request.question.strip():
        raise HTTPException(status_code=400, detail="Soru boş olamaz")
    
    result = ask_question(qa_chain, request.question)
    return QuestionResponse(**result)

@app.post("/index")
async def index_file(request: IndexRequest, background_tasks: BackgroundTasks):
    """Tek bir dosyayı arka planda indeksle"""
    if not os.path.exists(request.file_path):
        raise HTTPException(status_code=404, detail="Dosya bulunamadı")
    
    def do_index():
        docs = load_single_document(request.file_path)
        if docs:
            chunks = split_documents(docs)
            add_documents_to_existing(chunks)
            print(f"[ARKAPLAN] İndekslendi: {request.file_path}")
    
    background_tasks.add_task(do_index)
    return {"message": f"İndeksleme başlatıldı: {request.file_path}"}

# Servisi başlatmak için:
# uvicorn api:app --host 0.0.0.0 --port 8000 --reload

Gerçek Dünya Senaryosu: IT Dokümantasyonu

Bu sistemi gerçekten çalıştırmak için şöyle bir test akışı izleyebilirsiniz:

# Örnek belgeler için dizin oluştur
mkdir -p documents

# Test için örnek bir markdown dosyası oluştur
cat > documents/vpn-setup.md << 'EOF'
# VPN Kurulum Kılavuzu

## Windows için Kurulum
1. IT portalından VPN istemcisini indirin
2. Kurulum sihirbazını yönetici olarak çalıştırın
3. Sunucu adresi: vpn.sirket.com
4. Kullanıcı adınız: kurumsal e-posta adresiniz

## Linux için Kurulum
sudo apt install openvpn
sudo openvpn --config /etc/openvpn/sirket.conf

## Sorun Giderme
Bağlantı kurulamazsa IT'ye ticket açın: [email protected]
EOF

# İndeksleme yap
python main.py index

# Sohbet başlat
python main.py chat

# Ya da API olarak çalıştır
uvicorn api:app --host 0.0.0.0 --port 8000

# API'yi test et
curl -X POST "http://localhost:8000/ask" 
  -H "Content-Type: application/json" 
  -d '{"question": "Linux için VPN nasıl kurulur?"}'

Performans ve Maliyet Optimizasyonu

Büyük ölçekte çalışırken dikkat etmeniz gereken noktalar:

  • Chunk size seçimi: Teknik belgeler için 1000-1500 karakter ideal. Hukuki metinler için 500-800 karakter daha iyi sonuç veriyor çünkü o belgeler çok yoğun bilgi içeriyor
  • Embedding maliyeti: text-embedding-3-small modeli 1 milyon token için 0.02 dolar. 1000 sayfalık bir PDF yaklaşık 500k token ediyor, yani 1 dolara indekslenmiş oluyor
  • Incremental update: Her belge değiştiğinde tüm veritabanını yeniden oluşturmak yerine add_documents_to_existing() fonksiyonunu kullanın
  • MMR kullanımı: search_type="mmr" olmadan beş benzer chunk getirebilir ve LLM’e tekrarlı bağlam verebilirsiniz, bu hem kaliteyi düşürür hem token israf eder
  • Yerel model tercih: Gizli belgeler için Ollama + nomic-embed-text kombinasyonu kullanın. Hiçbir veri dışarı çıkmıyor
  • Cache katmanı: Aynı soruyu sürekli soran kullanıcılar için Redis cache ekleyebilirsiniz, hem maliyet düşer hem hız artar

Sonuç

LangChain ile RAG sistemi kurmak başta karmaşık görünüyor ama parçalara ayırdığınızda oldukça mantıklı bir yapı çıkıyor. Belge yükleme, parçalama, vektörleştirme ve sorgulama adımlarının her biri bağımsız olarak geliştirilebilir ve test edilebilir.

Burada yazdığım kod production’a taşınabilir ama birkaç ek şey yapmanızı öneririm: authentication katmanı ekleyin (JWT tabanlı), rate limiting uygulayın, chunk’ların kalitesini düzenli olarak test edin ve kullanıcı geri bildirimlerini takip edin. “Bu cevap faydalı mıydı?” gibi basit bir feedback mekanizması bile sistemin zayıf noktalarını ortaya çıkarmak için çok değerli.

En önemli şey ise şu: chunk stratejisi her proje için farklı olacak. IT belgelerinde işe yarayan boyut, hukuk sözleşmelerinde berbat sonuç verebilir. Birkaç saat test etmek, ilerleyen süreçteki hayal kırıklıklarını önlüyor. Başlamak için küçük bir belge setiyle denemeler yapın, sonuçları gözlemleyin ve iteratif olarak geliştirin.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir