Skip to content

Capture retrieved passages

Use this optional reference when a retrieval comparison needs the exact chunks, source IDs and tenant ownership evidence. Start with the verification checklist to set the target and evidence directory; this calls live services and writes Langfuse evaluation runs. Keep raw results gitignored.

The standard evaluator scores relevance but does not export all passages. When comparing missing or duplicated retrieval evidence, save this temporary wrapper as $KB_VERIFY_EVIDENCE/capture_retrieval.py:

import dataclasses
import json
import os
from pathlib import Path

from cli.cli_eval import main
from ixevaluation.rewriter.evaluator import LangfuseDataset, QueryRewriterEvaluator
from ixrag.lightrag.langgraph_retriever import LightRAGRetriever

folder = Path(os.environ["KB_VERIFY_EVIDENCE"])
output = folder / os.environ["KB_VERIFY_RESULT"]
domain = os.environ["KB_VERIFY_DOMAIN"]
original_fetch = LangfuseDataset.fetch
original_run = QueryRewriterEvaluator.run


def fetch_tenant(self):
    items = [item for item in original_fetch(self)
             if (item.metadata or {}).get("site_name") == domain]
    assert items, f"No dataset items for {domain}"
    snapshot = [{"id": item.id, "input": item.input,
                 "expected_output": item.expected_output, "metadata": item.metadata}
                for item in items]
    snapshot.sort(key=lambda item: item["id"] or "")
    path = folder / "retrieval-dataset.json"
    if path.exists():
        assert json.loads(path.read_text()) == snapshot, "Comparison dataset changed"
    else:
        path.write_text(json.dumps(snapshot, indent=2, ensure_ascii=False) + "\n")
    return items


async def record_run(self):
    original_invoke = LightRAGRetriever.ainvoke
    passages = []

    async def capture(retriever, query, *args, **kwargs):
        documents = await original_invoke(retriever, query, *args, **kwargs)
        passages.append({"retrieval_input": query, "retrieval_kwargs": kwargs,
                         "documents": [{"page_content": doc.page_content,
                                        "metadata": doc.metadata} for doc in documents]})
        return documents

    LightRAGRetriever.ainvoke = capture
    try:
        result = await original_run(self)
        output.write_text(json.dumps(dataclasses.asdict(result), indent=2,
                                     ensure_ascii=False) + "\n")
        return result
    finally:
        LightRAGRetriever.ainvoke = original_invoke
        output.with_name(output.stem + "-passages.json").write_text(
            json.dumps(passages, indent=2, ensure_ascii=False, default=str) + "\n")


LangfuseDataset.fetch = fetch_tenant
QueryRewriterEvaluator.run = record_run
raise SystemExit(main())

From backend/, with the exported variables from the verification checklist, source the CLI secrets helper, then run:

source scripts/load-dev-secrets.sh
export KB_VERIFY_PHASE='test-before'
export KB_VERIFY_ENV='test'
git rev-parse HEAD > "$KB_VERIFY_EVIDENCE/$KB_VERIFY_PHASE-commit.txt"
for kb_trial in 1 2; do
  KB_VERIFY_RESULT="$KB_VERIFY_PHASE-$kb_trial.json" \
    .venv/bin/python "$KB_VERIFY_EVIDENCE/capture_retrieval.py" \
    query-rewriter query-rewriter-v1 --arm production --rag-mode mix \
    --env "$KB_VERIFY_ENV" --concurrency 1 --json \
    > "$KB_VERIFY_EVIDENCE/$KB_VERIFY_PHASE-$kb_trial.log" 2>&1
  kb_result=$?
  printf '%s\n' "$kb_result" > "$KB_VERIFY_EVIDENCE/$KB_VERIFY_PHASE-$kb_trial.exit"
done

For post-ingestion or local-fix trials, change the phase to a distinct name such as test-after or test-identity-fixed. For an approved production comparison, set both phase and environment to production. Never overwrite baseline files; the wrapper asserts dataset equality across phases. Use naive instead of mix when testing a lite tenant, consistently in all comparison arms. Inspect every trial even if the evaluator exits nonzero: separate a relevance-floor failure from a runtime failure, and report both rather than silently treating either as success. If interrupted before evaluation completes, partial passages do not constitute a completed trial.

For each question, compare scores, latency and the actual ordered passage text, logical chunk IDs, source paths and parent documents. Count unique content hashes as well as unique IDs: chunk-X and tenant:chunk-X can be two identities for the same evidence. Save representative passages and judge reasons in the private report. When identities disagree, trace both vector and graph paths at the merge boundary; verify the adapter contract with the focused unit regression above.

Summarize exact-content uniqueness from the saved passages (retain source IDs and texts for manual comparison; repeated text across different queries is normal):

.venv/bin/python - <<'PY'
import hashlib
import json
import os
from pathlib import Path

for path in sorted(Path(os.environ["KB_VERIFY_EVIDENCE"]).glob("*-passages.json")):
    for call in json.loads(path.read_text()):
        docs = call["documents"]
        hashes = {hashlib.sha256(doc["page_content"].encode()).hexdigest()
                  for doc in docs}
        print(path.name, repr(call["retrieval_input"]),
              f"{len(hashes)} unique bodies / {len(docs)} returned")
PY

Check suspected tenant leakage against stored rows using the existing diagnostic collector and scoped read queries: Mongo composite _id, explicit tenantId, content hash, source path and owner document must agree, and graph nodes/edges must have the expected tenant filter. A logical result ID without a tenant prefix is intentional; it does not alone prove a missing filter or cross-tenant read. Scope the conclusion to the records checked. Do not strip storage prefixes or change database rows to make a comparison look clean.