Membangun Detektor Plagiarisme Cerdas untuk Bahasa Indonesia dengan Machine Learning

Plagiarisme adalah tantangan serius di dunia pendidikan dan profesional. Mengatasi fenomena “si A menyalin si B” memerlukan alat yang canggih dan cerdas. Artikel ini akan memandu Anda membangun sistem deteksi plagiarisme yang tangguh, mampu memahami nuansa Bahasa Indonesia, dan melakukan pemeriksaan secara komprehensif: dari keseluruhan teks, per paragraf, hingga per kalimat. Sistem ini juga dilengkapi dengan “bank teks” (menggunakan PostgreSQL dan Qdrant) yang secara otomatis akan memeriksa silang setiap dokumen baru yang Anda unggah terhadap semua dokumen yang telah ada.

Gambaran Umum Arsitektur Sistem

Solusi deteksi plagiarisme kami dibangun di atas tumpukan teknologi modern yang memastikan skalabilitas dan performa:

  • Frontend & API Gateway: Nuxt (Server Script)

    Berfungsi sebagai antarmuka pengguna dan titik masuk utama untuk permintaan. Nuxt akan menerima input teks dari pengguna dan meneruskannya ke layanan embedding/check di backend.
  • Layanan Embedding & Pencarian: Python + FastAPI + Sentence Transformers + Qdrant Client

    Ini adalah “otak” dari sistem. Dibangun dengan Python dan framework FastAPI, layanan ini bertanggung jawab untuk mengubah teks menjadi representasi numerik (embedding) menggunakan model sentence-transformers/paraphrase-multilingual-mpnet-base-v2 yang sangat baik untuk bahasa multibahasa termasuk Indonesia. qdrant-client digunakan untuk berinteraksi dengan database vektor.
  • Database Vektor: Qdrant

    Qdrant adalah database vektor berkinerja tinggi yang dirancang untuk menyimpan embedding teks. Ia mengoptimalkan pencarian kemiripan di antara vektor-vektor ini, memungkinkan deteksi plagiarisme yang cepat dan akurat berdasarkan kedekatan semantik.
  • Database Relasional: PostgreSQL

    PostgreSQL berfungsi untuk menyimpan metadata penting dari setiap dokumen, seperti doc_id unik, judul, tanggal pembuatan, dan teks asli. Ini memastikan integritas data dan kemampuan pelacakan dokumen.

Prasyarat (Alat & Versi)

Sebelum memulai, pastikan Anda memiliki alat dan lingkungan berikut terinstal:

  • Docker & Docker Compose: Untuk menjalankan PostgreSQL dan Qdrant dengan mudah.
  • Python 3.10+: Lingkungan runtime untuk layanan backend.
  • Node.js & Nuxt 3: Untuk frontend dan server API gateway.
  • (Opsional) GPU: Jika Anda berencana memproses volume dokumen yang besar dan membutuhkan inferensi yang cepat.

Langkah 1: Mengatur PostgreSQL & Qdrant dengan Docker

Kita akan menggunakan Docker Compose untuk menginstal dan menjalankan kedua database dengan cepat. Buat file docker-compose.yml sebagai berikut:

version: '3.8'
services:
  postgres:
    image: postgres:15
    environment:
      POSTGRES_USER: pguser
      POSTGRES_PASSWORD: pgpass
      POSTGRES_DB: plagiarism
    ports:
      - "5432:5432"
    volumes:
      - ./pgdata:/var/lib/postgresql/data

  qdrant:
    image: qdrant/qdrant:latest
    ports:
      - "6333:6333"
    volumes:
      - ./qdrant_storage:/qdrant/storage

Jalankan Docker Compose dari terminal:

docker-compose up -d

Langkah 2: Backend Embedding & Pencarian (Python + FastAPI)

Buat virtual environment dan instal dependensi yang diperlukan:

python -m venv venv
source venv/bin/activate
pip install -U pip
pip install fastapi uvicorn sentence-transformers qdrant-client sqlalchemy psycopg2-binary python-multipart numpy

Selanjutnya, buat file service/app.py dengan kode berikut. Ini akan menjadi inti layanan deteksi plagiarisme Anda:

# service/app.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from sentence_transformers import SentenceTransformer
import numpy as np
from qdrant_client import QdrantClient
from qdrant_client.http.models import VectorParams, Distance, PointStruct
from sqlalchemy import create_engine, Column, String, Text, DateTime
from sqlalchemy.orm import declarative_base, sessionmaker
from datetime import datetime
import re
import uuid

