Breaking News
القائمة

كيفية بناء تطبيق RAG محلي بلغة Java باستخدام LangChain4j و Ollama

كيفية بناء تطبيق RAG محلي بلغة Java باستخدام LangChain4j و Ollama
100%

يُعد التوليد المعزز بالاسترجاع (RAG) طريقة للإجابة عن الأسئلة باستخدام النماذج اللغوية الكبيرة، حيث يبحث التطبيق أولاً في مستنداته الخاصة عن النصوص المتعلقة بالسؤال، ثم يرسل هذا النص إلى النموذج مع السؤال. وبذلك، يجيب النموذج بناءً على البيانات المقدمة بدلاً من الاعتماد على ما يتذكره من تدريبه. ويوضح تطبيق تجريبي مبني بلغة Java 17 هذه الآلية من خلال الإجابة عن أسئلة حول وصفات طعام خاصة، ويعمل بالكامل محلياً دون الحاجة إلى مفتاح واجهة برمجة التطبيقات (API). ويستخدم التطبيق نموذج تضمين صغيراً من مكتبة LangChain4j لتحويل نصوص الوصفات إلى أرقام قابلة للبحث، ونموذجاً صغيراً يعمل عبر أداة Ollama لكتابة الإجابة. وفي الاختبارات، خمّن النموذج وقت خبز كعكة الليمون بشكل خاطئ بدون استخدام تقنية RAG، ولكنه قرأ الوصفة أولاً وأجاب بشكل صحيح عند استخدامها.

Advertisement

لماذا لا تعرف النماذج اللغوية الكبيرة مستنداتك

لا تعرف النماذج اللغوية الكبيرة سوى النصوص العامة التي تدربت عليها. ولم تكن المستندات الخاصة، مثل ويكي الشركة أو ملف وصفات العائلة، جزءاً من هذا التدريب، مما يترك النموذج بلا مرجع عند سؤاله عنها. وبدلاً من التصريح بعدم معرفته، يقدم النموذج الإجابة التي تبدو أكثر منطقية.

على سبيل المثال، ينص ملف وصفات العائلة الخاص بكعكة Nani's Lemon Semolina Cake على خبز الكعكة عند درجة حرارة 170 مئوية لمدة 38 دقيقة. وعند توجيه سؤال حولها إلى نموذج محلي صغير، وهو نموذج qwen2.5:0.5b عبر أداة Ollama، دون إضافة أي نص آخر في الأوامر النصية، تكون النتيجة خاطئة:

Question: How long do I bake Nani's Lemon Semolina Cake, and at what temperature?

Answer: To bake Nani's Lemon Semolina Cake, you should bake it at 350°F (180°C) for 30 minutes.

جاءت الإجابة خاطئة في كلا الرقمين، ولا يبدو فيها أي تردد. وتعالج تقنية RAG هذه المشكلة دون إعادة تدريب النموذج، وذلك من خلال تزويده بالصفحة الصحيحة من المستندات مع كل سؤال.

كيف يعمل التوليد المعزز بالاسترجاع في ثلاث مراحل

يعمل تطبيق RAG عبر ثلاث مراحل. تُنفذ المرحلة الأولى مرة واحدة قبل وصول أي سؤال، بينما تُنفذ المرحلتان الأخريان مع كل سؤال.

  1. الفهرسة (Index): يُقسّم كل مستند إلى قطع صغيرة تُسمى الأجزاء (Chunks). ويُحوّل كل جزء إلى قائمة من الأرقام باستخدام نموذج تضمين، ثم يُحفظ الجزء وأرقامه في متجر المتجهات.
  2. الاسترجاع (Retrieve): عند وصول سؤال، يُحوّل إلى أرقام باستخدام نموذج التضمين ذاته. ثم يعيد متجر المتجهات الأجزاء القليلة التي تكون أرقامها الأقرب إلى أرقام السؤال.
  3. التوليد (Generate): تُوضع التعليمات والسؤال والأجزاء المسترجعة في أمر نصي واحد يُرسل إلى النموذج اللغوي الكبير، والذي يتولى كتابة الإجابة.

