Thuta Learning
Local AI / Local LLM
ProjectsAIbeginner

Project: ကိုယ်ပိုင် Document အတွက် Q&A Assistant

ဒီခန်းပြီးရင် ဘာတတ်သွားမလဲ

  • Project: ကိုယ်ပိုင် Document အတွက် Q&A Assistant concept ကို နားလည်ရှင်းပြနိုင်ရန်
  • Diagram ကို ဖတ်ပြီး architecture ထဲမှာ data/request ဘယ်လိုစီးဆင်းသလဲ ခြေရာခံနိုင်ရန်
  • ကိုယ့် hardware/use case အတွက် ဘယ်လို ရွေးချယ်သင့်သလဲ ဆုံးဖြတ်နိုင်ရန်

နားလည်ထားရမယ့် အချက်

ဒါဟာ flagship local-RAG project ဖြစ်ပြီး retrieval chapter တွေက idea ငါးခုကို pipeline တစ်ခုတည်းထဲ ပေါင်းစပ်ဖို့ ရှိနေတာပါ — user ကိုယ်ပိုင် document တွေကနေ တကယ် အဖြေထုတ်ပေးနိုင်ဖို့ပါ။

  • Text extraction — upload လုပ်ထားတဲ့ PDF (သို့) text file ကို plain text အဖြစ် ပြောင်းလဲခြင်း
  • Chunking — text ကို overlap ပါတဲ့ အပိုင်းသေးလေးများအဖြစ် ခွဲခြင်း
  • Embedding model — chunk တစ်ခုချင်းစီကို vector အဖြစ် ပြောင်းလဲခြင်း
  • Vector store — vector တွေကို index လုပ်ပြီး similarity search ပြုလုပ်ခြင်း
  • Generation — retrieved chunk များကိုသာ သုံးပြီး citation ပါတဲ့ အဖြေပေးခြင်း

Document တစ်ခုလုံးကို vector တစ်ခုတည်းအနေနဲ့ embed လုပ်လိုက်ရင် မေးခွန်းကျဉ်းကျဉ်းလေးတစ်ခုနဲ့ ကိုက်ညီအောင် specific မဖြစ်တော့ပါဘူး။

Embedding model က chunk တစ်ခုချင်းစီကို vector အဖြစ် ပြောင်းပေးပြီး vector store က ဒီ vector တွေကို index လုပ်ထားလို့ မေးခွန်းအသစ်ရဲ့ vector ကို similarity search နဲ့ နှိုင်းယှဉ်နိုင်ပါတယ် — ဒါက retrieval-augmented generation ရဲ့ retrieval အပိုင်းဖြစ်ပြီး base model ကို training မလုပ်ခဲ့တဲ့ document တွေကနေ assistant က အဖြေပေးနိုင်တဲ့ အကြောင်းရင်းပါ။

Generation အပိုင်းကတော့ local chat model တစ်ခုက top-matching chunk တွေနဲ့ user ရဲ့ မေးခွန်းကိုပဲ context အနေနဲ့ လက်ခံပြီး ဒီ context ကိုပဲ သုံးပြီး အဖြေပေးဖို့ instruct ခံရတာပါ။

Citation တွေက claim တစ်ခုချင်းစီကို chunk နဲ့ source document တိကျစွာ ချိတ်ဆက်ပေးလို့ user က အဖြေကို မျက်မှောင်ကြုတ်စရာမလိုဘဲ verify လုပ်နိုင်ပါတယ် — RAG chapter က အလေးထားခဲ့တဲ့ honesty နဲ့ verifiability principle တွေကို တိုက်ရိုက် အသုံးချတာပါ။

ဒီ project ထဲမှာ training/fine-tuning လုံးဝ မရှိပါ

ရှင်းလင်းစွာ ထပ်ပြောရရင် — ဒီ project ထဲမှာ model ကို train (သို့) fine-tune လုပ်တာ လုံးဝ မရှိပါဘူး။ Document တွေဟာ weights ထဲ ဘယ်တော့မှ ပေါင်းစပ်မသွားပါဘူး။ မေးခွန်းတိုင်းက embed-search-generate sequence တူညီတာကို static document index အတိုင်း အသစ်ပြန်စလုပ်တာပါ၊ document အသစ်ထည့်ရင် ချက်ချင်း search ဖြစ်နိုင်ပြီး retraining ကြောင့် ဘာမှ "မေ့" သွားစရာ မရှိပါဘူး။

