แคชโมเดล AI ในเบราว์เซอร์

โมเดล AI ส่วนใหญ่มีอย่างน้อย 1 สิ่งที่เหมือนกันคือ โมเดลมีขนาดค่อนข้างใหญ่สำหรับทรัพยากรที่ถ่ายโอนผ่านอินเทอร์เน็ต โมเดลการตรวจจับออบเจ็กต์ MediaPipe ที่มีขนาดเล็กที่สุด (SSD MobileNetV2 float16) หนัก 5.6 MB และขนาดใหญ่ที่สุดอยู่ที่ประมาณ 25 MB

LLM แบบโอเพนซอร์ส gemma-2b-it-gpu-int4.bin มีขนาด 1.35 GB ซึ่งถือว่าน้อยมากสำหรับ LLM โมเดล Generative AI สามารถมีขนาดมหาศาล นี่จึงเป็นเหตุผลที่ปัจจุบันมีการใช้ AI อย่างแพร่หลาย ในระบบคลาวด์ แอปใช้โมเดลที่ได้รับการเพิ่มประสิทธิภาพสูงในอุปกรณ์โดยตรงมากขึ้นเรื่อยๆ แม้จะมีการสาธิต LLM ที่ทำงานในเบราว์เซอร์อยู่แล้ว ต่อไปนี้เป็นตัวอย่างระดับเวอร์ชันที่ใช้งานจริงของโมเดลอื่นๆ ที่ทำงานในเบราว์เซอร์

Adobe Photoshop บนเว็บที่เปิดเครื่องมือการเลือกวัตถุที่ทำงานด้วยระบบ AI ซึ่งเลือกวัตถุไว้ 3 ชิ้น ได้แก่ ยีราฟ 2 ตัวและดวงจันทร์

หากต้องการทำให้การเปิดตัวแอปพลิเคชันในอนาคตเร็วขึ้น คุณควรแคชข้อมูลโมเดลในอุปกรณ์ให้ชัดเจนแทนที่จะพึ่งพาแคชของเบราว์เซอร์ HTTP โดยนัย

แม้ว่าคู่มือนี้จะใช้ gemma-2b-it-gpu-int4.bin model ในการสร้างแชทบ็อต แต่คุณสามารถทำให้แนวทางนี้เหมาะกับโมเดลอื่นๆ และกรณีการใช้งานอื่นๆ ในอุปกรณ์ได้ วิธีที่ใช้กันมากที่สุดในการเชื่อมต่อแอปกับโมเดลคือการให้บริการโมเดลควบคู่ไปกับทรัพยากรที่เหลือของแอป การเพิ่มประสิทธิภาพ การแสดงโฆษณาเป็นสิ่งที่สำคัญมาก

กำหนดค่าส่วนหัวของแคชที่ถูกต้อง

หากแสดงโมเดล AI จากเซิร์ฟเวอร์ คุณต้องกำหนดค่าส่วนหัว Cache-Control ที่ถูกต้อง ตัวอย่างต่อไปนี้แสดงการตั้งค่าเริ่มต้นที่ดี ซึ่งคุณสร้างขึ้นตามความต้องการของแอปได้

Cache-Control: public, max-age=31536000, immutable

เวอร์ชันที่เปิดตัวของโมเดล AI แต่ละเวอร์ชันเป็นทรัพยากรแบบคงที่ เนื้อหาที่ไม่มีการเปลี่ยนแปลงควรมีการระบุ max-age แบบยาวรวมกับการป้องกันแคชใน URL คำขอ หากต้องการอัปเดตโมเดล คุณต้องระบุ URL ใหม่ให้กับโมเดล

เมื่อผู้ใช้โหลดหน้าซ้ำ ไคลเอ็นต์จะส่งคำขอตรวจสอบอีกครั้ง แม้ว่าเซิร์ฟเวอร์จะรู้ว่าเนื้อหามีความเสถียรแล้ว คำสั่ง immutable ระบุไว้อย่างชัดแจ้งว่าการตรวจสอบอีกครั้งไม่จำเป็นเนื่องจากเนื้อหาจะไม่เปลี่ยนแปลง เบราว์เซอร์และแคชตัวกลางหรือพร็อกซีเซิร์ฟเวอร์ไม่ได้รองรับคำสั่ง immutable ในวงกว้าง แต่เมื่อรวมคำสั่ง immutable กับคำสั่ง max-age ที่เป็นที่คุ้นเคยกันโดยทั่วไป คุณจะมั่นใจได้ถึงความสามารถในการใช้งานร่วมกันสูงสุด คำสั่งการตอบกลับ public ระบุว่าเก็บการตอบกลับไว้ในแคชที่แชร์ได้