ويعكس الاسم هذه المراحل: تُسترجع النصوص، وتُعزز الأوامر النصية بها، ثم يُولد النموذج الإجابة. وتُبنى الفهرسة مرة واحدة، بينما يتطلب كل سؤال لاحق عملية بحث واحدة واستدعاءً واحداً للنموذج. وتشبه هذه الهيكلية امتحان الكتاب المفتوح، حيث يعثر مساعد على الصفحات المطابقة لطالب لم يقرأ الكتاب المدرسي من قبل.

مكونات مسار عمل RAG

الجزء (Chunk) هو قطعة من مستند صغيرة بما يكفي لتناول موضوع واحد، مثل فقرة واحدة من وصفة. ويُبحث في الأجزاء بدلاً من المستندات الكاملة لأن المستند الكامل سيملأ الأوامر النصية بنصوص لا يحتاجها السؤال. ويجب أن يكون الجزء مفهوماً عند قراءته بمفرده.

أما التضمينات الدلالية فهي مصفوفة من الأرقام (float[] بلغة Java) تمثل معنى النص، ويُنتجها نموذج التضمين. على سبيل المثال، إذا تحولت عبارة «اخبز الكعكة» إلى {0.9, 0.1} وعبارة «وقت الفرن للكعكة» إلى {0.8, 0.2}، فإن المصفوفتين تحملان أرقاماً متقاربة لأن النصين يحملان المعنى ذاته تقريباً. بينما قد تتحول عبارة «قدّم الحساء ساخناً» إلى {0.1, 0.9}، وهو ما يبدو مختلفاً تماماً. ويعمل نموذج all-MiniLM-L6-v2 بهذه الطريقة باستخدام 384 رقماً بدلاً من رقمين، ويقرأ 256 من الرموز كحد أقصى ويقتطع الباقي. ويجب تضمين السؤال والأجزاء باستخدام النموذج ذاته لتتطابق المتجهات.

ويحتفظ متجر المتجهات بكل جزء بجوار التضمين الخاص به، ويبحث عن المتجهات المخزنة الأقرب إلى متجه معين. وفي حين تستخدم أنظمة الإنتاج قاعدة بيانات PostgreSQL مع إضافة pgvector، فإن قائمة Java عادية تفي بالغرض لبضع مئات من الأجزاء.

ويقارن تشابه جيب التمام بين تضمينين ويعيد نتيجة تتراوح بين -1 و 1، حيث تعني النتيجة 1 تطابق المعنى، و 0 تعني عدم وجود صلة، و-1 تعني العكس. وتُحسب النتيجة بضرب المصفوفتين موضعاً بموضع، وجمع النتائج، ثم القسمة على مقدار كل مصفوفة. ويضمن ذلك حصول النص الطويل والنص القصير اللذين يحملان المعنى ذاته على نتيجة عالية. وتعني النتائج الأعلى تقارباً أكبر في المعنى، وهو أمر مفيد لترتيب الأجزاء.

وتمثل قيمة Top-k عدد الأجزاء الحاصلة على أعلى النتائج والتي يُحتفظ بها من عملية البحث. وتحافظ القيمة الصغيرة على قصر الأوامر النصية ولكنها قد تفوت الإجابة، بينما تضيف القيمة الكبيرة نصوصاً غير ذات صلة. أما نافذة السياق فهي الحد الأقصى لعدد الرموز التي يقرأها النموذج في استدعاء واحد، مما يعني ضرورة ملاءمة التعليمات والسؤال وجميع أجزاء Top-k داخلها.

بناء تطبيق RAG مبسط بلغة Java

يمكن بناء مثال Recipe Box كمشروع صغير باستخدام أداة Maven ونموذج التضمين المدمج في مكتبة LangChain4j، مما يعني أن تقسيم الأجزاء ومتجر المتجهات والبحث عن التشابه والأوامر النصية تُكتب جميعها كشيفرات برمجية مخصصة.

الخطوة 1: إنشاء مشروع Maven