text
PRIVATE DOCUMENT Q&A PIPELINE
-----------------------------
INGEST -- runs once per uploaded document
  document --> extract text --> chunk (with overlap) --> embed chunk
                                                              |
                                                              v
                                                      VECTOR STORE
                                        [ vector, chunk text, source, idx ]

QUERY -- runs on every question
  question --> embed question
                    |
                    v
        similarity search over VECTOR STORE
                    |
                    v
          top-K matching chunks (by score)
                    |
                    v
  prompt = system + top-K chunks + question  --->  LOCAL CHAT MODEL
                    |
                    v
         answer text + citations [source, chunk #]

Nothing here trains or fine-tunes the model -- every question re-runs
embed -> search -> generate fresh against the same static chunk index.

လက်တွေ့ scenario နဲ့ ချိတ်ကြည့်မယ်

Ingest path နဲ့ query path နှစ်ခု vector store တစ်ခုတည်း share လုပ်တဲ့အနေနဲ့ တည်ဆောက်ပါ။

Document ကို extract ပြီး chunk ခွဲခြင်း

Upload လုပ်တဲ့ document တစ်ခုစီအတွက်, raw text ကို extract လုပ်ပြီး token အနည်းငယ်ရာစီရှိတဲ့ chunk တွေအဖြစ် overlap သေးသေးလေးနဲ့ ခွဲပါတယ် — idea တစ်ခု chunk boundary မှာ မပျောက်အောင်ပါ။

Chunk တွေကို embed လုပ်ပြီး သိမ်းခြင်း

Chunk တစ်ခုချင်းစီအတွက် local embedding endpoint ကို ခေါ်ပြီး vector တစ်ခုစီကို chunk text၊ source filename၊ chunk index နဲ့အတူ သိမ်းပါတယ်။

မေးခွန်းကို embed လုပ်ခြင်း

မေးခွန်းတိုင်းမှာ run ပြီး မေးခွန်းကို embedding model တူတူနဲ့ embed လုပ်ပါတယ်။

Top chunk တွေကို retrieve လုပ်ခြင်း

Store ပေါ် similarity search run ပြီး top handful chunk တွေကို ဆွဲထုတ်ပါတယ်။

Grounded အဖြေ generate လုပ်ခြင်း

Chunk တွေနဲ့ မေးခွန်းပဲ ပါတဲ့ prompt တစ်ခု (provided context ကနေပဲ အဖြေပေးဖို့ instruction ပါ) တည်ဆောက်ပြီး local chat model ဆီ ပို့ပါတယ်။

အဖြေကို visible citation list — အဖြေရဲ့ အစိတ်အပိုင်းတစ်ခုချင်းစီ document/chunk ဘယ်ကလာသလဲ — နဲ့အတူ afterthought မဟုတ်ဘဲ response ရဲ့ first-class အစိတ်အပိုင်းအဖြစ် render ပါ။

ဒီ project အတွက် "Done" ဆိုတာက အောက်ပါအချက်များ ပြည့်စုံရမည်ဖြစ်သည် —

  • document တွေက တကယ် အဖြေပေးနိုင်တဲ့ မေးခွန်းမေးရင် citation ပါတဲ့ မှန်ကန်တဲ့ အဖြေရပြီး source chunk ဖွင့်ကြည့်ရင် ကိုက်ညီခြင်း
  • document တွေမပါဝင်တဲ့ မေးခွန်းမေးရင် assistant က မသိဘူးလို့ ပြောခြင်း (မှန်းဆမပြောဘဲ)
  • document အသစ်ထည့်ရင် restart (သို့) retraining step လုံးဝမလိုဘဲ နောက်မေးခွန်းမှာ ချက်ချင်း search ဖြစ်ခြင်း

အတူတူ စမ်းရေးကြည့်မယ်

typescript
// A private document Q&A pipeline: ingest documents into a vector store,
// then answer questions using only retrieved chunks -- never by retraining
// or fine-tuning anything.
const EMBED_URL = "http://localhost:11434/api/embeddings";
const CHAT_URL = "http://localhost:11434/api/chat";
const EMBED_MODEL = "nomic-embed-text";
const CHAT_MODEL = "llama3.1:8b";

type StoredChunk = {
  vector: number[];
  text: string;
  source: string;
  chunkIndex: number;
};

const vectorStore: StoredChunk[] = []; // in-memory for a single-user tool

function chunkText(text: string, size = 800, overlap = 150): string[] {
  const chunks: string[] = [];
  let start = 0;
  while (start < text.length) {
    const end = Math.min(start + size, text.length);
    chunks.push(text.slice(start, end));
    if (end === text.length) break;
    start = end - overlap; // overlap so an idea at a boundary isn't lost
  }
  return chunks;
}

async function embed(text: string): Promise<number[]> {
  const res = await fetch(EMBED_URL, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ model: EMBED_MODEL, prompt: text }),
  });
  if (!res.ok) throw new Error(`Embedding request failed: ${res.status}`);
  const data = (await res.json()) as { embedding: number[] };
  return data.embedding;
}

