Dalam artikel ini

Tutorial: Membangun Hybrid Retrieval dengan Embedding dan RDFLib

6 menit bacaDiperbarui
  • Retrieval
  • Knowledge Graph
  • RAG
  • Python
  • RDFLib

Misalkan asisten rapat menerima pertanyaan ini:

Mengapa tim memilih metode A daripada metode B?

Keputusan dan alasannya bisa muncul di bagian transkrip yang berbeda. Kita akan menggunakan embedding untuk menemukan potongan yang relevan, lalu menelusuri hubungannya dalam knowledge graph untuk mengumpulkan bukti terkait.

Ini versi kecil dari pola retrieval dalam project meeting knowledge saya: hasil pencarian transkrip → node graph yang terhubung ke bukti → konteks terkait. Di sini, “hybrid” berarti pencarian vektor ditambah penelusuran graph. Kita tidak memerlukan database vektor, framework LLM, atau aplikasi lengkapnya.

1. Siapkan Python dan Ollama

Gunakan Python 3.12 dan instal dua dependensi Python berikut:

python -m pip install httpx rdflib
ollama pull qwen3-embedding:0.6b

Instal dan jalankan Ollama terlebih dahulu. Server lokalnya seharusnya tersedia di http://localhost:11434. Jika belum berjalan, jalankan ollama serve di terminal lain.

Model embedding Qwen3 mengubah teks menjadi vektor. RDFLib menyimpan graph dalam memori. Jalankan blok Python berikut secara berurutan, baik dalam notebook maupun digabungkan dalam satu skrip.

2. Beri setiap potongan teks ID yang tetap

Kita akan menggunakan empat potongan transkrip fiktif:

passages = {
    "D1": "We chose method A for meeting search.",
    "D2": "Method A preserves links back to the original transcript evidence.",
    "D3": "We rejected method B because it loses source references when merging chunks.",
    "D4": "The next team lunch is on Friday.",
}

ID ini menghubungkan teks yang dapat dicari dengan representasinya dalam graph. Pada transkrip asli, simpan juga ID rapat, pembicara, timestamp, dan teks asli. Batasi retrieval pada rapat yang memang boleh diakses pengguna.

3. Cari menggunakan embedding

Kita akan memanggil endpoint /api/embed Ollama, menormalisasi vektor yang dikembalikan, lalu mengurutkan potongan teks berdasarkan dot product. Untuk vektor dengan panjang satu, hasil ini sama dengan cosine similarity.

import math
import httpx

def embed(texts):
    response = httpx.post(
        "http://localhost:11434/api/embed",
        json={
            "model": "qwen3-embedding:0.6b",
            "input": texts,
            "truncate": False,
        },
        timeout=120,
    )
    response.raise_for_status()
    vectors = response.json()["embeddings"]
    if len(vectors) != len(texts):
        raise ValueError("Expected one embedding per input")
    normalized = []
    for vector in vectors:
        length = math.sqrt(sum(value * value for value in vector))
        if not length:
            raise ValueError("Received an empty or zero embedding")
        normalized.append([value / length for value in vector])
    return normalized

question = "Why did the team choose method A over method B?"
query = (
    "Instruct: Retrieve meeting passages explaining a decision and its reasons.\n"
    f"Query: {question}"
)
passage_ids = list(passages)
document_vectors = embed([passages[pid] for pid in passage_ids])
query_vector = embed([query])[0]

scores = {}
for pid, vector in zip(passage_ids, document_vectors, strict=True):
    scores[pid] = sum(
        a * b for a, b in zip(query_vector, vector, strict=True)
    )

hit_ids = sorted(passage_ids, key=lambda pid: (-scores[pid], pid))[:2]
for pid in hit_ids:
    print(pid, round(scores[pid], 3), passages[pid])

Contoh retrieval Qwen menambahkan instruksi pada query dan membuat embedding dokumen tanpa awalan tersebut. Gunakan model embedding yang sama untuk keduanya.

Urutan hasil bergantung pada model. Periksa hasilnya, jangan menganggap potongan tertentu pasti menempati posisi pertama. Pencarian dua hasil teratas mungkin menemukan keputusan atau alasan, tetapi tidak secara eksplisit memastikan seluruh rangkaian bukti yang terhubung ikut ditemukan.

4. Hubungkan bukti dalam RDFLib

Sekarang buat graph kecil. Kita mendefinisikan relasinya secara manual agar tutorial ini tetap fokus pada retrieval. Ekstraksi dan validasi relasi secara otomatis adalah tugas tersendiri.

from rdflib import Graph, Literal, Namespace
from rdflib.namespace import RDFS

EX = Namespace("https://example.org/meeting/")
graph = Graph()
graph.bind("ex", EX)

nodes = [
    (EX.decision, "Choose method A", "D1"),
    (EX.reason, "Preserve transcript evidence links", "D2"),
    (EX.alternative, "Reject method B: source references are lost", "D3"),
]
for node, label, pid in nodes:
    graph.add((node, RDFS.label, Literal(label)))
    graph.add((node, EX.evidence, Literal(pid)))