يضع المشروع الفئات في حزمة com.howtodoinjava.rag والوصفات في src/main/resources. ويتطلب المشروع اعتمادية langchain4j-embeddings-all-minilm-l6-v2، التي تحتوي على ملف النموذج وتُشغله داخل جهاز Java الظاهري (JVM) دون الحاجة إلى مفتاح واجهة برمجة التطبيقات، كما تجلب مكتبة Jackson لتحليل بيانات JSON.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
                             https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>com.howtodoinjava</groupId>
  <artifactId>rag-explained</artifactId>
  <version>1.0</version>

  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <exec.mainClass>com.howtodoinjava.rag.RecipeBoxRag</exec.mainClass>
  </properties>

  <dependencies>
    <!-- Offline embedding model: all-MiniLM-L6-v2 runs inside the JVM, no API key -->
    <dependency>
      <groupId>dev.langchain4j</groupId>
      <artifactId>langchain4j-embeddings-all-minilm-l6-v2</artifactId>
      <version>1.22.0-beta32</version>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.16.0</version>
      </plugin>
      <plugin>
        <groupId>org.codehaus.mojo</groupId>
        <artifactId>exec-maven-plugin</artifactId>
        <version>3.6.4</version>
      </plugin>
    </plugins>
  </build>
</project>

وتُحمّل عملية البناء الأولى حوالي 190 ميجابايت للنموذج وبيئة تشغيله.

الخطوة 2: إضافة مستندات الوصفات

تُضاف أربعة ملفات نصية عادية، يحتوي كل منها على عنوان في السطر الأول، تليه فقرات للمكونات والطريقة وملاحظات التقديم. ويبدو ملف كعكة الليمون كما يلي:

Nani's Lemon Semolina Cake

Ingredients: 1 cup fine semolina, 3/4 cup sugar, 1 cup yogurt, 1/2 cup
melted butter, zest of 2 lemons, 1 teaspoon baking powder and a pinch of
cardamom.

Method: Mix the semolina, sugar and yogurt, and let the batter rest for
20 minutes so the semolina softens. Stir in the butter, zest, cardamom
and baking powder. Bake at 170 C for 38 minutes in a 9-inch tin.

Serving: Pour warm lemon syrup over the cake while it is still hot. The
cake keeps for 3 days in an airtight box at room temperature.

وتتوفر الملفات الأخرى، بما في ذلك وصفة خبز الموز التي تذكر أيضاً الخبز في قالب، على منصة GitHub.

الخطوة 3: إنشاء متجر المتجهات

متجر المتجهات هو قائمة من الإدخالات التي يحمل كل منها جزءاً وتضمينه. وتُقيّم طريقة search() الخاصة به كل إدخال مقابل تضمين السؤال باستخدام تشابه جيب التمام، ثم ترتبها وتحتفظ بقيمة Top-k.

package com.howtodoinjava.rag;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;

/**
 * A vector store kept in a plain list: every chunk is saved next to its embedding,
 * and a search compares the question's embedding with all of them.
 */
public class InMemoryVectorStore {

    public record Entry(String chunk, float[] embedding) {
    }

    public record Match(String chunk, double score) {
    }

    private final List<Entry> entries = new ArrayList<>();

    public void add(String chunk, float[] embedding) {
        entries.add(new Entry(chunk, embedding));
    }

    public int size() {
        return entries.size();
    }

    /** Similarity search: score every stored chunk and keep the top-k. */
    public List<Match> search(float[] questionEmbedding, int topK) {
        return entries.stream()
                .map(e -> new Match(e.chunk(), cosineSimilarity(questionEmbedding,
                        e.embedding())))
                .sorted(Comparator.comparingDouble(Match::score).reversed())
                .limit(topK)
                .toList();
    }

    /** Cosine similarity = (A . B) / (|A| * |B|), a number from -1 to 1. */
    static double cosineSimilarity(float[] a, float[] b) {
        double dot = 0;
        double normA = 0;
        double normB = 0;
        for (int i = 0; i < a.length; i++) {
            dot += a[i] * b[i];
            normA += a[i] * a[i];
            normB += b[i] * b[i];
        }
        return dot / (Math.sqrt(normA) * Math.sqrt(normB));
    }
}