// --- ingest path: run once per uploaded document ---
export async function ingestDocument(source: string, rawText: string) {
  const chunks = chunkText(rawText);
  for (let i = 0; i < chunks.length; i++) {
    const vector = await embed(chunks[i]);
    vectorStore.push({ vector, text: chunks[i], source, chunkIndex: i });
  }
}

function cosineSimilarity(a: number[], b: number[]): number {
  let dot = 0, normA = 0, normB = 0;
  for (let 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));
}

function searchChunks(queryVector: number[], topK = 4): StoredChunk[] {
  return [...vectorStore]
    .map((chunk) => ({ chunk, score: cosineSimilarity(queryVector, chunk.vector) }))
    .sort((a, b) => b.score - a.score)
    .slice(0, topK)
    .map((r) => r.chunk);
}

// --- query path: run on every question ---
export async function answerFromDocuments(question: string) {
  const questionVector = await embed(question);
  const topChunks = searchChunks(questionVector);

  const context = topChunks
    .map((c, i) => `[${i + 1}] (${c.source}, chunk ${c.chunkIndex})\n${c.text}`)
    .join("\n\n");

  const res = await fetch(CHAT_URL, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      model: CHAT_MODEL,
      stream: false,
      messages: [
        {
          role: "system",
          content:
            "Answer only using the numbered context below. If the " +
            "context does not contain the answer, say so -- do not guess. " +
            "Cite context numbers like [1] for every claim.\n\n" + context,
        },
        { role: "user", content: question },
      ],
    }),
  });

  if (!res.ok) throw new Error(`Chat request failed: ${res.status}`);
  const data = (await res.json()) as { message: { content: string } };

  return {
    answer: data.message.content,
    citations: topChunks.map((c) => ({ source: c.source, chunk: c.chunkIndex })),
  };
}

Run လုပ်လို့မရနိုင်ပါ

ဒီ code ဟာ ကိုယ်ပိုင် local AI server (ဥပမာ Ollama) ကို ခေါ်သုံးပါတယ် — ဒီ site ရဲ့ browser playground ကနေ visitor ရဲ့ စက်ပေါ်က local server ကို လှမ်းမခေါ်နိုင်ပါ (network access ကန့်သတ်ထားလို့ပါ)။ ကိုယ်တိုင် server run ထားတဲ့ terminal (သို့) file ထဲမှာ ဒီ code ကို run ပြီး စမ်းကြည့်ပါ။

You should see
Pipeline က မှန်ကန်စွာ အလုပ်လုပ်နေရင် document ထဲက အကြောင်းအရာနဲ့ သက်ဆိုင်တဲ့ မေးခွန်းမေးရင် စက္ကန့်အနည်းငယ်အတွင်း chunk ရွေးချယ်မှု ပြီးပြီး local model က paragraph အတိုင်းအတာ အဖြေပြန်ပေးပါတယ်၊ အဖြေအောက်မှာ "source document -- chunk number" ပုံစံ citation list ပါလာပါတယ်။ Document ထဲ လုံးဝမပါဝင်တဲ့ အကြောင်းအရာမေးရင် (သို့) document upload မလုပ်ရသေးရင် assistant က "ပေးထားတဲ့ document တွေထဲမှာ ဒီအချက် မတွေ့ပါဘူး" လို့ ရိုးရိုးရှင်းရှင်း ဝန်ခံပါတယ်၊ အဖြေ လုပ်ကြံပြီး မထုတ်ပါဘူး။ Document အသစ်တစ်ခု ထည့်လိုက်ရင် နောက် query မှာ ချက်ချင်း ပါဝင်လာပါတယ်၊ ဘာ setup ပြန်လုပ်စရာမှ မလိုပါဘူး။

၅ မိနစ် စမ်းကြည့်