Chrome DevTools แสดงส่วนหัวเวอร์ชันที่ใช้งานจริงCache-Controlซึ่งส่งโดย Hugging Face เมื่อขอโมเดล AI (แหล่งที่มา)

แคชโมเดล AI ฝั่งไคลเอ็นต์

เมื่อแสดงโมเดล AI คุณต้องแคชโมเดลในเบราว์เซอร์อย่างชัดแจ้ง วิธีนี้ช่วยให้มั่นใจได้ว่าข้อมูลโมเดลจะพร้อมใช้งานหลังจากที่ผู้ใช้โหลดแอปซ้ำ

มีเทคนิคมากมายที่คุณสามารถใช้เพื่อให้บรรลุเป้าหมายนี้ สำหรับตัวอย่างโค้ดต่อไปนี้ ให้สมมติว่าไฟล์โมเดลแต่ละไฟล์จัดเก็บไว้ในออบเจ็กต์ Blob ที่ชื่อ blob ในหน่วยความจำ

ตัวอย่างโค้ดแต่ละรายการจะมีคำอธิบายประกอบด้วยเมธอด performance.mark() และ performance.measure() เพื่อทำความเข้าใจประสิทธิภาพ มาตรการเหล่านี้ขึ้นอยู่กับอุปกรณ์และไม่สามารถทำให้เป็นแบบทั่วไปได้

ใน Chrome DevTools แอปพลิเคชัน > พื้นที่เก็บข้อมูล ให้ดูแผนภาพการใช้งานที่มีกลุ่มสำหรับ IndexedDB, พื้นที่เก็บข้อมูลแคช และระบบไฟล์ จะเห็นได้ว่าแต่ละกลุ่มใช้ข้อมูล 1,354 เมกะไบต์ ซึ่งรวมกันแล้วเท่ากับ 4,063 เมกะไบต์

คุณสามารถเลือกใช้ API รายการใดรายการหนึ่งต่อไปนี้เพื่อแคชโมเดล AI ในเบราว์เซอร์ ได้แก่ Cache API, Origin Private File System API และ IndexedDB API คำแนะนำทั่วไปคือให้ใช้ API แคช แต่คู่มือนี้จะพูดถึงข้อดีและข้อเสียของตัวเลือกทั้งหมด

API แคช

Cache API มีพื้นที่เก็บข้อมูลถาวรสําหรับคู่ออบเจ็กต์ Request และ Response ที่แคชไว้ในหน่วยความจำที่มีอายุยาวนาน แม้ว่าจะมีการระบุไว้ในข้อมูลจำเพาะของ Service Workers แต่คุณก็ใช้ API นี้จากเทรดหลักหรือผู้ปฏิบัติงานทั่วไปได้ หากต้องการใช้นอกบริบทของ Service Worker ให้เรียกใช้เมธอด Cache.put() ด้วยออบเจ็กต์ Response สังเคราะห์ที่จับคู่กับ URL สังเคราะห์แทนออบเจ็กต์ Request

คู่มือนี้จะถือว่ามี blob ในหน่วยความจำ ใช้ URL ปลอมเป็นคีย์แคชและใช้ Response สังเคราะห์ตาม blob หากต้องการดาวน์โหลดโมเดลโดยตรง คุณจะใช้ Response ที่จะได้รับจากการส่งคำขอ fetch()

เช่น วิธีจัดเก็บและกู้คืนไฟล์โมเดลด้วย Cache API มีดังนี้

const storeFileInSWCache = async (blob) => {
  try {
    performance.mark('start-sw-cache-cache');
    const modelCache = await caches.open('models');
    await modelCache.put('model.bin', new Response(blob));
    performance.mark('end-sw-cache-cache');

    const mark = performance.measure(
      'sw-cache-cache',
      'start-sw-cache-cache',
      'end-sw-cache-cache'
    );
    console.log('Model file cached in sw-cache.', mark.name, mark.duration.toFixed(2));
  } catch (err) {
    console.error(err.name, err.message);
  }
};