الخطوة 4: إنشاء عميل Ollama

تُشغل أداة Ollama النماذج المفتوحة محلياً خلف واجهة برمجة تطبيقات HTTP على المنفذ 11434. ويُحمّل النموذج بأمر واحد، ويبلغ حجمه حوالي 400 ميجابايت:

ollama pull qwen2.5:0.5b

ويُرسل العميل الأوامر النصية إلى نقطة نهاية /api/chat الخاصة بأداة Ollama ويعيد الإجابة النصية.

package com.howtodoinjava.rag;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.Map;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

/**
 * Sends one prompt to a local Ollama server and returns the model's text answer.
 * This is the "generate" phase of RAG; the class knows nothing about retrieval.
 */
public class OllamaClient {

    /** The same instruction for every call, with or without RAG. */
    static final String SYSTEM_MESSAGE =
            "You are a cooking assistant. Answer in one or two sentences.";

    private static final ObjectMapper JSON = new ObjectMapper();

    private final HttpClient http = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(5))
            .build();
    private final String baseUrl;
    private final String model;

    public OllamaClient(String baseUrl, String model) {
        this.baseUrl = baseUrl;
        this.model = model;
    }

    /** Reads OLLAMA_URL and OLLAMA_MODEL, with the Ollama defaults as fallback. */
    public static OllamaClient fromEnvironment() {
        String url = System.getenv()
                .getOrDefault("OLLAMA_URL", "http://localhost:11434");
        String model = System.getenv().getOrDefault("OLLAMA_MODEL", "qwen2.5:0.5b");
        return new OllamaClient(url, model);
    }

    public String generate(String prompt) throws IOException, InterruptedException {
        Map<String, Object> body = Map.of(
                "model", model,
                "stream", false,
                "messages", List.of(
                        Map.of("role", "system", "content", SYSTEM_MESSAGE),
                        Map.of("role", "user", "content", prompt)),
                // temperature 0 and a fixed seed give the same answer on every run;
                // num_predict caps the answer length in tokens
                "options", Map.of("temperature", 0, "seed", 42, "num_predict", 200));

        HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + "/api/chat"))
                .timeout(Duration.ofMinutes(2))
                .header("Content-Type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(body)))
                .build();

        HttpResponse<String> response =
                http.send(request, HttpResponse.BodyHandlers.ofString());
        if (response.statusCode() != 200) {
            throw new IOException("Ollama returned " + response.statusCode()
                    + ": " + response.body());
        }
        JsonNode json = JSON.readTree(response.body());
        return json.path("message").path("content").asText().strip();
    }
}

وتضمن درجة الحرارة 0 والبذرة الثابتة الحصول على إجابات متسقة، بينما يحد num_predict الإجابة عند 200 من الرموز. وتختبر فئة AskWithoutRag العميل وتنتج الإجابة الخاطئة الموضحة سابقاً.

package com.howtodoinjava.rag;

/**
 * Asks the model about a private recipe with no retrieved text in the prompt.
 * The model has never seen our Recipe Box, so it can only guess.
 */
public class AskWithoutRag {

    public static void main(String[] args) throws Exception {
        String question = "How long do I bake Nani's Lemon Semolina Cake, "
                + "and at what temperature?";

        OllamaClient llm = OllamaClient.fromEnvironment();
        String answer = llm.generate(question);

        System.out.println("Question: " + question);
        System.out.println("Answer:");
        System.out.println(answer);
    }
}

الخطوة 5: تشغيل الفهرسة والاسترجاع والتوليد

تُشغل فئة RecipeBoxRag المراحل الثلاث. وتُنشئ طريقة chunk() جزءاً واحداً لكل فقرة، وتبدأ كل جزء بعنوان الوصفة لتسهيل العثور عليه. وتضع طريقة buildPrompt() السؤال قبل الأجزاء لمنع النموذج من التركيز على التفاصيل الخاطئة.

