অধ্যায় 1 · অ্যাডভান্সড RAG: হাইব্রিড রিট্রিভাল ও রিরাংকিং
হাইব্রিড রিট্রিভাল ও এরর বিশ্লেষণ
- পৃষ্ঠা 1 / 22
- 26 মিনিট পড়া
বিগিনার টিউটোরিয়াল শেষ হয়েছিল এমন একটা মূল্যায়ন-বেসলাইন নিয়ে, যা উত্তরযোগ্য প্রশ্নের ৮৪.৬% ক্ষেত্রে সঠিক ডকুমেন্ট এনেছিল আর চারটা কেসে ব্যর্থ হয়েছিল (মূল্যায়ন ডেটাসেট ও নির্ধারিত টেস্ট)। এতে বোঝা যায় যে কিছু ভুল হচ্ছে, কোথায় হচ্ছে তা বোঝা যায় না। ঢাকার নাদিয়া তার সাপোর্ট টিমের কাছে শোনে, "বট রিটার্ন নিয়ে ভুল বলছে"। পলিসিটা কি ইনডেক্সেই নেই, পাঁচ নম্বরে নেমে গেছে, থ্রেশহোল্ডে বাদ পড়েছে, মডেল এলোমেলো করে ফেলেছে, নাকি সাইটেশন ভুল? প্রতিটার প্রতিকার আলাদা। এই পাতা একটা ডায়াগনোসিস স্তর বানায় (ধাপ-ধরে-ধরে যাচাই আর এরর শ্রেণিবিন্যাস), তারপর একই হাতে-বানানো লেবেলে কীওয়ার্ড, ভেক্টর আর ফিউজ করা রিট্রিভালের তুলনা করে।
নতুন কোড হলো সাবপ্যাকেজ evidencedesk/retrieval/। এটা বিগিনারের মডিউলগুলো (bm25, embeddings, index, chunking, citations) ইমপোর্ট করে, নতুন করে বানায় না। কোনো সংখ্যার আগে একটা সতর্কতা: এখানে "ডেন্স" মানে এমবেডিং ও সাদৃশ্য পাতার খেলনা এমবেডার, অর্থাৎ হ্যাশ করা ক্যারেক্টার ট্রাইগ্রাম, যা অক্ষর মেলায়, অর্থ নয়। ফলাফল পদ্ধতিটা দেখায়। এগুলো প্রোডাকশনের সংখ্যা নয়, আর কোনো আসল এমবেডিং মডেল চালানো হয়নি। উত্তরযোগ্য প্রশ্ন ২১টা, তাই একটা প্রশ্ন হার বদলায় ৪.৮ পয়েন্ট।
যা শিখবেন
- কোন পাইপলাইন-ধাপে প্রমাণ হারিয়ে গেল তা কীভাবে খুঁজবেন, আর গুনে দেখার জন্য পাঁচ লেবেলের এরর শ্রেণিবিন্যাস।
- একই লেবেলে কীওয়ার্ড ও ভেক্টর সার্চের তুলনা কীভাবে করবেন, আর রিসিপ্রোকাল র্যাংক ফিউশন (RRF) দিয়ে সেগুলো কীভাবে জুড়বেন, যা হাতে মিলিয়ে দেখা যায়।
- ফিল্টার কেন র্যাংকিংয়ের আগে বসে, আর পুরনো বা সীমিত টেক্সট যে ঢুকছে না তা কীভাবে পরীক্ষা করবেন।
- ডকুমেন্টের কাঠামো ধরে চাঙ্কিং টেবিল ও কোডকে কীভাবে সামলায়, আর দুই-ধাপের পাইপলাইনের আকার কেমন।
বেসলাইন পাইপলাইনের ডায়াগনোসিস করুন
একটা RAG উত্তর পাঁচটা ধাপ পার হয়। কোনো ধাপে হারানো তথ্য পরের ধাপ ফেরাতে পারে না, তাই ধাপগুলো ক্রমে দেখুন আর সবচেয়ে আগের ব্যর্থতা ঠিক করুন।
| ধাপ | প্রশ্ন |
|---|---|
| ingestion (ইনজেশন) | উত্তরের টেক্সট কি ইনডেক্স করা কোনো চাঙ্কের ভেতরে পুরোটা আছে (Label.grade_of)? |
| retrieval (রিট্রিভাল) | উত্তরসহ কোনো চাঙ্ক কি শীর্ষ k-এর মধ্যে আছে, আর কত নম্বরে? |
| context (কনটেক্সট) | স্কোর-সীমা, ডুপ্লিকেট বাদ আর বাজেট পার হয়ে কি টিকেছে? |
| generation (জেনারেশন) | উদ্ধৃত টেক্সট কি দাবিটাকে সমর্থন করে (check_support)? |
| citation (সাইটেশন) | প্রতিটা উদ্ধৃত আইডি কি এমন চাঙ্ককে নির্দেশ করে যা মডেলকে দেখানো হয়েছিল (check_citations)? |
যাচাইয়ের জন্য সঠিক উত্তর লাগে, তাই কিটে এলো data/retrieval/labels.jsonl: ২৪টা প্রশ্ন (rag_cases.jsonl-এর ১৬টা একই আইডিসহ, আর ৮টা নতুন)। প্রতিটা লেবেল ডকুমেন্ট পড়ে লেখা হয়েছে, কোনো সার্চ চালানোর আগে। লেবেলে চাঙ্ক-আইডি নেই, কারণ অন্য চাঙ্কার বসালে আইডি ভেঙে যায়। তাতে আছে ডকুমেন্ট থেকে নেওয়া একটা ছোট প্রমাণ-বাক্যাংশ (evidence phrase), একটা গ্রেড (২ মানে প্রশ্নের উত্তর দেয়, ১ মানে কাজের প্রেক্ষাপট), একই চাঙ্কে থাকতে হবে এমন ঐচ্ছিক আরও বাক্যাংশ (টেবিলের সারির জন্য তার হেডার) আর একটা কারণ। Label.grade_of(chunk) ফেরত দেয় চাঙ্কে পুরোটা থাকা বাক্যাংশগুলোর মধ্যে সেরা গ্রেড।
প্রথমে বিগিনারের বেসলাইন: প্যারাগ্রাফ চাঙ্কের ওপর ভেক্টর সার্চ, শুধু বর্তমান ডকুমেন্ট, শীর্ষ ৩, কোসাইন অন্তত ০.৩। তিনটা নতুন ডকুমেন্ট (টর্ক ও টায়ার-প্রেশার চার্ট, ট্রাবলশুটিং গাইড, শুধু কর্মীদের নোট) কর্পাসকে একটু কঠিন করেছে।
from evidencedesk.retrieval.catalog import Scope, load_catalog
from evidencedesk.retrieval.labels import load_labels
from evidencedesk.retrieval.report import evaluate
from evidencedesk.retrieval.searchers import DenseSearcher
labels = load_labels()
cat = load_catalog("paragraph") # the Beginner chunks, plus three new documents
dense = DenseSearcher(cat.chunks)
rows = evaluate(dense, cat, labels, Scope(), k=3, floor=0.3) # the Beginner baseline: current only, cosine >= 0.3
print(f"{len(rows)} questions, {sum(r.failed for r in rows)} fail")
for r in rows:
if r.failed:
f = r.findings[0]
print(f"{r.id:<5}{f.stage:<10}{f.label:<17}{f.detail}")24 questions, 5 fail
rc03 context missed_evidence retrieved at rank 1 but dropped before the prompt
rc06 retrieval missed_evidence answer not in the top 3; rank 1 is kc-privacy-v1:c0
rc08 retrieval missed_evidence answer not in the top 3; rank 1 is kc-sizing-v1:c1
rc15 context irrelevant_match 3 chunk(s) in the prompt; the corpus has no answer
h01 retrieval missed_evidence answer not in the top 3; rank 1 is kc-torque-v1:c0২৪টার মধ্যে পাঁচটা ব্যর্থ, পাঁচ আলাদা কারণে। rc03 ("RD2-24") রিট্রিভালের ব্যর্থতা নয়: রিকল নোটিশ ১ নম্বরেই আছে, আর স্কোর-সীমা ০.২৯৫-এ সেটা ফেলে দেয় (RAG-এর নির্ভরযোগ্যতা ও মূল্যায়নের ডেটা পাতার গার্ড)। rc06 ও rc08-এ ("money back", "voucher") ভেক্টর সার্চ প্রশ্নটাকে রিফান্ড বা গিফট-কার্ডের টেক্সটের সঙ্গে জুড়তে পারে না। rc15-এর কোনো উত্তর নেই, তবু তিনটা চাঙ্ক সীমা পার করে। h01 একটা টেবিলের সারি। (বিগিনারের স্যুট মার্জিন-নিয়মও লাগিয়েছিল আর উত্তর যাচাই করেছিল, তাই তার ব্যর্থতার তালিকা আলাদা।)
বাকি ধাপগুলো দেখাতে ইচ্ছাকৃতভাবে ভাঙা একটা রান লাগে: উইন্ডো খুব ছোট একটা চাঙ্কার (ইনজেশন), ভুল সংখ্যাওয়ালা হাতে-লেখা একটা উত্তর, আর এমন একটা উত্তর যা মডেলকে কখনো না-দেখানো চাঙ্ক সাইটেশন হিসেবে দেয়। মডেলটা একটা স্ট্যান্ড-ইন; আসল মডেল কম অনুমানযোগ্য, আর সেজন্যই টেস্ট করতে হয়।
from evidencedesk.retrieval.candidates import ranked
from evidencedesk.retrieval.report import run_trace
from evidencedesk.retrieval.taxonomy import Trace, diagnose
rc01 = next(x for x in labels if x.id == "rc01")
cut = load_catalog("fixed", size=60, overlap=0) # windows far too small: the evidence is cut in two
hit = ranked([(cat.by_id["kc-returns-v3:c0"], 0.55)], "dense")
cases = {
"ingestion": run_trace(rc01, DenseSearcher(cut.chunks), cut, Scope(), 3),
"generation": Trace(tuple(cat.chunks), hit, hit, "Return within 60 days.", ("kc-returns-v3:c0",)),
"citation": Trace(tuple(cat.chunks), hit, hit, "Return within 30 days of delivery.", ("kc-returns-v3:c1",)),
}
for name, trace in cases.items():
print(f"{name:<11}", [(f.stage, f.label) for f in diagnose(rc01, trace)])
lost = sum(not any(x.grade_of(c) == 2 for c in cut.chunks) for x in labels if x.answerable)
print(f"with windows of 60 characters, {lost} of 21 answers are whole in no chunk")ingestion [('ingestion', 'missed_evidence')]
generation [('generation', 'unsupported_answer')]
citation [('citation', 'unsupported_answer')]
with windows of 60 characters, 16 of 21 answers are whole in no chunkরিট্রিভাল এররের শ্রেণিবিন্যাস বানান
ধাপ বলে ঠিক করতে কোথায় যেতে হবে; লেবেল বলে আপনি কী দেখেছেন। একটা ফাইন্ডিং হলো (ধাপ, লেবেল) জোড়া। diagnose ফাইন্ডিংগুলো ধাপের ক্রমে সাজিয়ে দেয়, তাই প্রথমটাই মূল কারণ।
| লেবেল | অর্থ | যেভাবে ধরা হয় |
|---|---|---|
missed_evidence | উত্তর আছে, কিন্তু পরের ধাপে পৌঁছায়নি | কোনো চাঙ্কে পুরোটা নেই, শীর্ষ k-তে নেই, বা প্রম্পটের আগেই বাদ পড়েছে |
irrelevant_match | অন্য কিছু উত্তরের ওপরে উঠেছে, অথবা উত্তর না থাকলেও প্রম্পট ভরেছে | ১ নম্বরে থাকা চাঙ্কের গ্রেড ০; উত্তরহীন প্রশ্নে কোনো কনটেক্সট |
stale_document | বাতিল (superseded) ডকুমেন্ট ক্যান্ডিডেট হয়েছে | status != "current" |
duplicate_chunks | প্রায় একই চাঙ্ক প্রম্পটে ভাগ বসিয়েছে | শব্দের ওভারল্যাপ ০.৮ বা বেশি (বিগিনারের overlap) |
unsupported_answer | উদ্ধৃত টেক্সট দাবিটা সমর্থন করে না | সংখ্যা নেই, মূল শব্দের ভাগ কম, বা সাইটেশন দেখানো হয়নি |
# file: evidencedesk/retrieval/taxonomy.py (excerpt)
STAGES = ("ingestion", "retrieval", "context", "generation", "citation")
LABELS = ("missed_evidence", "irrelevant_match", "stale_document", "duplicate_chunks", "unsupported_answer")
@dataclass(frozen=True)
class Finding:
stage: str # one of STAGES: where the fix belongs
label: str # one of LABELS: what the symptom is
detail: str
@dataclass(frozen=True)
class Trace:
"""What one run did, step by step. Built by the pipeline, read by diagnose()."""
indexed: tuple[Chunk, ...] # every chunk the index holds
retrieved: RankedList # what search returned
context: RankedList # what survived into the prompt (score floor, de-duplication, token budget ...)
answer: str | None = None # the model's claim, if a model was called
cited: tuple[str, ...] = () # chunk ids the model citedtaxonomy.py-এর বাকিটা কিটে আছে। দেখতে বৈধ একটা সাইটেশন মানেই পাস নয়: check_citations আলাদা করে এমন আইডি যা আছে কিন্তু দেখানো হয়নি, আর এমন আইডি যা কোথাও নেই; তার ওপর দাবিটাকে টেক্সটের সঙ্গে মিলতে হয়। একটা লেবেল-সারি, পুরনো-ডকুমেন্টের ফাঁদ:
{"id": "rc09", "question": "Are refunds paid as store credit?", "tags": ["stale"], "answerable": true,
"gold": [{"section": "kc-returns-v3#How refunds are paid", "evidence": "Refunds go back to the original payment method within 5 business days", "grade": 2}],
"reason": "The current policy says original payment method. The superseded v2 says store credit: retrieving it gives a confident wrong yes."}সব ২৪ প্রশ্নে লেবেল গুনলে বোঝা যায় ফিল্টার কী দেয়। তারপর ইচ্ছে করে আরেকটা ব্যর্থতা বানানো হয়েছে: একটা চাঙ্কের দুই কপি রেখে দেওয়া রি-ইমপোর্ট।
from dataclasses import replace
from evidencedesk.retrieval.catalog import Catalog
from evidencedesk.retrieval.report import label_counts
open_ = label_counts(evaluate(dense, cat, labels, None, k=3, floor=0.3)) # no filter at all
shut = label_counts(evaluate(dense, cat, labels, Scope(), k=3, floor=0.3))
print(f"{'stage':<11}{'label':<17}{'no filter':>10}{'filtered':>10}")
for key in sorted(open_.keys() | shut.keys()):
print(f"{key[0]:<11}{key[1]:<17}{open_[key]:>10}{shut[key]:>10}")
twin = replace(cat.by_id["kc-returns-v3:c0"], id="kc-returns-v3:c0-copy") # a re-import that kept both copies
dup = Catalog(cat.chunks + [twin], cat.audience)
t = run_trace(rc01, DenseSearcher(dup.chunks), dup, Scope(), 3)
print([c.chunk.id for c in t.retrieved])
print([(f.stage, f.label) for f in diagnose(rc01, t)])stage label no filter filtered
context irrelevant_match 1 1
context missed_evidence 1 1
retrieval irrelevant_match 6 4
retrieval missed_evidence 3 3
retrieval stale_document 7 0
['kc-returns-v3:c0', 'kc-returns-v3:c0-copy', 'kc-warranty-v2:c0']
[('context', 'duplicate_chunks')]ফিল্টার ছাড়া সাতটা প্রশ্নের শীর্ষ ৩-এ বাতিল ডকুমেন্টের চাঙ্ক আছে (rc09-এ পুরনো স্টোর-ক্রেডিট নিয়মটাই ১ নম্বরে); Scope() দিলে একটাও নেই। বাকি লেবেলগুলো থেকে যায়: সেগুলোর দরকার ভালো রিট্রিভাল, ফিল্টার নয়। ডুপ্লিকেটটা তিন প্রম্পট-স্লটের দুটো নিয়ে নেয় (কনটেক্সটের মান, বাজেট ও পুনরুৎপাদনযোগ্যতা পাতায় এমন পুনরাবৃত্তি drop_duplicates দিয়ে সরিয়েছিল)।
BM25 কীওয়ার্ড রিট্রিভাল বেসলাইন বানান
এটা RAG-এর নির্ভরযোগ্যতা ও মূল্যায়নের ডেটা পাতার BM25, অপরিবর্তিত। SparseSearcher শুধু একে সাধারণ ইন্টারফেস দেয় আর BM25.from_chunks দিয়ে বানায়, ফলে ভেক্টর ইনডেক্স যে টেক্সট এমবেড করে হুবহু সেটাই ইনডেক্স হয়। একটা টার্মের স্কোর idf * tf / (tf + k1 * (1 - b + b * length / avg_length)), যেখানে k1 = 1.2 ও b = 0.75; বিরল শব্দের ওজন সাধারণ শব্দের চেয়ে বেশি। ব্লকটা হুবহু আইডেন্টিফায়ারের ওপর হাতে মিলিয়ে দেখায়:
from evidencedesk.retrieval.searchers import SparseSearcher
sparse = SparseSearcher(cat.chunks)
bm = sparse.bm25
print(f"{bm.n} chunks, average length {bm.avg_len:.1f} words")
for term in ("rd2-24", "return", "bike"):
print(f"{term:<8}in {bm.df[term]:>2} chunks idf {bm.idf(term):.2f}")
for q in ("RD2-24", "How do I get my money back?"):
print(q)
for s in (sparse, dense):
print(f" {s.name:<7}", [(c.chunk.id.removeprefix("kc-"), round(c.score, 2)) for c in s.search(q, 3, cat.allowed(Scope()))])
n, hand = bm.length["kc-recalls-v1:c0"], 3.56 * 1 / (1 + 1.2 * (1 - 0.75 + 0.75 * bm.length["kc-recalls-v1:c0"] / bm.avg_len))
print(f"by hand for RD2-24: chunk of {n} words, 3.56 * 1 / (1 + 1.2 * (0.25 + 0.75 * {n} / {bm.avg_len:.1f})) = {hand:.2f}")52 chunks, average length 21.0 words
rd2-24 in 1 chunks idf 3.56
return in 2 chunks idf 3.05
bike in 12 chunks idf 1.44
RD2-24
sparse [('recalls-v1:c0', 1.5)]
dense [('recalls-v1:c0', 0.29), ('troubleshoot-v1:c1', 0.22), ('troubleshoot-v1:c0', 0.19)]
How do I get my money back?
sparse [('returns-v3:c2', 1.72)]
dense [('privacy-v1:c0', 0.22), ('shipping-v1:c1', 0.22), ('returns-v3-bn:c1', 0.18)]
by hand for RD2-24: chunk of 25 words, 3.56 * 1 / (1 + 1.2 * (0.25 + 0.75 * 25 / 21.0)) = 1.50"RD2-24" একটাই চাঙ্কে আছে, তাই তার idf বেশি আর BM25 শুধু সেই চাঙ্কটাই ফেরত দেয়। যে শব্দ কোনো চাঙ্কে নেই তার স্কোর শূন্য: যেখানে ভেক্টর সার্চ সবসময় নিকটতম প্রতিবেশী ফেরত দেয়, BM25 সেখানে চুপ। সতর্কতা: স্টেমিং নেই ("returnable" দিয়ে "return" মেলে না), আর idf গোনে সেই চাঙ্কও যা ফিল্টার পরে লুকাবে। তিন-ডকুমেন্টের একটা স্থির ফিক্সচার হাতের হিসাবসহ আছে tests/test_retrieval_fusion.py-এ।
ডেন্স ও স্পার্স রিট্রিভালের তুলনা করুন
ন্যায্য তুলনায় একটাই জিনিস বদলায়: একই চাঙ্ক, একই এমবেড-টেক্সট, একই ফিল্টার, একই লেবেল, একই k। হিট@k মানে "উত্তরসহ একটা চাঙ্ক শীর্ষ k-তে আছে" (বিগিনারের পাতায় ডকুমেন্ট-স্তরে যাকে recall@k বলা হয়েছিল)। পরের পাতা যোগ করে recall@k, precision@k, MRR ও nDCG (পাঠ্যবইয়ের রূপ: গেইন 2^rel - 1, ডিসকাউন্ট log2(1 + rank); লাইব্রেরিভেদে ভিন্ন হয়)।
from evidencedesk.retrieval.report import hit_at
TAGS = ["exact_term", "paraphrase", "bangla", "structured"]
by = {s.name: evaluate(s, cat, labels, Scope(), k=5) for s in (sparse, dense)}
print(f"{'tag':<12}{'n':>3}{'sparse@1':>10}{'sparse@3':>10}{'dense@1':>9}{'dense@3':>9}")
for tag in TAGS + [None]:
cells = [hit_at(by[s], k, tag)[0] for s in ("sparse", "dense") for k in (1, 3)]
print(f"{tag or 'all':<12}{hit_at(by['sparse'], 1, tag)[1]:>3}{cells[0]:>10}{cells[1]:>10}{cells[2]:>9}{cells[3]:>9}")
rank = {s: {r.id: r.rank for r in rows} for s, rows in by.items()}
for a, b in (("sparse", "dense"), ("dense", "sparse")):
only = [q for q in rank[a] if rank[a][q] and rank[a][q] <= 3 and not (rank[b][q] and rank[b][q] <= 3)]
print(f"top 3 has the answer for {a} but not {b}:", only)tag n sparse@1 sparse@3 dense@1 dense@3
exact_term 4 3 4 2 4
paraphrase 6 3 4 4 4
bangla 3 2 3 3 3
structured 6 4 6 3 5
all 21 15 19 14 18
top 3 has the answer for sparse but not dense: ['rc06', 'h01']
top 3 has the answer for dense but not sparse: ['rc05']যেখানে শব্দ হুবহু মেলে সেখানে কীওয়ার্ড সার্চ জেতে (rc06 "go back"-এর "back" মেলায়; h01)। ভেক্টর সার্চ জেতে rc05, "returnable after a month"; কিন্তু কেন জেতে তা সততার সঙ্গে বলুন: খেলনা এমবেডার "returnable" ও "return"-এর অক্ষর-ট্রাইগ্রাম মেলায়। এটা বানান, অর্থ নয়। ভাউচারের প্রশ্ন (rc08) কেউই পায় না; তার জন্য এমন সমার্থক শব্দ লাগে যা এখানকার কোনো পদ্ধতি জানে না। ডেন্স সার্চ "RD2-24"-ও সহজে পায়, যা ট্রাইগ্রাম সম্পর্কে কিছু বলে, আসল মডেল সম্পর্কে নয়; আসল মডেল আইডেন্টিফায়ার অন্যভাবে সামলাতে পারে, আর আমরা তা চালাইনি। মোট ফলে একটা প্রশ্নের তফাত (হিট@৩-এ ১৯ বনাম ১৮): সমান সমান।
র্যাংক করা তালিকা ফিউজ করুন
দুই পদ্ধতির স্কোর যোগ করা যায় না: BM25 সীমাহীন, কোসাইন -১ থেকে ১-এর মধ্যে। রিসিপ্রোকাল র্যাংক ফিউশন শুধু র্যাংক ব্যবহার করে: score(d) = sum over lists of 1 / (k + rank of d in that list), র্যাংক শুরু ১ থেকে।
টুল নোট (২০২৬-১০-১২ অনুযায়ী): RRF এসেছে Cormack, Clarke ও Buettcher-এর SIGIR 2009 গবেষণা থেকে। তাঁরা একটা পাইলটে
k = 60স্থির করেন, একে প্রায় সর্বোত্তম কিন্তু "গুরুত্বপূর্ণ নয়" পান, আর k ব্যবহার করেন আউটলায়ার সিস্টেমের উঁচু র্যাংকের প্রভাব কমাতে। সেরা একক সিস্টেমের চেয়ে তাঁদের প্রায় ৪ থেকে ৫% লাভ ২০০৯ সালের TREC ও LETOR ডেটার, LLM-যুগের হাইব্রিড সার্চে তা সরাসরি খাটে না। যে লাইব্রেরি ব্যবহার করছেন তার নিজের ডিফল্ট কী, তা বর্তমান ডকুমেন্টেশনে দেখে নিন।
# file: evidencedesk/retrieval/fusion.py (excerpt)
K = 60 # the paper's constant: fixed in a pilot, "near-optimal, but the choice was not critical"
def rrf(lists: Sequence[RankedList], k: int = K, top: int | None = None, source: str = "hybrid") -> RankedList:
"""score(d) = sum over the lists that contain d of 1 / (k + rank of d in that list). Scores are ignored.
Exact fractions make ties real ties; ties go to the better best-rank, then to the chunk id, so output is stable.
"""
if k < 0:
raise ValueError("k must not be negative")
total: dict[str, Fraction] = {}
first: dict[str, Candidate] = {}
parts: dict[str, list[Contribution]] = {}
for lst in lists:
for rank, cand in enumerate(lst, 1): # the position in the list, so a hand-built list works too
cid = cand.chunk.id
total[cid] = total.get(cid, Fraction(0)) + Fraction(1, k + rank)
first.setdefault(cid, cand)
parts.setdefault(cid, []).append(Contribution(cand.source, rank, cand.score))
best = {cid: min(p.rank for p in ps) for cid, ps in parts.items()}
order = sorted(total, key=lambda cid: (-total[cid], best[cid], cid))
fused = [Candidate(first[cid].chunk, float(total[cid]), n, source, tuple(parts[cid])) for n, cid in enumerate(order, 1)]
return fused[:top]নিখুঁত ভগ্নাংশ ব্যবহার করায় টাই সত্যিকারের টাই হয়, আর টাই-নিয়ম (সেরা র্যাংক, তারপর আইডি) আউটপুট স্থির রাখে। প্রতিটা ফিউজড ক্যান্ডিডেট তার উৎসগুলোর আসল স্কোর parts-এ রাখে, কারণ RRF নিজে সেগুলো ফেলে দেয়। হাতে-হিসাব করা কেসগুলো ফিক্সচার data/retrieval/rrf_examples.json-এ। তালিকা [d1,d2,d3] ও [d3,d1,d4]-এর জন্য: d1 = 1/61 + 1/62 = 0.032522 আর d3 = 1/63 + 1/61 = 0.032266, তাই d3 এক তালিকায় প্রথম হলেও d1 জেতে। আরও দুটো কেস k-এর প্রভাব দেখায়: k = 1 হলে এক তালিকার প্রথম স্থান দুই তালিকার চতুর্থ স্থানকে হারায়; k = 60 হলে যে ডকুমেন্ট দুই তালিকাতেই আছে সে জেতে।
import json
from fractions import Fraction
from evidencedesk.retrieval.candidates import ids, ranked
from evidencedesk.retrieval.fusion import rrf
from evidencedesk.retrieval.labels import DATA
class Doc: # the smallest stand-in for a chunk: fusion only reads .id
def __init__(self, id): self.id = id
for ex in json.loads((DATA / "rrf_examples.json").read_text(encoding="utf-8"))[:2]:
lists = [ranked([(Doc(i), 0.0) for i in names], "x") for names in ex["lists"]]
got = [(c.chunk.id, c.score) for c in rrf(lists, ex["k"])]
hand = [(i, float(sum(Fraction(p.strip()) for p in expr.split("+")))) for i, expr in ex["expected"]]
print(ex["id"], "k =", ex["k"], "same as by hand:", all(a[0] == b[0] and abs(a[1] - b[1]) < 1e-12 for a, b in zip(got, hand)))
print(" ", [(i, round(s, 5)) for i, s in got])two-lists k = 60 same as by hand: True
[('d1', 0.03252), ('d3', 0.03227), ('d2', 0.01613), ('d4', 0.01587)]
agreement-beats-one-first-place k = 60 same as by hand: True
[('q', 0.03226), ('p', 0.01639), ('r', 0.01639)]এবার আসল তালিকা: একটা হুবহু আইডেন্টিফায়ার, একটা প্যারাফ্রেজ আর টেবিলের প্রশ্ন। প্রতিটা তালিকায় আর ফিউজড তালিকায় উত্তরটা কোথায় বসে?
from evidencedesk.retrieval.searchers import HybridSearcher
hybrid = HybridSearcher(sparse, dense)
allowed = cat.allowed(Scope())
for q in ("RD2-24", "Is a bike returnable after a month?", "What torque should the stem bolts have on a size M?"):
lab = next(x for x in labels if x.question == q)
fused = hybrid.search(q, 20, allowed)
top = next(c for c in fused if lab.grade_of(c.chunk) == 2)
where = {p.source: p.rank for p in top.parts}
print(f"{q[:34]:<36}answer: sparse rank {where.get('sparse', '-')}, dense rank {where.get('dense', '-')}, hybrid rank {top.rank}")
top = lambda q: round(dense.search(q, 1, allowed)[0].score, 3)
print("best dense score: unanswerable 'electric scooters'", top("Do you sell electric scooters?"),
"| answerable 'RD2-24'", top("RD2-24"), "| 'money back'", top("How do I get my money back?"))RD2-24 answer: sparse rank 1, dense rank 1, hybrid rank 1
Is a bike returnable after a month answer: sparse rank 4, dense rank 1, hybrid rank 2
What torque should the stem bolts answer: sparse rank 3, dense rank 12, hybrid rank 5
best dense score: unanswerable 'electric scooters' 0.456 | answerable 'RD2-24' 0.295 | 'money back' 0.224ফিউশন প্যারাফ্রেজটা উদ্ধার করে (কীওয়ার্ডে ৪র্থ, ভেক্টরে ১ম, ফিউজডে ২য়) কিন্তু বিনামূল্যে নয়: টেবিলের সারি দুই তালিকায় ৩য় ও ১২তম, ফিউজডে ৫ম, কারণ দুর্বল ভেক্টর তালিকা ভালো কীওয়ার্ড তালিকাকে পাতলা করে দেয়। শেষ লাইনে আরেকটা দাম দেখা যায়। স্কোর ছাড়া "কতটা নিশ্চিত" জানার উপায় নেই: উত্তরহীন স্কুটার প্রশ্নে সেরা কোসাইন (০.৪৫৬) "RD2-24" (০.২৯৫) আর "money back"-এর (০.২২৪) চেয়ে বেশি, যাদের উত্তর আছে। উঁচু সাদৃশ্য-স্কোর একটা সংকেত, প্রমাণ নয়, আর এর ওপর কোনো থ্রেশহোল্ড এই কেসগুলো আলাদা করে না।
মেটাডেটা ও অ্যাক্সেস ফিল্টার আগে লাগান
দুই ধরনের টেক্সট কখনো ক্যান্ডিডেট সেটে ঢুকবে না: বাতিল টেক্সট, আর যা প্রশ্নকারীর দেখার কথা নয়। কিট দ্বিতীয় ধরনটা ফ্রন্ট ম্যাটারে চিহ্নিত করে (audience: staff; বিগিনারের লোডার এই কি উপেক্ষা করে)। একটা Scope মেটাডেটাকে অনুমোদিত চাঙ্ক-আইডির সেটে বদলে দেয়, আর প্রতিটা সার্চার সেই সেট নেয়, ফলে শুধু অনুমোদিত চাঙ্ক র্যাংক হয়:
# file: evidencedesk/retrieval/catalog.py (excerpt)
@dataclass(frozen=True)
class Scope:
"""What a request is allowed to see. The default is a public customer asking about current policy."""
statuses: frozenset[str] = frozenset({"current"})
audiences: frozenset[str] = frozenset({"public"})
langs: frozenset[str] | None = None # None = any language# file: evidencedesk/retrieval/searchers.py (excerpt)
class DenseSearcher:
name = "dense"
def __init__(self, chunks: list[Chunk], model: Model | None = None) -> None:
self.chunks, self.model = {c.id: c for c in chunks}, model or Model.toy()
self.index, _ = VectorIndex.build([c.to_passage() for c in chunks], self.model, lambda: "fixed-clock")
def search(self, query: str, k: int = 5, allowed: set[str] | None = None) -> RankedList:
"""Filter first, then rank only the allowed vectors. Zero or negative cosine is not a match."""
self.index.meta.check(self.model)
rows = [n for n, cid in enumerate(self.index.ids) if allowed is None or cid in allowed]
if unusable_reason(query) or not rows:
return []
hits = top_k(self.model.embed_batch([query])[0], self.index.matrix[rows], [self.index.ids[n] for n in rows], k)
return ranked([(self.chunks[h.passage_id], h.score) for h in hits if h.score > 0], self.name)উল্টো পথ (অ্যান্টি-প্যাটার্ন) হলো আগে শীর্ষ k নেওয়া, পরে যা অনুমোদিত নয় তা বাদ দেওয়া (কিটে post_filter):
from evidencedesk.retrieval.searchers import post_filter
q = "Can I return a bike after 40 days?"
everyone = cat.allowed(Scope(audiences=frozenset({"public", "staff"}), statuses=frozenset({"current", "superseded"})))
short = lambda found: [c.chunk.id.removeprefix("kc-") for c in found]
print("no filter ", short(hybrid.search(q, 3, everyone)))
print("post-filter", short(post_filter(hybrid, q, 3, allowed)))
print("pre-filter ", short(hybrid.search(q, 3, allowed)))
leak = "goodwill refund store manager"
print(leak, "->", short(hybrid.search(leak, 3, everyone))[:2], "| post-filter:", short(post_filter(hybrid, leak, 3, allowed)))
print(len(everyone), "chunks in the index,", len(allowed), "visible to a public customer asking for current policy")no filter ['returns-v2:c0', 'returns-v3:c0', 'staff-goodwill-v1:c0']
post-filter ['returns-v3:c0']
pre-filter ['returns-v3:c0', 'returns-v3:c2', 'battery-v1:c1']
goodwill refund store manager -> ['staff-goodwill-v1:c0', 'staff-goodwill-v1:c1'] | post-filter: []
52 chunks in the index, 48 visible to a public customer asking for current policyপোস্ট-ফিল্টার তিনটার জায়গায় একটা চাঙ্ক ফেরত দিয়েছে, আর লিক-কোয়েরিতে কিছুই না, কারণ একমাত্র হিটগুলো নিষিদ্ধ ছিল। প্রি-ফিল্টার তালিকা ভরে অনুমোদিত চাঙ্ক থেকে। test_retrieval_filters.py প্রতিটা লেবেল-করা প্রশ্ন আর দুটো আক্রমণ-কোয়েরি তিন সার্চারের মধ্য দিয়ে চারটা k-তে চালায় এবং অ্যাসার্ট করে যে কোনো বাতিল বা কর্মী-চাঙ্ক ফেরত আসে না। এটা র্যাংকিংয়ের আগে কোডে অ্যাক্সেস নিয়ন্ত্রণ, মডেলকে টেক্সট উপেক্ষা করতে বলার প্রম্পট নয় (প্রম্পট গার্ডরেইল নয়)। ভেক্টর ডেটাবেস আনুমানিক ইনডেক্সের ভেতরে ফিল্টার করতে পারে; তার ডকুমেন্টেশন পড়ুন, আর পরীক্ষা করুন ফিল্টার-করা কোয়েরি এখনো k-টা ফল দেয় কি না। পরের পাতা এই ধারণা টেন্যান্ট পর্যন্ত বাড়ায়।
ডকুমেন্টের কাঠামো ধরে চাঙ্কিং বেছে নিন
চাঙ্কিং ঠিক করে একটা হিট কী। চারটা চাঙ্কার, একই লেবেল, হাইব্রিড সার্চ: fixed (পুরো বডির ওপর ২০০ ক্যারেক্টারের উইন্ডো, হেডিং-অন্ধ), section (প্রতি ## হেডিংয়ে একটা চাঙ্ক), paragraph (বিগিনারের চাঙ্কার) আর structure (প্যারাগ্রাফ, তার সঙ্গে টেবিল ও কোড একক হিসেবে, পরের অংশ)। "whole" গোনে উত্তরযোগ্য কতগুলো প্রশ্নের উত্তর কোনো চাঙ্কে পুরোটা আছে (ইনজেশন যাচাই); "context chars" হলো শীর্ষ ৩ চাঙ্কের গড় আকার, টোকেন ও খরচের মোটামুটি মাপ।
def context_chars(searcher, c): # characters the model would read: the top 3 chunks
sizes = [sum(len(x.chunk.text) for x in searcher.search(l.question, 3, c.allowed(Scope()))) for l in labels]
return sum(sizes) / len(sizes)
print(f"{'strategy':<10}{'chunks':>7}{'whole':>7}{'hit@1':>7}{'hit@3':>7}{'hit@3 tables/code/long':>24}{'context chars':>15}")
for name in ("fixed", "section", "paragraph", "structure"):
c = load_catalog(name)
h = HybridSearcher(SparseSearcher(c.chunks), DenseSearcher(c.chunks))
whole = sum(any(x.grade_of(ch) == 2 for ch in c.chunks) for x in labels if x.answerable)
rows = evaluate(h, c, labels, Scope(), k=5)
print(f"{name:<10}{len(c.chunks):>7}{whole:>5}/21{hit_at(rows, 1)[0]:>7}{hit_at(rows, 3)[0]:>7}"
f"{hit_at(rows, 3, 'structured')[0]:>16}/{hit_at(rows, 3, 'structured')[1]}{context_chars(h, c):>15.0f}")strategy chunks whole hit@1 hit@3 hit@3 tables/code/long context chars
fixed 46 20/21 16 19 5/6 562
section 44 21/21 18 20 6/6 517
paragraph 52 21/21 16 19 5/6 382
structure 54 21/21 18 19 5/6 386শুধু fixed উইন্ডো একটা উত্তর হারায়: তারা স্টেম-বোল্টের সারি থেকে হেডার কেটে ফেলে, ফলে সারিটা আর বলে না কোন কলাম size M। পুরো সেকশন সবচেয়ে ভালো হিট দেয়, কিন্তু প্যারাগ্রাফের ৩৮২-এর বদলে কনটেক্সটে ৫১৭ ক্যারেক্টার খরচ করে; এই কর্পাসে বেশির ভাগ সেকশনে একটাই প্যারাগ্রাফ, তাই কৌশলগুলোর তফাত সামান্য, আর একটা প্রশ্নের ব্যবধান নয়েজ। নিজের ডকুমেন্ট দেখে বেছে নিন: ছোট সেকশনে section চাঙ্ক, লম্বা বহু-বিষয়ের সেকশনে প্যারাগ্রাফ, টেবিল ও কোডের জন্য আলাদা নিয়ম, আর কাঠামোহীন টেক্সটের জন্য fixed উইন্ডো শেষ ভরসা।
টেবিল, কোড ও কাঠামোবদ্ধ ডকুমেন্ট সামলান
হেডার ছাড়া টেবিলের ঘর অর্থহীন একটা সংখ্যা, আর একটা লাইন বাদ গেলে কমান্ড চলে না। chunk_structured প্রথমে প্রতিটা সেকশনকে টেক্সট, টেবিল (| দিয়ে শুরু হওয়া লাইন) আর ফেন্সড-কোড ব্লকে ভাগ করে (split_blocks)। তারপর টেক্সট ব্লক ২০০ ক্যারেক্টার পর্যন্ত জোড়া হয়; কোড ব্লক কখনো কাটা হয় না আর লাইন-ব্রেক রাখে; টেবিল কাটা হয় শুধু সারির মাঝখানে এবং প্রতিটা টুকরোয় হেডার আবার বসে। প্রতিটা চাঙ্ক তার সোর্স লাইন মনে রাখে, তাই সাইটেশনে লেখা যায় "Bolt torque, lines 16-18" (StructChunk বিগিনারের Chunk-এ একটা kind যোগ করে)।
s = load_catalog("structure")
for x in s.chunks:
if x.id in ("kc-torque-v1:c1", "kc-torque-v1:c8"):
print(f"{x.id.removeprefix('kc-torque-v1:'):<3}{x.kind:<6}{x.location.label:<24}", repr(x.text[:58]))
flat = next(x for x in cat.chunks if x.doc_id == "kc-torque-v1" and "Stem bolts" in x.text)
print("paragraph chunker:", repr(flat.text[:70]))c1 table Bolt torque, lines 16-18 '| Part | Size M | Size L | Note |\n|---|---|---|---|\n| Stem'
c8 code Check a serial number, lines 37-40 '```bash\nkestrel-recall check --serial RD2-24-00417\nkestrel'
paragraph chunker: '| Part | Size M | Size L | Note | |---|---|---|---| | Stem bolts | 5 |'শেষ লাইনটা একই টেবিলে বিগিনারের প্যারাগ্রাফ চাঙ্কার: সে লাইনগুলো স্পেস দিয়ে জোড়ে, ফলে সারি আর কমান্ড মিশে যায়। ওপরের হিট-হার এই ক্ষতি দেখতে পায় না; সাইটেশন আর মডেল যা পড়ে তা আরও খারাপ। PDF ও ওয়েব পেজের জন্য আগে একটা কনভার্টার লাগে; তার হেডিং ও সারি রাখুন (যেমন Markdown হিসেবে)।
দুই-ধাপের রিট্রিভাল পাইপলাইন বানান
প্রথম ধাপ সস্তা ও চওড়া: অনেক ক্যান্ডিডেট আনে। দ্বিতীয় ধাপ ধীর ও সংকীর্ণ: শুধু সেগুলোকে স্কোর করে আর প্রম্পটের জন্য কয়েকটা রাখে। TwoStage যেকোনো সার্চার আর rerank মেথডওয়ালা যেকোনো অবজেক্ট মুড়ে নেয়, এবং দ্বিতীয় ধাপ কয়টা ক্যান্ডিডেট স্কোর করল তা গোনে (এর খরচ, সময় মেপে নয় গুনে, যাতে আউটপুট পুনরাবৃত্তিযোগ্য থাকে)।
# file: evidencedesk/retrieval/pipeline.py (excerpt)
class Reranker(Protocol):
def rerank(self, query: str, found: RankedList) -> RankedList: ... # same candidates, new scores and order
@dataclass
class TwoStage:
first: Searcher
reranker: Reranker
candidates: int = 20 # how many the first stage fetches
final: int = 3 # how many reach the context
name: str = "two_stage"
scored: int = field(default=0, init=False) # candidates the second stage had to score: its cost, counted not timed
def search(self, query: str, k: int | None = None, allowed: set[str] | None = None) -> RankedList:
broad = self.first.search(query, self.candidates, allowed)
self.scored += len(broad)
return self.reranker.rerank(query, broad)[: k or self.final]OverlapReranker একটা নির্ধারিত স্ট্যান্ড-ইন, যা কোয়েরির কত শতাংশ শব্দ চাঙ্কে আছে সেটা স্কোর করে। এটা ক্রস-এনকোডার নয়; কোয়েরি আর প্যাসেজ একসঙ্গে পড়া মডেল পরের পাতায়। ক্যান্ডিডেটের সংখ্যা বদলে নকশার সীমা দেখা যায়:
from evidencedesk.retrieval.pipeline import OverlapReranker, TwoStage
base = evaluate(hybrid, cat, labels, Scope(), k=3)
print(f"first stage alone: hit@1 {hit_at(base, 1)[0]}, hit@3 {hit_at(base, 3)[0]} of 21")
print("candidates answer among candidates after rerank hit@1 hit@3 chunks scored per question")
for n in (3, 10, 30):
two = TwoStage(hybrid, OverlapReranker(), candidates=n, final=3)
rows = evaluate(two, cat, labels, Scope(), k=3)
pool = hit_at(evaluate(hybrid, cat, labels, Scope(), k=n), n)[0]
print(f"{n:>10}{pool:>20}/21{hit_at(rows, 1)[0]:>19}{hit_at(rows, 3)[0]:>7}{two.scored / len(labels):>16.1f}")first stage alone: hit@1 16, hit@3 19 of 21
candidates answer among candidates after rerank hit@1 hit@3 chunks scored per question
3 19/21 17 19 3.0
10 20/21 18 20 10.0
30 20/21 18 20 21.7দ্বিতীয় ধাপ শুধু প্রথম ধাপের আনা জিনিস নতুন করে সাজাতে পারে, তাই উত্তরটা ক্যান্ডিডেটদের মধ্যে থাকতেই হবে (৩টা নিলে ২১-এর ১৯, ১০টা নিলে ২১-এর ২০)। ১০ ক্যান্ডিডেটে একই হাইব্রিড সার্চ ১ নম্বরে ১৬টা থেকে ১৮টা উত্তরে ওঠে, প্রতি প্রশ্নে ৩টার বদলে ১০টা চাঙ্ক স্কোর করে; ৩০ ক্যান্ডিডেটে এখানে কিছু বাড়ে না। ক্যান্ডিডেট সংখ্যা আর চূড়ান্ত কনটেক্সটের আকার ঠিকভাবে পরের পাতায় মাপা হয়।
অনুশীলনী: তিনটা রিট্রিভাল কৌশলের তুলনা
শুধু-কীওয়ার্ড, শুধু-ডেন্স আর হাইব্রিড, একই চাঙ্ক, ফিল্টার ও লেবেলে। ব্লক আর টেস্টগুলো চালান, তারপর আমারটা পড়ার আগে নিজের সুপারিশ লিখুন।
from evidencedesk.retrieval.report import label_counts
print(f"{'strategy':<9}{'hit@1':>6}{'hit@3':>6}{'hit@5':>6}{'answer outranked':>18}{'no answer in 5':>16}{'junk for unanswerable':>23}")
for s in (sparse, dense, hybrid):
rows = evaluate(s, cat, labels, Scope(), k=5)
c = label_counts(rows)
junk = sum(r.returned > 0 for r in rows if not r.answerable)
print(f"{s.name:<9}{hit_at(rows, 1)[0]:>6}{hit_at(rows, 3)[0]:>6}{hit_at(rows, 5)[0]:>6}"
f"{c[('retrieval', 'irrelevant_match')]:>18}{c[('retrieval', 'missed_evidence')]:>16}{junk:>20}/3")
missed = {s.name: [r.id for r in evaluate(s, cat, labels, Scope(), k=5) if r.answerable and r.rank is None] for s in (sparse, dense, hybrid)}
print("no answer in the top 5:", missed)strategy hit@1 hit@3 hit@5 answer outranked no answer in 5 junk for unanswerable
sparse 15 19 20 5 1 1/3
dense 14 18 19 5 2 3/3
hybrid 16 19 20 4 1 3/3
no answer in the top 5: {'sparse': ['rc08'], 'dense': ['rc08', 'h01'], 'hybrid': ['rc08']}python -m pytest tests/test_retrieval_fusion.py tests/test_retrieval_labels.py tests/test_retrieval_taxonomy.py \
tests/test_retrieval_filters.py tests/test_retrieval_structure.py tests/test_retrieval_pipeline.py 2>&1 | tail -1 | sed 's/ in [0-9.]*s//'40 passedআমার সুপারিশ: ডিফল্ট হিসেবে প্রি-ফিল্টারসহ হাইব্রিড ব্যবহার করুন। হিট@১-এ এটাই সেরা (১৬, বাকিরা ১৫ ও ১৪), আর হিট@৩ ও হিট@৫-এ ভালো একক পদ্ধতির সমান। শীর্ষ ৫-এ এটা শুধু ভাউচারের প্রশ্নটা হারায়, কীওয়ার্ড সার্চের মতোই (ডেন্স টেবিলের সারিটাও হারায়)। কিন্তু এটা স্পষ্ট জয় নয়: কৌশলগুলোর তফাত ২১-এর মধ্যে এক-দুটো প্রশ্ন; হাইব্রিড তবু টেবিলের সারিকে পাঁচ নম্বরে রাখে; আর উত্তরহীন প্রশ্নে হাইব্রিড ও ডেন্স প্রতিবারই চাঙ্ক ফেরত দেয় (৩-এর ৩)। কিছুই ফেরত না দেওয়ার জন্য আলাদা পর্যাপ্ততা-যাচাই লাগে, যা কোনো র্যাংকিং পদ্ধতি দেয় না। এই ২১ প্রশ্নে k বা গভীরতা টিউন করে তারপর সেটাকেই আপনার ফল বলে জানাবেন না। তারপর: আসল এমবেডিং মডেল (এখানে চালানো হয়নি), একটা রিরাংকার, আরও লেবেল-করা প্রশ্ন।
কিটে যোগ হলো evidencedesk/retrieval/ (নয়টা ছোট মডিউল), data/retrieval/ আর tests/test_retrieval_*.py।
কাজের জায়গায় এটা কোথায় দেখা যায়
সাপোর্ট টিম জানায় "বট ভুল বলছে"। অন-কল ইঞ্জিনিয়ার একটা ব্যর্থ প্রশ্নের ট্রেস খোলেন, ধাপটা খুঁজে বের করেন, আর বাগটা ঠিক মালিকের কাছে পাঠান: ভাঙা PDF কনভার্টারের জন্য ইনজেশন, শব্দভান্ডারের ফাঁকের জন্য রিট্রিভাল, থ্রেশহোল্ডের জন্য কনটেক্সট। সপ্তাহে পঞ্চাশটা ব্যর্থতায় লেবেল গুনলে প্যাটার্ন ধরা পড়ে, আর "RAG ভালো করো" টিকিটে পরিণত হয়। লেবেল-করা সেট তারপর প্রতিটা পরিবর্তন পাহারা দেয়, রুব্রিক, রিগ্রেশন ও রিলিজ গেট পাতার স্যুটের মতো।
সাধারণ ভুল
- টেস্ট সেটে টিউন করা। ২১ প্রশ্নে
k, গভীরতা বা থ্রেশহোল্ড বেছে সেই স্কোর উদ্ধৃত করা। কিছু প্রশ্ন আলাদা রাখুন। - র্যাংকিংয়ের পরে ফিল্টার করা। ফল ছোট বা খালি হয়, আর নিষিদ্ধ টেক্সট লগে ও পরের ধাপে ঢুকে যায়।
- RRF স্কোরকে আস্থা ভাবা। এটা শুধু র্যাংকের ওপর নির্ভর করে। গেট লাগলে আসল স্কোর (
parts) রাখুন, আর জেনে রাখুন, উত্তরযোগ্য প্রশ্ন আলাদা করতে এই স্কোর দুর্বল। - চাঙ্ক-আইডি বা পুরো ডকুমেন্টে বাঁধা লেবেল। চাঙ্কার বদলালে চাঙ্ক-আইডি বদলায়; ডকুমেন্ট-স্তরের লেবেল কাটা টেবিল লুকিয়ে ফেলে। প্রমাণ-টেক্সটে লেবেল দিন।
- খেলনা ফলকে আসল বলা। ট্রাইগ্রাম এমবেডার এমবেডিং মডেল নয়। কী চালানো হয়েছে তা বলুন।
নিজে চেষ্টা করুন
- সহজ (এমবেডিং ও সাদৃশ্য আবার দেখে): ভুল বানানের আইডেন্টিফায়ার
"RD2-25"দিয়েSparseSearcherওDenseSearcher-এ সার্চ করুন (Scope()-এর অনুমোদিত আইডি, k = 3)। আগে অনুমান করুন কোনটা রিকল নোটিশ পায়, তারপর চালান। - মাঝারি (RAG-এর নির্ভরযোগ্যতা ও মূল্যায়নের ডেটা আবার দেখে):
labels.jsonlএকটা অস্থায়ী ফাইলে কপি করুন, প্রমাণ-বাক্যাংশ ও কারণসহ "Can you deliver to a post-office box?" প্রশ্নটা যোগ করুন,load_labels(path)দিয়ে লোড করুন, আর ২৫ প্রশ্নে তিন কৌশল চালান। - কঠিন (লোকাল ইনডেক্স ও রিট্রিভালের মান আবার দেখে): এমন একটামাত্র ডেন্স-স্কোর থ্রেশহোল্ড খুঁজুন যা ২১টা উত্তরযোগ্য আর ৩টা উত্তরহীন প্রশ্নকে সবচেয়ে ভালো আলাদা করে (সেরা স্কোর অন্তত t হলে উত্তর দিন)। ২৪টা সিদ্ধান্তের কয়টা ভুল, আর এটা আপনাকে কী বলে?
উত্তর
সহজ। কীওয়ার্ড সার্চ খালি তালিকা ফেরত দেয়: "rd2-25" টোকেন কোনো চাঙ্কে নেই। ভেক্টর সার্চ তিনটা চাঙ্ক ফেরত দেয় আর রিকল নোটিশকে দুই নম্বরে রাখে (কোসাইন ০.২৪), টর্ক চার্টের কোড ব্লকের পেছনে, যেখানে "RD2-24-00417" আছে। পাস চেকলিস্ট: আপনি কীওয়ার্ডের খালি ফল অনুমান করেছেন; দেখেছেন ভেক্টর সার্চ ভুল বানান সামলায় শুধু অক্ষর মিলে, আর সে যে অনিশ্চিত তার কোনো ইঙ্গিত দেয় না।
মাঝারি। প্রমাণ-বাক্যাংশ "We cannot deliver to post-office boxes", kc-shipping-v1#Where we deliver-এ, গ্রেড ২, ট্যাগ direct। পাস চেকলিস্ট: বাক্যাংশ হুবহু কপি করা; load_labels ফাইলটা গ্রহণ করে; আসল labels.jsonl অক্ষত (একটা টেস্ট তার ২৪ সারি পিন করে রাখে)।
কঠিন। Scope()-এর অধীনে প্রতিটা প্রশ্নের সেরা ডেন্স স্কোর নিন এবং ০.২০ থেকে ০.৭৯ পর্যন্ত প্রতিটা থ্রেশহোল্ড চেষ্টা করুন:
top = {x.id: (dense.search(x.question, 1, allowed) or [None])[0] for x in labels}
best = min(((sum((c is not None and c.score >= t) != x.answerable for x in labels for c in [top[x.id]]), t)
for t in [n / 100 for n in range(20, 80)]))
print(f"best single dense threshold: {best[1]:.2f} -> {best[0]} of {len(labels)} wrong answer/abstain decisions")best single dense threshold: 0.28 -> 2 of 24 wrong answer/abstain decisionsসেরা থ্রেশহোল্ডেও দুটো সিদ্ধান্ত ভুল থাকে: স্কুটার প্রশ্নের স্কোর তার ওপরে, "money back"-এর নিচে। আর t বাছা হয়েছে একই ডেটায়, তাই ২৪-এর ২ আশাবাদী। পাস চেকলিস্ট: আপনি আগে স্কোপ লাগিয়েছেন; সব প্রশ্নের জন্য একটাই থ্রেশহোল্ড নিয়েছেন; সিদ্ধান্তে এসেছেন যে শুধু স্কোর-সীমা দিয়ে কখন উত্তর না দেওয়া উচিত তা ঠিক করা যায় না, প্রমাণ দাবিটা সমর্থন করে কি না তার যাচাই লাগে।
সারসংক্ষেপ
- ধাপের ক্রমে ডায়াগনোসিস করুন (ইনজেশন, রিট্রিভাল, কনটেক্সট, জেনারেশন, সাইটেশন), সবচেয়ে আগের ব্যর্থ ধাপ ঠিক করুন, আর প্রতিটা ফাইন্ডিংয়ে পাঁচ এরর-ধরনের একটা লেবেল দিন।
- প্রাসঙ্গিকতা লেবেল করুন প্রমাণ-টেক্সট ও গ্রেড দিয়ে, যাতে একই লেবেল যেকোনো চাঙ্কার, রিট্রিভার বা ফিউশন বিচার করে।
- কীওয়ার্ড ও ভেক্টর সার্চ আলাদা প্রশ্নে ব্যর্থ হয়; RRF শুধু র্যাংক দিয়ে তাদের জোড়ে (k = 60, ২০০৯-এর গবেষণা থেকে), হাতে মিলিয়ে দেখা যায়, আর বিনামূল্যে নয়।
- র্যাংকিংয়ের আগে ফিল্টার করুন; চাঙ্কিংয়ে কাঠামো রাখুন (টেবিলের হেডার, পুরো কোড ব্লক, সোর্স লাইন); দ্বিতীয় ধাপ শুধু প্রথম ধাপের ক্যান্ডিডেট নতুন করে সাজাতে পারে।
- খেলনা এমবেডার আর ২৪টা প্রশ্ন পদ্ধতি দেখায়, প্রোডাকশন-মান নয়।
এরপর: রিরাংকিং ও রিট্রিভাল মূল্যায়ন পাতায় আসবে ক্রস-এনকোডার ধরনের একটা রিরাংকার, গ্রেড-করা লেবেলে recall@k, precision@k, MRR ও nDCG, ক্যান্ডিডেট ও কনটেক্সটের আকার ঠিক করা, ডুপ্লিকেট বাদ, ডেটার সতেজতা আর টেন্যান্টের সীমানা।