graph.add((EX.reason, EX.motivates, EX.decision))
graph.add((EX.decision, EX.rejects, EX.alternative))

Graph.add() milik RDFLib menerima triple subjek–predikat–objek. Properti evidence mencatat potongan teks yang mendukung setiap node. Predikat lain menyatakan hubungan antar-node.

Label merangkum potongan teks; sumbernya tetap potongan teks asli. Relasi yang tersimpan tetap perlu diperiksa terhadap buktinya, terutama jika diekstrak oleh model.

5. Jadikan hasil pencarian vektor sebagai seed graph

Temukan node graph yang ID buktinya cocok dengan hasil pencarian vektor:

seeds = {
    node
    for pid in hit_ids
    for node in graph.subjects(EX.evidence, Literal(pid))
}

Selanjutnya, perluas hanya melalui jenis relasi yang diizinkan. Penelusuran berikut mengikuti edge masuk maupun keluar, dengan batas kedalaman dan jumlah node:

from collections import deque

def expand(seeds, max_depth=2, max_nodes=6):
    allowed = {EX.motivates, EX.rejects}
    visited = set(sorted(seeds, key=str)[:max_nodes])
    queue = deque((node, 0) for node in sorted(visited, key=str))

    while queue:
        node, depth = queue.popleft()
        if depth >= max_depth:
            continue
        neighbors = list(graph.predicate_objects(node))
        neighbors += [(p, s) for s, p in graph.subject_predicates(node)]
        for predicate, neighbor in sorted(neighbors, key=lambda pair: tuple(map(str, pair))):
            if predicate not in allowed or neighbor in visited:
                continue
            if len(visited) >= max_nodes:
                continue
            visited.add(neighbor)
            queue.append((neighbor, depth + 1))
    return visited

related_nodes = expand(seeds)

Menelusuri edge masuk memungkinkan kita mencapai alasan dari keputusan. Ini pilihan arah penelusuran, bukan pembalikan makna relasi: alasan memotivasi keputusan, bukan sebaliknya.

Jika tidak ada node graph yang cocok, seeds tetap kosong. Kita masih bisa mengembalikan hasil pencarian vektor tanpa mengarang hubungan. Batas kedalaman dan jumlah node membatasi perluasan, tetapi tidak menjamin konteks yang ditemukan relevan.

6. Susun paket konteks dengan tautan sumber

Kumpulkan bukti dari node yang dikunjungi, dengan hasil pencarian vektor tetap di urutan awal. Sertakan juga relasi graph dalam arah aslinya:

graph_ids = {
    str(pid)
    for node in related_nodes
    for pid in graph.objects(node, EX.evidence)
    if str(pid) in passages
}
context_ids = list(dict.fromkeys(hit_ids + sorted(graph_ids)))

context = "\n".join(f"[{pid}] {passages[pid]}" for pid in context_ids)
print(context)

for subject, predicate, obj in sorted(graph, key=lambda triple: tuple(map(str, triple))):
    if predicate in {EX.motivates, EX.rejects} and subject in related_nodes and obj in related_nodes:
        print(graph.value(subject, RDFS.label), predicate.split("/")[-1], graph.value(obj, RDFS.label))

Jika seed mencakup salah satu dari tiga node yang terhubung, perluasan dua hop dapat mengumpulkan D1, D2, dan D3. D4 tidak memiliki hubungan dalam graph, meskipun tetap ada dalam paket jika dipilih pencarian vektor. Dengan begitu, kita bisa membedakan bukti yang ditemukan langsung dan bukti tambahan dari graph.

Paket ini sekarang dapat diberikan ke model pembuat jawaban. Contoh jawaban yang didukung dataset fiktif ini adalah:

Tim memilih metode A [D1] karena mempertahankan tautan ke bukti transkrip [D2]. Metode B ditolak karena kehilangan referensi sumber ketika menggabungkan chunk [D3].

Jawaban tersebut merupakan contoh yang ditulis untuk penjelasan, bukan hasil generasi model. Kode berhenti pada retrieval dan penyusunan konteks. Meminta sitasi lewat prompt saja tidak membuktikan jawaban didukung sumber; periksa setiap klaim terhadap potongan teks yang dirujuk.

Langkah yang bisa dicoba berikutnya

Bandingkan hit_ids dengan context_ids untuk beberapa pertanyaan. Apakah perluasan graph menambahkan bukti yang hilang, atau sekadar lebih banyak teks? Coba juga pertanyaan yang tidak berkaitan: pencarian top-k selalu mengembalikan kandidat, sehingga asisten sungguhan memerlukan cara untuk mendeteksi bukti yang tidak cukup dan menahan diri dari menjawab.

Untuk korpus lebih besar, simpan embedding potongan teks dalam cache dan beralih dari pemindaian menyeluruh jika diperlukan. Pertahankan ID sumber sepanjang pipeline, validasi edge graph, dan evaluasi retrieval vektor saja serta hybrid dengan anggaran konteks akhir yang sama. Contoh kecil ini menunjukkan bagaimana komponennya terhubung; contoh ini tidak membuktikan bahwa perluasan graph memperbaiki setiap query.