package com.howtodoinjava.rag;

import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.net.ConnectException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;

import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.model.embedding.onnx.allminilml6v2.AllMiniLmL6V2EmbeddingModel;

/**
 * A minimal RAG pipeline for a private Recipe Box: index, retrieve, generate.
 */
public class RecipeBoxRag {

    static final List<String> RECIPE_FILES = List.of(
            "lemon-semolina-cake.txt",
            "banana-walnut-bread.txt",
            "tomato-rasam.txt",
            "sunday-pancakes.txt");

    static final int TOP_K = 2;

    public static void main(String[] args) throws Exception {
        String question = args.length > 0
                ? String.join(" ", args)
                : "How long do I bake Nani's Lemon Semolina Cake, "
                        + "and at what temperature?";

        // embedding model: runs inside the JVM, no API key, 384 numbers per text
        EmbeddingModel embeddingModel = new AllMiniLmL6V2EmbeddingModel();
        InMemoryVectorStore store = new InMemoryVectorStore();

        // Phase 1: index. Chunk every document, embed every chunk, store both.
        for (String file : RECIPE_FILES) {
            for (String chunk : chunk(loadRecipe(file))) {
                float[] vector = embeddingModel.embed(chunk).content().vector();
                store.add(chunk, vector);
            }
        }
        System.out.println("Indexed " + store.size() + " chunks");

        // Phase 2: retrieve. Embed the question, then run a similarity search.
        float[] questionVector = embeddingModel.embed(question).content().vector();
        List<InMemoryVectorStore.Match> matches = store.search(questionVector, TOP_K);

        System.out.println("Question: " + question);
        System.out.println("Top-" + TOP_K + " chunks:");
        for (InMemoryVectorStore.Match match : matches) {
            System.out.printf(Locale.ROOT, "  %.3f  %s%n", match.score(),
                    ConsoleFormat.shorten(match.chunk()));
        }

        // Phase 3: generate. Put the retrieved text into the prompt, then call the LLM.
        String prompt = buildPrompt(question, matches);
        System.out.println();
        System.out.println("Prompt sent to the model:");
        System.out.println(prompt);
        System.out.println();
        System.out.println("Answer:");
        System.out.println(ConsoleFormat.wrap(generateOrExplain(prompt)));
    }

    /** Splits a recipe into chunks: one per paragraph, each starting with the title. */
    static List<String> chunk(String recipe) {
        String[] paragraphs = recipe.strip().split("\\n\\s*\\n");
        String title = paragraphs[0].strip();
        List<String> chunks = new ArrayList<>();
        for (int i = 1; i < paragraphs.length; i++) {
            chunks.add(title + "\n" + paragraphs[i].strip());
        }
        return chunks;
    }

    /** The augmented prompt: instruction, question, then the retrieved chunks. */
    static String buildPrompt(String question, List<InMemoryVectorStore.Match> matches) {
        List<String> chunks = matches.stream()
                .map(InMemoryVectorStore.Match::chunk)
                .toList();
        return """
                Answer the question using only the context below.
                If the context does not contain the answer,
                say "The Recipe Box does not say."

                Question: %s

                Context:
                %s""".formatted(question, String.join("\n\n", chunks));
    }

    static String generateOrExplain(String prompt) throws InterruptedException {
        try {
            return OllamaClient.fromEnvironment().generate(prompt);
        } catch (ConnectException e) {
            return "(no Ollama server found; start one to see the generated answer)";
        } catch (IOException e) {
            return "(Ollama call failed: " + e.getMessage() + ")";
        }
    }