const restoreFileFromSWCache = async () => {
  try {
    performance.mark('start-sw-cache-restore');
    const modelCache = await caches.open('models');
    const response = await modelCache.match('model.bin');
    if (!response) {
      throw new Error(`File model.bin not found in sw-cache.`);
    }
    const file = await response.blob();
    performance.mark('end-sw-cache-restore');
    const mark = performance.measure(
      'sw-cache-restore',
      'start-sw-cache-restore',
      'end-sw-cache-restore'
    );
    console.log(mark.name, mark.duration.toFixed(2));
    console.log('Cached model file found in sw-cache.');
    return file;
  } catch (err) {    
    throw err;
  }
};

API ระบบไฟล์ส่วนตัวต้นทาง

ระบบไฟล์ส่วนตัวต้นทาง (OPFS) เป็นมาตรฐานที่ค่อนข้างใหม่มากสำหรับปลายทางพื้นที่เก็บข้อมูล เป็นการตั้งค่าส่วนตัวสำหรับต้นทางของหน้าเว็บ ดังนั้นผู้ใช้จึงมองไม่เห็น ซึ่งต่างจากระบบไฟล์ทั่วไป ไฟล์นี้จะให้สิทธิ์เข้าถึงไฟล์พิเศษที่ได้รับการเพิ่มประสิทธิภาพอย่างมากและให้สิทธิ์ในการเขียนเนื้อหา

ตัวอย่างเช่น ต่อไปนี้เป็นวิธีจัดเก็บและกู้คืนไฟล์โมเดลใน OPFS

const storeFileInOPFS = async (blob) => {
  try {
    performance.mark('start-opfs-cache');
    const root = await navigator.storage.getDirectory();
    const handle = await root.getFileHandle('model.bin', { create: true });
    const writable = await handle.createWritable();
    await blob.stream().pipeTo(writable);
    performance.mark('end-opfs-cache');
    const mark = performance.measure(
      'opfs-cache',
      'start-opfs-cache',
      'end-opfs-cache'
    );
    console.log('Model file cached in OPFS.', mark.name, mark.duration.toFixed(2));
  } catch (err) {
    console.error(err.name, err.message);
  }
};

const restoreFileFromOPFS = async () => {
  try {
    performance.mark('start-opfs-restore');
    const root = await navigator.storage.getDirectory();
    const handle = await root.getFileHandle('model.bin');
    const file = await handle.getFile();
    performance.mark('end-opfs-restore');
    const mark = performance.measure(
      'opfs-restore',
      'start-opfs-restore',
      'end-opfs-restore'
    );
    console.log('Cached model file found in OPFS.', mark.name, mark.duration.toFixed(2));
    return file;
  } catch (err) {    
    throw err;
  }
};

API ของ IndexedDB

IndexedDB เป็นมาตรฐานที่ได้รับการคุ้มครองมาอย่างดีสำหรับการจัดเก็บข้อมูลที่กำหนดเองในลักษณะแบบถาวรในเบราว์เซอร์ ขึ้นชื่อว่าเป็น API ที่ค่อนข้างซับซ้อน แต่การใช้ไลบรารี Wrapper อย่าง idb-keyval คุณจะทำให้ IndexedDB เป็นเหมือนที่เก็บคีย์-ค่าแบบคลาสสิกได้

เช่น

import { get, set } from 'https://cdn.jsdelivr.net/npm/idb-keyval@latest/+esm';

const storeFileInIDB = async (blob) => {
  try {
    performance.mark('start-idb-cache');
    await set('model.bin', blob);
    performance.mark('end-idb-cache');
    const mark = performance.measure(
      'idb-cache',
      'start-idb-cache',
      'end-idb-cache'
    );
    console.log('Model file cached in IDB.', mark.name, mark.duration.toFixed(2));
  } catch (err) {
    console.error(err.name, err.message);
  }
};

const restoreFileFromIDB = async () => {
  try {
    performance.mark('start-idb-restore');
    const file = await get('model.bin');
    if (!file) {
      throw new Error('File model.bin not found in IDB.');
    }
    performance.mark('end-idb-restore');
    const mark = performance.measure(
      'idb-restore',
      'start-idb-restore',
      'end-idb-restore'
    );
    console.log('Cached model file found in IDB.', mark.name, mark.duration.toFixed(2));
    return file;
  } catch (err) {    
    throw err;
  }
};