# CONFIG
QDRANT_COLLECTION = "plagiarism_vectors"
VECTOR_SIZE = 768
THRESHOLD = 0.8
QDRANT_URL = "http://localhost:6333"
DATABASE_URL = "postgresql+psycopg2://pguser:pgpass@localhost:5432/plagiarism"

# Init
app = FastAPI(title="Plagiarism Checker Service")
model = SentenceTransformer('')  # loads on startup
qdrant = QdrantClient(url=QDRANT_URL)
engine = create_engine(DATABASE_URL)
Base = declarative_base()
SessionLocal = sessionmaker(bind=engine)

# DB model
class Document(Base):
    __tablename__ = "documents"
    doc_id = Column(String, primary_key=True, index=True)
    title = Column(String)
    text = Column(Text)
    created_at = Column(DateTime, default=datetime.utcnow)

Base.metadata.create_all(bind=engine)

# Ensure Qdrant collection exists
try:
    qdrant.recreate_collection(
        collection_name=QDRANT_COLLECTION,
        vectors_config=VectorParams(size=VECTOR_SIZE, distance=Distance.COSINE)
    )
except Exception:
    # if exists, create may fail — try get or ignore
    qdrant.create_collection(collection_name=QDRANT_COLLECTION, vectors_config=VectorParams(size=VECTOR_SIZE, distance=Distance.COSINE))

# Helpers
def split_paragraphs(text: str):
    paras = [p.strip() for p in re.split(r'\n{2,}', text) if p.strip()]
    return paras if paras else [text.strip()]

def split_sentences(paragraph: str):
    # simple rule-based sentence split (works reasonably for Indo). Improve with spaCy if needed.
    sents = re.split(r'(?<=[\.\?\!])\s+', paragraph.strip())
    return [s.strip() for s in sents if s.strip()]

def cosine_sim(a: np.ndarray, b: np.ndarray):
    den = (np.linalg.norm(a) * np.linalg.norm(b))
    if den == 0: return 0.0
    return float(np.dot(a, b) / den)