Retrieved chunk တစ်ခုစီရဲ့ similarity score ကို threshold တစ်ခုနဲ့ နှိုင်းယှဉ်ပြီး၊ top chunk အားလုံးရဲ့ score က threshold အောက်ရောက်နေရင် LLM ကို လုံးဝ ခေါ်မနေတော့ဘဲ "ဒီမေးခွန်းအတွက် document ထဲမှာ ဆီလျော်တဲ့ အကြောင်းအရာ မတွေ့ပါဘူး" လို့ တိုက်ရိုက် ပြန်ပေးတဲ့ logic ထည့်ပါ။

သတိလေးတစ်ချက်

Chunk size ကို ကြီးလွန်း (သို့) overlap မထား chunk လုပ်ခြင်း — retrieval က relevant မဟုတ်တဲ့ text အလုံးအရင်းကို ဆွဲထုတ်လာပြီး boundary က idea တွေကို ဖြတ်ချပစ်တတ်ပါတယ်

LLM ကို "provided context ကနေပဲ အဖြေပေးပါ" လို့ ရှင်းလင်းစွာ မ instruct ဘဲ retrieved chunk ကို context အဖြစ်ပဲ ထည့်ပေးခြင်း — model က context ထဲမပါတဲ့ အချက်တွေကို ကိုယ့်ဗဟုသုတနဲ့ ဖြည့်စွက် answer ပေးတတ်ပါတယ်

Ollama API Reference -- Generate EmbeddingsLocal AI / Local LLM

ဒီနေရာမှာ လူအများမှားတတ်တယ်

  • Chunk size ကို ကြီးလွန်း (သို့) overlap မထား chunk လုပ်ခြင်း — retrieval က relevant မဟုတ်တဲ့ text အလုံးအရင်းကို ဆွဲထုတ်လာပြီး boundary က idea တွေကို ဖြတ်ချပစ်တတ်ပါတယ်
  • LLM ကို "provided context ကနေပဲ အဖြေပေးပါ" လို့ ရှင်းလင်းစွာ မ instruct ဘဲ retrieved chunk ကို context အဖြစ်ပဲ ထည့်ပေးခြင်း — model က context ထဲမပါတဲ့ အချက်တွေကို ကိုယ့်ဗဟုသုတနဲ့ ဖြည့်စွက် answer ပေးတတ်ပါတယ်
  • Model (သို့) tool အသစ်တစ်ခုကို production/daily-use workflow ထဲ တိုက်ရိုက်မထည့်ခင် သေးငယ်တဲ့ scale နဲ့ အရင်စမ်းကြည့်ပါ။

လေ့ကျင့်ခန်း

Retrieved chunk တစ်ခုစီရဲ့ similarity score ကို threshold တစ်ခုနဲ့ နှိုင်းယှဉ်ပြီး၊ top chunk အားလုံးရဲ့ score က threshold အောက်ရောက်နေရင် LLM ကို လုံးဝ ခေါ်မနေတော့ဘဲ "ဒီမေးခွန်းအတွက် document ထဲမှာ ဆီလျော်တဲ့ အကြောင်းအရာ မတွေ့ပါဘူး" လို့ တိုက်ရိုက် ပြန်ပေးတဲ့ logic ထည့်ပါ။

You'll know it worked when: Pipeline က မှန်ကန်စွာ အလုပ်လုပ်နေရင် document ထဲက အကြောင်းအရာနဲ့ သက်ဆိုင်တဲ့ မေးခွန်းမေးရင် စက္ကန့်အနည်းငယ်အတွင်း chunk ရွေးချယ်မှု ပြီးပြီး local model က paragraph အတိုင်းအတာ အဖြေပြန်ပေးပါတယ်၊ အဖြေအောက်မှာ "source document -- chunk number" ပုံစံ citation list ပါလာပါတယ်။ Document ထဲ လုံးဝမပါဝင်တဲ့ အကြောင်းအရာမေးရင် (သို့) document upload မလုပ်ရသေးရင် assistant က "ပေးထားတဲ့ document တွေထဲမှာ ဒီအချက် မတွေ့ပါဘူး" လို့ ရိုးရိုးရှင်းရှင်း ဝန်ခံပါတယ်၊ အဖြေ လုပ်ကြံပြီး မထုတ်ပါဘူး။ Document အသစ်တစ်ခု ထည့်လိုက်ရင် နောက် query မှာ ချက်ချင်း ပါဝင်လာပါတယ်၊ ဘာ setup ပြန်လုပ်စရာမှ မလိုပါဘူး။

Project: ကိုယ်ပိုင် Document အတွက် Q&A Assistant | Thuta Learning