    static String loadRecipe(String file) {
        String path = "/recipes/" + file;
        try (InputStream in = RecipeBoxRag.class.getResourceAsStream(path)) {
            if (in == null) {
                throw new IllegalStateException("Recipe not found: " + file);
            }
            return new String(in.readAllBytes(), StandardCharsets.UTF_8);
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }
}

الخطوة 6: تشغيل البرنامج

مع تشغيل أداة Ollama، يُنفذ البرنامج عبر أداة Maven:

mvn compile exec:java

وتُظهر المخرجات الأجزاء المسترجعة والأوامر النصية والإجابة الصحيحة:

Indexed 12 chunks
Question: How long do I bake Nani's Lemon Semolina Cake, and at what temperature?
Top-2 chunks:
  0.840  Nani's Lemon Semolina Cake | Method: Mix the semolina, su...
  0.788  Nani's Lemon Semolina Cake | Serving: Pour warm lemon syr...

Prompt sent to the model:
Answer the question using only the context below.
If the context does not contain the answer,
say "The Recipe Box does not say."

Question: How long do I bake Nani's Lemon Semolina Cake, and at what temperature?

Context:
Nani's Lemon Semolina Cake
Method: Mix the semolina, sugar and yogurt, and let the batter rest for
20 minutes so the semolina softens. Stir in the butter, zest, cardamom
and baking powder. Bake at 170 C for 38 minutes in a 9-inch tin.

Nani's Lemon Semolina Cake
Serving: Pour warm lemon syrup over the cake while it is still hot. The
cake keeps for 3 days in an airtight box at room temperature.

Answer:
  To bake Nani's Lemon Semolina Cake, you should bake it at 170 C for 38
  minutes.

إخفاقات RAG الشائعة وكيفية إصلاحها

قد تفشل تقنية RAG إذا أعاد البحث الأجزاء الخاطئة أو إذا أضاف النموذج حقائق غير موجودة في الأجزاء. وتوضح فئة RagTrap كلتا المشكلتين.

استرجاع الجزء الخاطئ

إذا قسّمت أداة التقسيم الوصفات إلى فقرات ولكنها أسقطت العنوان، فقد تفشل عملية البحث.

/** The broken chunker: one chunk per paragraph, and the title is dropped. */
static List<String> chunkWithoutTitle() {
    List<String> chunks = new ArrayList<>();
    for (String file : RecipeBoxRag.RECIPE_FILES) {
        String recipe = RecipeBoxRag.loadRecipe(file);
        String[] paragraphs = recipe.strip().split("\\n\\s*\\n");
        for (int i = 1; i < paragraphs.length; i++) {
            chunks.add(paragraphs[i].strip());
        }
    }
    return chunks;
}

عند السؤال عن كعكة الليمون مع استرجاع أعلى نتيجة (Top-1)، تختلف النتائج بناءً على طريقة التقسيم:

Trap 1, question: How long do I bake the lemon cake, and at what temperature?
Chunks WITHOUT the title:
  0.687  Serving: Pour warm lemon syrup over the cake while it is ...
  Answer: To bake the lemon cake, you should pour warm lemon syrup over the
  cake while it is still hot. The cake keeps for 3 days in an airtight box
  at room temperature.
Chunks WITH the title:
  0.585  Nani's Lemon Semolina Cake | Method: Mix the semolina, su...
  Answer: To bake the lemon cake, you should bake it at 170 C for 38
  minutes.
المرتبةبدون العنوانالنتيجةمع العنوانالنتيجة
الأولىكعكة الليمون، ملاحظات التقديم0.687كعكة الليمون، الطريقة (تحتوي على الإجابة)0.585
الثانيةكعكة الليمون، الطريقة (تحتوي على الإجابة)0.432كعكة الليمون، ملاحظات التقديم0.542
الثالثةخبز الموز، الطريقة0.413كعكة الليمون، المكونات0.368

بدون العنوان، لا تذكر فقرة الطريقة كلمتي «ليمون» أو «كعكة»، مما يجعل ملاحظات التقديم تتصدر الترتيب وتؤدي إلى إجابة خاطئة. ويجب أن يحمل كل جزء اسم المستند الذي جاء منه، وأن يظل في موضوع واحد، وأن يتناسب مع حد الرموز الخاص بنموذج التضمين.

الإجابة خارج النص المسترجع

عند طرح سؤال «هل يمكنني تجميد كعكة Nani's Lemon Semolina Cake؟»، لا تقدم الوصفة إجابة. وتسمح الأوامر النصية الفضفاضة للنموذج باختلاق إجابة.

/** The broken prompt: it lets the model add what it "knows" to the context. */
static String loosePrompt(String question, List<InMemoryVectorStore.Match> matches) {
    List<String> chunks = matches.stream()
            .map(InMemoryVectorStore.Match::chunk)
            .toList();
    return """
            Use the context below to help you answer.

            Question: %s

            Context:
            %s""".formatted(question, String.join("\n\n", chunks));
}
Trap 2, question: Can I freeze Nani's Lemon Semolina Cake?
Loose prompt answer:
  Yes, you can freeze Nani's Lemon Semolina Cake. The cake keeps for 3 days
  in an airtight box at room temperature.
Strict prompt answer:
  The Recipe Box does not say.

وتتسبب الأوامر النصية الفضفاضة في إجابة النموذج بنعم ودمج جملة حقيقية من الجزء. بينما تخبر الأوامر النصية الصارمة النموذج بالإجابة فقط من السياق وتوفر جملة دقيقة للإجابات المفقودة.

بدائل أطر العمل

في التطبيقات، توفر أطر العمل هذه المكونات. وتقدم مكتبة LangChain4j فئتي InMemoryEmbeddingStore وEmbeddingStoreContentRetriever لعمليات البحث عن التشابه. ويبني إطار Spring AI مسار العمل ذاته باستخدام EmbeddingModel وVectorStore.

القدرات والمقارنات

يختلف التوليد المعزز بالاسترجاع عن الضبط الدقيق بوضع المستندات في الأوامر النصية وقت السؤال، بينما يغير الضبط الدقيق أوزان النموذج من خلال تدريب إضافي. ويسري مفعول المستند المُعدّل في تقنية RAG بمجرد إعادة فهرسته. وفي حين تعمل القائمة في الذاكرة مع مجموعات المستندات الصغيرة، تصبح قاعدة البيانات المتجهية مفيدة عندما يجب أن يظل الفهرس بعد إعادة التشغيل أو عندما تصبح مقارنة كل متجه بطيئة للغاية. ويمكن تشغيل مسار العمل بالكامل دون اتصال بالإنترنت بعد البناء الأولي؛ حيث يعمل نموذج all-MiniLM-L6-v2 داخل جهاز Java الظاهري، وتُشغل أداة Ollama النموذج اللغوي الكبير محلياً. وتسمح أدوات تحميل المستندات ومحللاتها باستخدام ملفات PDF أو Word عن طريق تحويلها إلى نص عادي أولاً. وأخيراً، تقلل تقنية RAG من هلوسة الذكاء الاصطناعي ولكنها لا توقفها، حيث لا يزال بإمكان النموذج إضافة حقائق غير موجودة في النص المسترجع.

تحسين أداء تطبيقات RAG المحلية

يفهرس مسار عمل RAG الكامل المستندات مرة واحدة عن طريق تقسيمها وتضمينها وتخزينها، ثم يسترجع الأجزاء الأقرب لكل سؤال لتوليد إجابة. وفي مثال Recipe Box، صحح النموذج ذو 0.5 مليار معلمة تخمينه الأولي البالغ 180 مئوية لمدة 30 دقيقة إلى الإجابة الدقيقة البالغة 170 مئوية لمدة 38 دقيقة بعد عملية بحث ناجحة. ولمنع إخفاقات الاسترجاع، يجب أن يحمل كل جزء اسم المستند المصدر الخاص به، مما يضمن عدم تفوق أقسام مثل ملاحظات التقديم على فقرات الطريقة. بالإضافة إلى ذلك، يساعد توجيه النموذج للإجابة بدقة من السياق المقدم في الحفاظ على الدقة.

كُتب هذا المقال بالذكاء الاصطناعي اعتماداً على ما نشره: howtodoinjava.com.

هل أعجبك هذا المقال؟
Advertisement

اقرأ المزيد

عمليات البحث الشائعة