def chunk_text_all(text: str):
    """Return list of chunks with type info: full, paragraphs, sentences"""
    chunks = []
    # full
    chunks.append({"type":"full","index":0,"text":text.strip()})
    paras = split_paragraphs(text)
    for i,p in enumerate(paras):
        chunks.append({"type":"paragraph","index:i,"text":p})
        sents = split_sentences(p)
        for j,s in enumerate(sents):
            chunks.append({"type":"sentence","index:f"{i}-{j}","text":s})
    return chunks

# Request model
class CheckRequest(BaseModel):
    doc_id: str
    title: str
    text: str

@app.post("/check")
def check_and_add(req: CheckRequest):
    doc_id = req.doc_id
    title = req.title
    text = req.text

    # 1) chunk
    chunks = chunk_text_all(text)
    texts = [c["text"] for c in chunks]

    # 2) embed
    embeddings = model.encode(texts, show_progress_bar=False)
    embeddings = np.array(embeddings)  # shape (n, 768)

    # 3) search each chunk in Qdrant (exclude same doc_id results to avoid self-match)
    results = {"full": [], "paragraphs": [], "sentences": []}
    for i, c in enumerate(chunks):
        vec = embeddings[i].tolist()
        # search top 5
        hits = qdrant.search(collection_name=QDRANT_COLLECTION, query_vector=vec, limit=5, with_payload=True, with_vectors=True)
        matches = []
        for h in hits:
            payload = h.payload or {}
            source_doc = payload.get("doc_id")
            # skip matches from same doc (because this doc may already be in DB)
            if source_doc == doc_id:
                continue
            # compute exact cosine using vectors returned
            if hasattr(h, "vector") and h.vector is not None:
                sim = cosine_sim(np.array(vec), np.array(h.vector))
            else:
                # fallback to score if vector not returned
                sim = float(getattr(h, "score", 0.0))
            matches.append({
                "score": sim,
                "source_doc_id": source_doc,
                "source_text": payload.get("text"),
                "source_type": payload.get("chunk_type"),
                "source_index": payload.get("chunk_index")
            })
        # sort matches desc
        matches = sorted(matches, key=lambda x: x["score"], reverse=True)
        entry = {
            "chunk_type": c["type"],
            "chunk_index": c["index"],
            "text": c["text"],
            "top_matches": matches[:5]
        }
        if c["type"] == "full":
            results["full"].append(entry)
        elif c["type"] == "paragraph":
            results["paragraphs"].append(entry)
        else:
            results["sentences"].append(entry)

    # 4) store doc metadata in Postgres (prevent duplicate by doc_id)
    db = SessionLocal()
    existing = db.query(Document).filter(Document.doc_id==doc_id).first()
    if not existing:
        newdoc = Document(doc_id=doc_id, title=title, text=text)
        db.add(newdoc)
        db.commit()
    db.close()

    # 5) upsert all chunks to Qdrant (id uses doc_id to prevent duplicates)
    points = []
    for i, c in enumerate(chunks):
        pid = f"{doc_id}__{c['']}__{c['']}"
        payload = {
            "doc_id": doc_id,
            "title": title,
            "chunk_type": c["type"],
            "chunk_index": c["index"],
            "text": c["text"]
        }
        points.append(PointStruct(id=pid, vector=embeddings[i].tolist(), payload=payload))
    qdrant.upsert(collection_name=QDRANT_COLLECTION, points=points)

    # 6) build report: find any sentences/paras > THRESHOLD
    flagged = {"sentences": [], "paragraphs": []}
    for s in results["sentences"]:
        if s["top_matches"] and s["top_matches"][0]["score"] >= THRESHOLD:
            flagged["sentences"].append({
                "text": s["text"],
                "best_match": s["top_matches"][0]
            })
    for p in results["paragraphs"]:
        if p["top_matches"] and p["top_matches"][0]["score"] >= THRESHOLD:
            flagged["paragraphs"].append({
                "text": p["text"],
                "best_match": p["top_matches"][0]
            })

    return {
        "status": "ok",
        "doc_id": doc_id,
        "scores": results,
        "flagged": flagged
    }

Beberapa catatan penting mengenai kode backend:

  • Endpoint /check akan menerima objek {doc_id, title, text}.
  • Sistem akan melakukan pemeriksaan kemiripan pada keseluruhan teks, setiap paragraf, dan setiap kalimat. Setelah itu, metadata dokumen disimpan di PostgreSQL, dan semua “chunk” teks (potongan teks) diunggah ke Qdrant.
  • Penting untuk dicatat bahwa proses pencarian di Qdrant secara otomatis mengecualikan dokumen yang sedang diperiksa (`self-match`) untuk menghindari hasil yang tidak relevan.
  • Nilai `THRESHOLD` default diatur ke 0.8, yang menandakan tingkat kemiripan yang kuat. Anda dapat menyesuaikan nilai ini sesuai kebutuhan.

Langkah 3: Nuxt (Frontend + Server Route)

Anda dapat membuat rute API server di Nuxt yang akan bertindak sebagai perantara, meneruskan permintaan dari frontend ke layanan FastAPI Anda. Ini adalah praktik terbaik untuk keamanan, dibandingkan memanggil FastAPI langsung dari sisi klien.

Contoh file server/api/check.post.ts untuk Nuxt 3 / Nitro:

// server/api/check.post.ts
import { defineEventHandler, readBody } from 'h3'

export default defineEventHandler(async (event) => {
  const body = await readBody(event)
  // ganti URL jika service beda host/port
  const FASTAPI_URL = process.env.FASTAPI_URL || "http://localhost:8000/check"
  const res = await $fetch(FASTAPI_URL, {
    method: "POST",
    body,
    headers: { "Content-Type": "application/json" }
  })
  return res
})
```

Berikut adalah contoh formulir frontend sederhana yang dapat Anda gunakan untuk berinteraksi dengan layanan ini:

<

div class="highlight js-code-highlight">

<

pre class="highlight vue"><template>
<form @submit.prevent="submit">
<input v-model="docId" placeholder="doc id (unik)"/>
<input v-model="title" placeholder="judul"/>
<textarea v-model="text" placeholder="tempelkan teks tugas"></textarea>
<button>Check</button>
</form>

<div v-if="report">
<h3>Flagged:</h3>
<div v-for="s in report.flagged.sentences" :key="s.text">
<b>Kalimat:</b> {{ s.text }}<i>match</i>: {{ s.best_match.source_doc_id }} ({{ s.best_match.score.toFixed(3) }})
<div>source text: {{ s.best_match.source_text }}</div>
</div>
<div v-for="p in report.flagged.paragraphs" :key="p.text">
<b>Paragraf:</b> {{ p.text }} — match: {{ p.best_match.source_doc_id }} ({{ p.best_match.score.toFixed(3) }})
</div>
</div>
</template>

<script setup>
import { ref } from '<span class="s1'>vue'
const docId = ref(task-</span><span class="p">${</span><span class="nb">Date</span><span class="p">.</span><span class="nf">now</span><span class="p">()}</span><span class="s2">)
const title = ref('')
const text = ref('')
const report = ref(null)

async function submit(){
report.value = null
const res = await $fetch(', {
method: ',
body: { doc_id: docId.value, title: title.value, text: text.value }
})
report.value = res
}
</script>

Langkah 4: Pengujian Cepat (Lokal)

  1. Jalankan FastAPI:

    uvicorn service.app:app --reload --port 8000
    
  2. Jalankan Nuxt, buka formulir, dan kirimkan dua dokumen yang serupa. Perhatikan bagian flagged pada respons untuk melihat potensi plagiarisme.
  3. Coba unggah 40 tugas berbeda, pastikan setiap tugas memiliki doc_id yang unik (misalnya, tugas-2025-09-25-001). Sistem akan secara otomatis menyimpan semua tugas ke "bank teks" dan memeriksa kemiripan di antara mereka.
  4. Jika Anda memiliki buku teks atau makalah referensi yang sering digunakan, Anda dapat memasukkannya ke dalam bank teks agar sistem dapat menggunakannya sebagai referensi.

Langkah 5: Tips Produksi / Tuning

  • Threshold: Nilai 0.8 adalah titik awal yang baik untuk deteksi kuat. Sesuaikan antara 0.7 hingga 0.75 untuk mendeteksi parafrase yang lebih halus.
  • Top_k: Menentukan jumlah hasil teratas yang dicari di Qdrant. Nilai 5-10 biasanya cukup untuk deteksi.
  • Batching: Untuk dokumen bervolume tinggi, proses embedding dalam batch (misalnya, 128 dokumen per batch) untuk meningkatkan performa.
  • GPU: Pindahkan model ke GPU (`device='cuda'`) jika Anda memiliki banyak dokumen dan ingin mempercepat inferensi.
  • Dedup (Dedikasi): Gunakan doc_id sebagai kunci utama untuk memastikan idempotensi pada endpoint /check dan mencegah duplikasi dokumen.
  • Eksklusi Self-Match: Pastikan filter payload.doc_id != doc_id selalu diterapkan saat melakukan pencarian di Qdrant untuk menghindari dokumen baru mencocokkan dirinya sendiri.
  • Tokenisasi Akurat: Pemisahan kalimat yang digunakan di atas adalah heuristik sederhana. Untuk akurasi tinggi, pertimbangkan untuk menggunakan tokenizer bahasa Indonesia yang lebih canggih (misalnya, model spaCy atau segmentasi berbasis transformer).
  • Privasi: Selalu perhatikan kebijakan privasi. Simpan teks hanya jika diizinkan oleh peraturan atau kebijakan institusi Anda.
  • Skalabilitas: Qdrant dapat dikonfigurasi dalam mode klaster untuk skalabilitas yang lebih besar. Gunakan sharding dan replika untuk menangani data bervolume besar.

Langkah 6: Checklist Deploy

  • [ ] Docker compose berjalan (Postgres + Qdrant)
  • [ ] FastAPI sudah di-deploy (dalam container atau VM) dan dapat diakses dari Nuxt
  • [ ] Nuxt server sudah di-deploy (variabel lingkungan FASTAPI_URL sudah diatur)
  • [ ] Backup PostgreSQL, snapshot Qdrant secara teratur
  • [ ] Monitoring: pantau latensi waktu embedding dan waktu pencarian Qdrant

Penutup (TL;DR)

  • Anda akan memiliki alur kerja: Nuxt → FastAPI (embedding + Qdrant) → PostgreSQL
  • Pemeriksa melakukan deteksi plagiarisme pada: keseluruhan teks, per paragraf, dan per kalimat.
  • Output mencakup: array skor dan item yang "ditandai" (kalimat/paragraf dengan skor > 0.8 akan menampilkan teks asli dan referensinya).
  • Setiap kali endpoint /check dipanggil, dokumen akan ditambahkan ke bank teks (unik dengan doc_id), memungkinkan semua tugas saling diperiksa secara otomatis.

Disclaimer

Tutorial ini dibuat oleh AI. Jika Anda menemukan kesalahan, anggaplah itu sebagai kesempatan untuk belajar dan memperbaikinya. Akan lebih baik lagi jika Anda dapat berkontribusi dengan tutorial yang lebih akurat.

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.
You need to agree with the terms to proceed