ทำเครื่องหมายพื้นที่เก็บข้อมูลว่ายังคงอยู่

เรียกใช้ navigator.storage.persist() ในตอนท้ายของวิธีการแคชเพื่อขอสิทธิ์ในการใช้พื้นที่เก็บข้อมูลถาวร วิธีนี้จะแสดงสัญญาที่แปลงเป็น true หากได้รับสิทธิ์ และfalse เบราว์เซอร์อาจหรือไม่ดำเนินการตามคำขอ ทั้งนี้ขึ้นอยู่กับกฎเฉพาะเบราว์เซอร์

if ('storage' in navigator && 'persist' in navigator.storage) {
  try {
    const persistent = await navigator.storage.persist();
    if (persistent) {
      console.log("Storage will not be cleared except by explicit user action.");
      return;
    }
    console.log("Storage may be cleared under storage pressure.");  
  } catch (err) {
    console.error(err.name, err.message);
  }
}

กรณีพิเศษ: ใช้รุ่นบนฮาร์ดดิสก์

คุณสามารถอ้างอิงโมเดล AI โดยตรงจากฮาร์ดดิสก์ของผู้ใช้เป็นทางเลือกในการใช้พื้นที่เก็บข้อมูลของเบราว์เซอร์ เทคนิคนี้จะช่วยให้แอปที่มุ่งเน้นด้านการค้นคว้าแสดงความเป็นไปได้ของการเรียกใช้โมเดลที่ระบุในเบราว์เซอร์ หรือช่วยให้ศิลปินใช้โมเดลที่ฝึกด้วยตนเองในแอปความคิดสร้างสรรค์ของผู้เชี่ยวชาญ

File System Access API

เมื่อใช้ File System Access API คุณสามารถเปิดไฟล์จากฮาร์ดดิสก์และรับ FileSystemFileHandle ที่คุณคงอยู่ใน IndexedDB ได้

เมื่อใช้รูปแบบนี้ ผู้ใช้จะต้องให้สิทธิ์เข้าถึงไฟล์โมเดลเพียงครั้งเดียว สิทธิ์ถาวรช่วยให้ผู้ใช้เลือกให้สิทธิ์เข้าถึงไฟล์อย่างถาวรได้ หลังจากโหลดแอปซ้ำและผ่านท่าทางสัมผัสของผู้ใช้ที่จำเป็นแล้ว เช่น การคลิกเมาส์ คุณจะคืนค่า FileSystemFileHandle ได้จาก IndexedDB ด้วยสิทธิ์เข้าถึงไฟล์ในฮาร์ดดิสก์

ระบบจะค้นหาและขอสิทธิ์ในการเข้าถึงไฟล์หากจำเป็น ซึ่งจะทำให้การโหลดซ้ำในอนาคตเป็นไปอย่างราบรื่น ตัวอย่างต่อไปนี้แสดงวิธีรับแฮนเดิลสำหรับไฟล์จากฮาร์ดดิสก์ จากนั้นจัดเก็บและคืนค่าแฮนเดิล

import { fileOpen } from 'https://cdn.jsdelivr.net/npm/browser-fs-access@latest/dist/index.modern.js';
import { get, set } from 'https://cdn.jsdelivr.net/npm/idb-keyval@latest/+esm';

button.addEventListener('click', async () => {
  try {
    const file = await fileOpen({
      extensions: ['.bin'],
      mimeTypes: ['application/octet-stream'],
      description: 'AI model files',
    });
    if (file.handle) {
      // It's an asynchronous method, but no need to await it.
      storeFileHandleInIDB(file.handle);
    }
    return file;
  } catch (err) {
    if (err.name !== 'AbortError') {
      console.error(err.name, err.message);
    }
  }
});

const storeFileHandleInIDB = async (handle) => {
  try {
    performance.mark('start-file-handle-cache');
    await set('model.bin.handle', handle);
    performance.mark('end-file-handle-cache');
    const mark = performance.measure(
      'file-handle-cache',
      'start-file-handle-cache',
      'end-file-handle-cache'
    );
    console.log('Model file handle cached in IDB.', mark.name, mark.duration.toFixed(2));
  } catch (err) {
    console.error(err.name, err.message);
  }
};

