Loading the notebook…
Loading the notebook…
Notebooks · In-depth guides · Guide 06
Retrieval-augmented generation answers from your documents. shipit's RAG indexes text and files into chunks, searches them with vectors and keywords together, and gives an agent search tools plus the instructions to…
Run end to end on shipit-agent 3.0 with real model calls (Haiku 5.5 on Anthropic, Gemma 4 31B on Bedrock). Every output below is from that run.
import os
from shipit_agent import Agent
from shipit_agent.llms import build_llm_from_env
from shipit_agent.llms.factory import build_llm_from_settings
os.environ["SHIPIT_ANTHROPIC_MODEL"] = "claude-haiku-5-5"
haiku = build_llm_from_env("anthropic")
gemma = build_llm_from_settings({"model": "google.gemma-4-31b"}, provider="bedrock")Four policy documents for a fictional billing product, written to a kb/ folder so the guide can index
real files. A file's path is its source, so keep paths short and stable.
from pathlib import Path
DOCS = {
"refunds.md": """# Refund policy
Customers can request a full refund within 30 days of a charge. After 30 days, refunds are prorated
by unused days. Refunds go back to the original payment method and take 5 to 10 business days.
Annual plans cancelled after 90 days are not refundable, but stay active until the term ends.""",
"plans.md": """# Plans and pricing
Starter costs $12 per seat per month and includes 3 projects. Team costs $29 per seat per month with
unlimited projects and SSO. Enterprise is priced per contract and adds audit logs, a 99.95% uptime SLA
and a named support engineer. Annual billing saves two months on every plan.""",
"security.md": """# Security
All data is encrypted at rest with AES-256 and in transit with TLS 1.3. Customer data stays in the
region chosen at signup: EU (Frankfurt) or US (Virginia). Access to production needs hardware keys,
and every access is logged and reviewed weekly. We run a third-party penetration test twice a year.""",
"support.md": """# Support
Starter gets email support with a 2 business day response. Team gets chat and email with a 4 hour
response during business hours. Enterprise gets 24/7 phone support with a 1 hour response for
severity-1 incidents. Status updates are posted at status.example.com.""",
}
folder = Path("kb")
folder.mkdir(exist_ok=True)
for name, text in DOCS.items():
(folder / name).write_text(text)
print(sorted(p.name for p in folder.iterdir()))['plans.md', 'refunds.md', 'security.md', 'support.md']
RAG.default(embedder=...) builds an in-memory vector store and an in-memory BM25 keyword store.
HashingEmbedder needs no network or key: it hashes words into a vector, so it's fast and deterministic,
but it matches words rather than meaning. Good for tests and for keyword-heavy text.
from shipit_agent.rag import RAG, HashingEmbedder
rag = RAG.default(embedder=HashingEmbedder(dimension=512))
for path in sorted(folder.glob("*.md")):
chunks = rag.index_file(str(path))
print(f"{path.name:12} {len(chunks)} chunk(s)")
print("chunks in the index:", rag.count())
print("sources:", rag.list_sources())plans.md 1 chunk(s) refunds.md 1 chunk(s) security.md 1 chunk(s) support.md 1 chunk(s) chunks in the index: 4 sources: ['kb/plans.md', 'kb/refunds.md', 'kb/security.md', 'kb/support.md']
Each document is split into chunks, the unit that search returns. A chunk keeps its text, its position in the document, its source and metadata.
chunk = rag.index_text(
"Invoices are emailed on the 1st of each month and can be downloaded as PDF from Settings > Billing.",
source="invoices.md", title="Invoices", metadata={"team": "billing"},
)[0]
print("id: ", chunk.id)
print("document:", chunk.document_id, "| index:", chunk.chunk_index, "| chars:", chunk.start_char, "-", chunk.end_char)
print("source: ", chunk.source, "| metadata:", chunk.metadata)
print("text: ", chunk.text)id: 116c201d910442e483b453c8230a66db::0
document: 116c201d910442e483b453c8230a66db | index: 0 | chars: 0 - 99
source: invoices.md | metadata: {'team': 'billing'}
text: Invoices are emailed on the 1st of each month and can be downloaded as PDF from Settings > Billing.DocumentChunker(target_tokens, overlap_tokens) controls the split. Smaller chunks give more precise
hits; overlap keeps a sentence that straddles a boundary findable from both sides.
from shipit_agent.rag import DocumentChunker, InMemoryBM25Store, InMemoryVectorStore
long_text = " ".join(DOCS.values())
for target in (512, 64, 32):
small = RAG(vector_store=InMemoryVectorStore(), keyword_store=InMemoryBM25Store(),
embedder=HashingEmbedder(dimension=256),
chunker=DocumentChunker(target_tokens=target, overlap_tokens=target // 8))
print(f"target_tokens={target:3} -> {len(small.index_text(long_text, source='all.md'))} chunks")target_tokens=512 -> 1 chunks target_tokens= 64 -> 11 chunks target_tokens= 32 -> 15 chunks
search() returns a RAGContext: the results, best first. Each has the vector similarity and the BM25
keyword score behind it, and a combined score that fuses the two by rank (so it orders results; it
isn't a similarity).
context = rag.search("how long does a refund take", top_k=3)
print(f"{context.total_found} found in {context.timing_ms['total_ms']:.2f} ms", {k: round(v, 3) for k, v in context.timing_ms.items()})
def fmt(score) -> str:
return " - " if score is None else f"{score:.3f}" # None: that search didn't return this chunk
for r in context.results:
print(f"{fmt(r.score)} vec {fmt(r.vector_score)} kw {fmt(r.keyword_score)} {r.chunk.source:12} {r.chunk.text[:60]}…")5 found in 0.65 ms {'embed_ms': 0.037, 'vector_ms': 0.155, 'keyword_ms': 0.043, 'total_ms': 0.649}
0.017 vec 0.190 kw 3.147 kb/refunds.md # Refund policy Customers can request a full refund within 3…
0.008 vec 0.117 kw - kb/support.md # Support Starter gets email support with a 2 business day r…
0.008 vec 0.077 kw - kb/plans.md # Plans and pricing Starter costs $12 per seat per month and…hybrid_alpha blends the two searches: 0 is keywords only, 1 is vectors only, 0.5 (the default)
is both. Keywords win on exact terms like product names and codes.
for alpha in (0.0, 0.5, 1.0):
hits = rag.search("which plan includes SSO", top_k=3, hybrid_alpha=alpha).results
print(f"alpha={alpha}: {[h.chunk.source for h in hits]}")alpha=0.0: ['kb/plans.md', 'kb/refunds.md', 'kb/security.md'] alpha=0.5: ['kb/plans.md', 'kb/refunds.md', 'kb/security.md'] alpha=1.0: ['kb/plans.md', 'kb/refunds.md', 'kb/security.md']
IndexFilters narrows the search to some sources, documents or metadata values before ranking.
from shipit_agent.rag import IndexFilters
for f in (IndexFilters(sources=["kb/support.md"]), IndexFilters(metadata_match={"team": "billing"})):
hits = rag.search("response time", top_k=3, filters=f).results
print([h.chunk.source for h in hits])['kb/support.md'] ['invoices.md']
A hit can come back with the chunks around it (chunks_above, chunks_below), so the model sees the
passage in context. Here the documents are split small to show it.
fine = RAG(vector_store=InMemoryVectorStore(), keyword_store=InMemoryBM25Store(),
embedder=HashingEmbedder(dimension=256), chunker=DocumentChunker(target_tokens=24, overlap_tokens=0))
fine.index_text(DOCS["security.md"], source="security.md")
hit = fine.search("penetration test", top_k=1, chunks_above=1).results[0]
print("expanded above:", len(hit.expanded_above))
for c in hit.expanded_above:
print(" above:", c.text[:70])
print(" hit: ", hit.chunk.text[:70])expanded above: 1 above: Access to production needs hardware keys, and every access is logged a hit: We run a third-party penetration test twice a year.
Search results carry chunk ids; fetch_chunk() reads one back, optionally with its neighbours. The agent's
rag_fetch_chunk tool uses this to read more around a hit.
first = rag.search("encryption", top_k=1).results[0].chunk
again = rag.fetch_chunk(first.id)
print(again.id == first.id, "|", again.source, "|", again.text[:80])True | kb/plans.md | # Plans and pricing Starter costs $12 per seat per month and includes 3 projects
to_prompt_context() formats the results as numbered passages, ready to paste into a prompt when you call a
model yourself.
print(rag.search("uptime SLA", top_k=2).to_prompt_context()[:600])[1] source=kb/plans.md | chunk_id=kb_plans.md::0
# Plans and pricing Starter costs $12 per seat per month and includes 3 projects. Team costs $29 per seat per month with unlimited projects and SSO. Enterprise is priced per contract and adds audit logs, a 99.95% uptime SLA and a named support engineer. Annual billing saves two months on every plan.
[2] source=kb/refunds.md | chunk_id=kb_refunds.md::0
# Refund policy Customers can request a full refund within 30 days of a charge. After 30 days, refunds are prorated by unused days. Refunds go back to the original payment method and take 5CallableEmbedder wraps any function that turns texts into vectors. Here, Titan Text Embeddings v2 through
boto3. Real embeddings match meaning, not just words.
import json
import boto3
bedrock = boto3.client("bedrock-runtime", region_name=os.getenv("AWS_REGION_NAME", "us-east-1"))
def titan(texts: list[str]) -> list[list[float]]:
vectors = []
for text in texts:
body = json.dumps({"inputText": text, "dimensions": 256})
response = bedrock.invoke_model(modelId="amazon.titan-embed-text-v2:0", body=body)
vectors.append(json.loads(response["body"].read())["embedding"])
return vectors
from shipit_agent.rag import CallableEmbedder
semantic = RAG.default(embedder=CallableEmbedder(titan, dimension=256))
for path in sorted(folder.glob("*.md")):
semantic.index_file(str(path))
print("indexed", semantic.count(), "chunks with Titan")indexed 4 chunks with Titan
The question below shares almost no words with the answer ("money back" vs "refund"). Vector-only search
(hybrid_alpha=1) with each embedder, ranked by vector similarity. On four short documents both put the refund
policy first, helped by shared words like "after"; Titan scores it twice as high, because it compares
meaning. The gap matters as a corpus grows and wording drifts.
question = "can I get my money back if I stop using it after a few weeks?"
for name, index in (("hashing", rag), ("titan", semantic)):
hits = index.search(question, top_k=4, hybrid_alpha=1.0).results
print(f"{name:8}", [f"{h.chunk.source.removeprefix('kb/')} {fmt(h.vector_score)}" for h in hits])hashing ['refunds.md 0.135', 'plans.md 0.092', 'support.md 0.070', 'invoices.md 0.046']
titan ['refunds.md 0.268', 'security.md 0.153', 'plans.md 0.086', 'support.md 0.079']
A reranker reads the top candidates and reorders them by how well they answer the question. LLMReranker
uses any model; pass it to RAG.default(reranker=...) and search with enable_reranking=True.
from shipit_agent.rag import LLMReranker
reranked = RAG.default(embedder=CallableEmbedder(titan, dimension=256), reranker=LLMReranker(llm=haiku))
for path in sorted(folder.glob("*.md")):
reranked.index_file(str(path))
q = "which plan gets help fastest when production is down?"
for flag in (False, True):
hits = reranked.search(q, top_k=3, enable_reranking=flag).results
print("reranked" if flag else "original", [h.chunk.source for h in hits],
[fmt(h.rerank_score) for h in hits] if flag else "")original ['kb/support.md', 'kb/security.md', 'kb/plans.md']
reranked ['kb/support.md', 'kb/plans.md', 'kb/security.md'] ['1.000', '0.300', '0.000']
reindex(document_id, content=...) replaces a document's chunks; delete_document() removes them.
doc_id = rag.search("Starter costs", top_k=1).results[0].chunk.document_id
rag.reindex(doc_id, content="# Plans and pricing\nStarter now costs $15 per seat per month.")
print(rag.search("Starter price per seat", top_k=1).results[0].chunk.text)
before = rag.count()
rag.delete_document(doc_id)
print("chunks:", before, "->", rag.count())
rag.index_file(str(folder / "plans.md"))# Plans and pricing Starter now costs $15 per seat per month. chunks: 5 -> 4
[Chunk(id='kb_plans.md::0', document_id='kb_plans.md', chunk_index=0, text='# Plans and pricing Starter costs $12 per seat per month and includes 3 projects. Team costs $29 per seat per month with unlimited projects and SSO. Enterprise is priced per contract and adds audit logs, a 99.95% uptime SLA and a named support engineer. Annual billing saves two months on every plan.', text_for_embedding='plans.md\n# Plans and pricing Starter costs $12 per seat per month and includes 3 projects. Team costs $29 per seat per month with unlimited projects and SSO. Enterprise is priced per contract and adds… (3,502 more characters)
Agent(rag=rag) adds three tools (rag_search, rag_fetch_chunk, rag_list_sources) and a prompt
section telling the model to search before answering and to cite passages as [1], [2].
agent = Agent(llm=haiku, rag=semantic)
print("tools:", [t.name for t in agent.tools])
result = agent.run("If I cancel a monthly Team plan after 45 days, what do I get back, and how long does it take?")
print(result.output)tools: ['rag_search', 'rag_fetch_chunk', 'rag_list_sources']
**Short answer:** You get a prorated refund for the unused days, not a full refund, and it takes 5 to 10 business days to reach your original payment method. - **Full refund window:** Only within 30 days of a charge. At 45 days you are past it. [1] - **After 30 days:** Refunds are prorated by unused days. [1] - **Timing:** Refunds go back to the original payment method and take 5 to 10 business days. [1] - **Price reference:** Team is $29 per seat per month. [4] I can't compute the exact amount. The knowledge base doesn't say how "unused days" is calculated for a monthly plan, or which billing cycle is prorated when you cancel on day 45 (you'd be in your second monthly period by then). Check with billing or the full terms for the exact figure. Only one refunds document was returned, so there may be more detail elsewhere.
result.rag_sources lists the passages the answer drew on, numbered the way the citations are.
for s in result.rag_sources:
print(f"[{s.index}] {s.source:12} score {s.score:.3f} {s.text[:70]}…")[1] kb/refunds.md score 0.017 # Refund policy Customers can request a full refund within 30 days of … [2] kb/support.md score 0.008 # Support Starter gets email support with a 2 business day response. T… [3] kb/security.md score 0.008 # Security All data is encrypted at rest with AES-256 and in transit w… [4] kb/plans.md score 0.008 # Plans and pricing Starter costs $12 per seat per month and includes …
Streaming shows each search the agent makes and what came back.
for e in Agent(llm=haiku, rag=semantic).stream("Where is EU customer data stored, and how is it protected?"):
if e.type == "tool_called":
print("search:", e.payload["tool"], e.payload["arguments"])
elif e.type == "tool_completed":
print(" ->", e.payload["output"][:110].replace("\n", " "), "…")
elif e.type == "run_completed":
print("\n" + e.payload["output"])search: rag_search {'query': 'where is EU customer data stored and how is it protected', 'top_k': 8} -> { "query": "where is EU customer data stored and how is it protected", "total_found": 4, "results": [ …
search: rag_list_sources {}
-> { "sources": [ "kb/plans.md", "kb/refunds.md", "kb/security.md", "kb/support.md" ] } …search: rag_fetch_chunk {'chunk_id': 'kb_security.md::0', 'chunks_below': 2}
-> { "chunk_id": "kb_security.md::0", "document_id": "kb_security.md", "source": "kb/security.md", "text" …EU customer data is stored in the EU (Frankfurt) region, but only if the customer chose that region at signup. The alternative is US (Virginia). Data stays in the chosen region [1]. **Protection, for all customer data:** - Encrypted at rest with AES-256 [1]. - Encrypted in transit with TLS 1.3 [1]. - Production access requires hardware keys [1]. - Every production access is logged and reviewed weekly [1]. - A third-party penetration test runs twice a year [1]. **Gaps:** The knowledge base has only one security document, which is a short summary. It doesn't cover backups, replicas, subprocessors, or GDPR-specific terms, so it can't confirm whether copies of EU data exist outside Frankfurt. Check the full security documentation or the DPA for those details. [1] kb/security.md
A good RAG agent says so instead of inventing one.
print(Agent(llm=haiku, rag=semantic).run("Do you offer a student discount?").output)The knowledge base doesn't say whether there is a student discount. Two searches ("student discount" and "education or academic pricing for students and teachers") returned no mention of one.
The closest information is the pricing page: Starter is $12 per seat per month, Team is $29 per seat per month, Enterprise is priced per contract, and annual billing saves two months on every plan [3]. That is the only pricing information I found.
To confirm, check with the sales or billing team, since the knowledge base may not cover promotions.One index can serve many agents. Gemma 4 31B on Bedrock, same question as section 16:
result = Agent(llm=gemma, rag=semantic).run(
"If I cancel a monthly Team plan after 45 days, what do I get back, and how long does it take?")
print(result.output)
print("sources:", [s.source for s in result.rag_sources])If you cancel a monthly Team plan after 45 days, you will receive a prorated refund for the unused days of the billing cycle [1]. The refund will be credited back to your original payment method and typically takes 5 to 10 business days to process [1]. sources: ['kb/refunds.md', 'kb/plans.md', 'kb/support.md', 'kb/security.md']
To control the instructions yourself, attach the tools with rag.as_tools() and add rag.prompt_section()
(or your own words) to the prompt.
custom = Agent(llm=haiku, tools=semantic.as_tools(),
prompt="Answer from the knowledge base in one sentence, then cite like [1].")
print([t.name for t in custom.tools])
print(custom.run("What does Enterprise add over Team?").output)['rag_search', 'rag_fetch_chunk', 'rag_list_sources']
Enterprise adds audit logs, a 99.95% uptime SLA, and a named support engineer over Team, plus 24/7 phone support with a 1-hour response for severity-1 incidents instead of Team's 4-hour business-hours response [1][2].
import shutil
shutil.rmtree(folder)RAG.default(embedder=...) gives hybrid vector + BM25 search in memory; index_text / index_file fill it.top_k, hybrid_alpha, IndexFilters, neighbouring chunks and a reranker.HashingEmbedder is offline and word-based; wrap a real embedder with CallableEmbedder for meaning.Agent(rag=rag) searches, cites as [N], and returns the passages in result.rag_sources.Next: Guide 07 — sessions and memory.