const restoreFileFromFileHandle = async () => {
  try {
    performance.mark('start-file-handle-restore');
    const handle = await get('model.bin.handle');
    if (!handle) {
      throw new Error('File handle model.bin.handle not found in IDB.');
    }
    if ((await handle.queryPermission()) !== 'granted') {
      const decision = await handle.requestPermission();
      if (decision === 'denied' || decision === 'prompt') {
        throw new Error(Access to file model.bin.handle not granted.');
      }
    }
    const file = await handle.getFile();
    performance.mark('end-file-handle-restore');
    const mark = performance.measure(
      'file-handle-restore',
      'start-file-handle-restore',
      'end-file-handle-restore'
    );
    console.log('Cached model file handle found in IDB.', mark.name, mark.duration.toFixed(2));
    return file;
  } catch (err) {    
    throw err;
  }
};

วิธีการเหล่านี้ใช้ร่วมกันไม่ได้ อาจมีกรณีที่คุณแคชโมเดลในเบราว์เซอร์อย่างชัดแจ้งและใช้โมเดลจากฮาร์ดดิสก์ของผู้ใช้

ข้อมูลประชากร

คุณดูวิธีพื้นที่เก็บข้อมูลของเคสปกติทั้ง 3 วิธี และวิธีฮาร์ดดิสก์ที่ใช้ในการสาธิต MediaPipe LLM

โบนัส: ดาวน์โหลดไฟล์ขนาดใหญ่เป็นกลุ่ม

หากต้องการดาวน์โหลดโมเดล AI ขนาดใหญ่จากอินเทอร์เน็ต ให้ดาวน์โหลดพร้อมกันเป็นส่วนย่อยๆ แล้วต่อไคลเอ็นต์เข้าด้วยกันอีกครั้ง

นี่คือฟังก์ชันตัวช่วยที่คุณใช้ในโค้ดได้ คุณจะต้องให้ผ่าน url เท่านั้น ฟังก์ชัน chunkSize (ค่าเริ่มต้น: 5 MB), maxParallelRequests (ค่าเริ่มต้น: 6), ฟังก์ชัน progressCallback (ซึ่งรายงานเกี่ยวกับ downloadedBytes และ fileSize ทั้งหมด) และ signal สำหรับสัญญาณ AbortSignal ล้วนเป็นฟังก์ชันที่ไม่บังคับ

คุณสามารถคัดลอกฟังก์ชันต่อไปนี้ในโปรเจ็กต์หรือติดตั้งแพ็กเกจ fetch-in-chunks จากแพ็กเกจ npm

async function fetchInChunks(
  url,
  chunkSize = 5 * 1024 * 1024,
  maxParallelRequests = 6,
  progressCallback = null,
  signal = null
) {
  // Helper function to get the size of the remote file using a HEAD request
  async function getFileSize(url, signal) {
    const response = await fetch(url, { method: 'HEAD', signal });
    if (!response.ok) {
      throw new Error('Failed to fetch the file size');
    }
    const contentLength = response.headers.get('content-length');
    if (!contentLength) {
      throw new Error('Content-Length header is missing');
    }
    return parseInt(contentLength, 10);
  }

  // Helper function to fetch a chunk of the file
  async function fetchChunk(url, start, end, signal) {
    const response = await fetch(url, {
      headers: { Range: `bytes=${start}-${end}` },
      signal,
    });
    if (!response.ok && response.status !== 206) {
      throw new Error('Failed to fetch chunk');
    }
    return await response.arrayBuffer();
  }

  // Helper function to download chunks with parallelism
  async function downloadChunks(
    url,
    fileSize,
    chunkSize,
    maxParallelRequests,
    progressCallback,
    signal
  ) {
    let chunks = [];
    let queue = [];
    let start = 0;
    let downloadedBytes = 0;

    // Function to process the queue
    async function processQueue() {
      while (start < fileSize) {
        if (queue.length < maxParallelRequests) {
          let end = Math.min(start + chunkSize - 1, fileSize - 1);
          let promise = fetchChunk(url, start, end, signal)
            .then((chunk) => {
              chunks.push({ start, chunk });
              downloadedBytes += chunk.byteLength;

              // Update progress if callback is provided
              if (progressCallback) {
                progressCallback(downloadedBytes, fileSize);
              }

              // Remove this promise from the queue when it resolves
              queue = queue.filter((p) => p !== promise);
            })
            .catch((err) => {              
              throw err;              
            });
          queue.push(promise);
          start += chunkSize;
        }
        // Wait for at least one promise to resolve before continuing
        if (queue.length >= maxParallelRequests) {
          await Promise.race(queue);
        }
      }

      // Wait for all remaining promises to resolve
      await Promise.all(queue);
    }

    await processQueue();

    return chunks.sort((a, b) => a.start - b.start).map((chunk) => chunk.chunk);
  }

  // Get the file size
  const fileSize = await getFileSize(url, signal);

  // Download the file in chunks
  const chunks = await downloadChunks(
    url,
    fileSize,
    chunkSize,
    maxParallelRequests,
    progressCallback,
    signal
  );

  // Stitch the chunks together
  const blob = new Blob(chunks);

  return blob;
}

export default fetchInChunks;

เลือกวิธีที่เหมาะกับคุณ

คู่มือนี้ได้สำรวจวิธีต่างๆ ในการแคชโมเดล AI ในเบราว์เซอร์อย่างมีประสิทธิภาพ ซึ่งเป็นงานสำคัญในการปรับปรุงประสบการณ์ของผู้ใช้และประสิทธิภาพของแอป ทีมพื้นที่เก็บข้อมูลของ Chrome ขอแนะนำ Cache API เพื่อประสิทธิภาพการทำงานสูงสุด เพื่อให้เข้าถึงโมเดล AI ได้อย่างรวดเร็ว ลดเวลาในการโหลดและปรับปรุงการตอบสนอง

OPFS และ IndexedDB เป็นตัวเลือกที่ใช้งานได้น้อยกว่า API ของ OPFS และ IndexedDB ต้องเรียงลำดับข้อมูลก่อนจึงจะจัดเก็บได้ IndexedDB ยังต้องทำการดีซีเรียลไลซ์ข้อมูลเมื่อดึงออกมา ทำให้เป็นตำแหน่งที่แย่ที่สุดในการจัดเก็บโมเดลขนาดใหญ่

สำหรับแอปพลิเคชันเฉพาะกลุ่ม File System Access API มีการเข้าถึงไฟล์โดยตรงในอุปกรณ์ของผู้ใช้ ซึ่งเหมาะสำหรับผู้ใช้ที่จัดการโมเดล AI ของตนเอง

หากต้องการรักษาโมเดล AI ของคุณให้ปลอดภัย ให้เก็บโมเดลไว้บนเซิร์ฟเวอร์ เมื่อจัดเก็บในไคลเอ็นต์แล้ว การดึงข้อมูลจากทั้ง Cache และ IndexedDB ด้วย DevTools หรือส่วนขยาย OFPS DevTools เป็นเรื่องที่ไม่สำคัญ API พื้นที่เก็บข้อมูลเหล่านี้มีการรักษาความปลอดภัยอย่างเท่าเทียมกันโดยธรรมชาติ คุณอาจอยากเก็บโมเดลเวอร์ชันที่เข้ารหัสไว้ แต่จากนั้นต้องได้รับคีย์การถอดรหัสไปยังไคลเอ็นต์ซึ่งอาจถูกดักรับข้อมูล ซึ่งหมายความว่าการที่ผู้ไม่ประสงค์ดีขโมย โมเดลของคุณจะทำได้ยากขึ้นเล็กน้อย แต่ก็เป็นไปไม่ได้

เราขอแนะนําให้เลือกกลยุทธ์การแคชที่สอดคล้องกับข้อกําหนดของแอป พฤติกรรมกลุ่มเป้าหมาย และลักษณะของโมเดล AI ที่ใช้ วิธีนี้ช่วยให้มั่นใจได้ว่าแอปพลิเคชันของคุณจะมีการตอบสนองและมีประสิทธิภาพภายใต้สภาวะของเครือข่ายและข้อจำกัดของระบบที่หลากหลาย


กิตติกรรมประกาศ

รีวิวนี้ได้รับการตรวจสอบโดย Joshua Bell, Reilly Grant, Evan Stade, Nathan Memmott, Austin Sullivan, Etienne Noël, André Bandarra, Alexandra Klepper, François Beaufort, Paul Kinlan และ Rachel Andrew