Files
2026-07-13 12:09:03 +08:00

344 lines
13 KiB
Python

import math
from collections import Counter
def chunk_text(text, chunk_size=200, overlap=50):
words = text.split()
chunks = []
start = 0
while start < len(words):
end = start + chunk_size
chunk = " ".join(words[start:end])
chunks.append(chunk)
start += chunk_size - overlap
return chunks
def build_vocabulary(documents):
vocab = set()
for doc in documents:
vocab.update(doc.lower().split())
return sorted(vocab)
def compute_tf(text, vocab):
words = text.lower().split()
count = Counter(words)
total = len(words)
if total == 0:
return [0.0] * len(vocab)
return [count.get(word, 0) / total for word in vocab]
def compute_idf(documents, vocab):
n = len(documents)
idf = []
for word in vocab:
doc_count = sum(1 for doc in documents if word in doc.lower().split())
idf.append(math.log((n + 1) / (doc_count + 1)) + 1)
return idf
def tfidf_embed(text, vocab, idf):
tf = compute_tf(text, vocab)
return [t * i for t, i in zip(tf, idf)]
def cosine_similarity(a, b):
dot_product = sum(x * y for x, y in zip(a, b))
norm_a = math.sqrt(sum(x * x for x in a))
norm_b = math.sqrt(sum(x * x for x in b))
if norm_a == 0 or norm_b == 0:
return 0.0
return dot_product / (norm_a * norm_b)
def search(query_embedding, stored_embeddings, top_k=5):
scores = []
for i, emb in enumerate(stored_embeddings):
sim = cosine_similarity(query_embedding, emb)
scores.append((i, sim))
scores.sort(key=lambda x: x[1], reverse=True)
return scores[:top_k]
def build_rag_prompt(query, retrieved_chunks):
context = "\n\n---\n\n".join(
f"[Source {i+1}]\n{chunk}"
for i, chunk in enumerate(retrieved_chunks)
)
return (
"Answer the question based ONLY on the following context.\n"
"If the context doesn't contain enough information, "
"say \"I don't have enough information to answer that.\"\n\n"
f"Context:\n{context}\n\n"
f"Question: {query}\n\n"
"Answer:"
)
def simple_generate(prompt, retrieved_chunks):
query_section = prompt.lower().split("question:")[-1]
query_words = set(query_section.split())
stop_words = {"the", "a", "an", "is", "are", "was", "were", "what", "how",
"why", "when", "where", "do", "does", "for", "of", "in", "to",
"and", "or", "on", "at", "by", "it", "its", "this", "that"}
query_words = query_words - stop_words
best_sentence = ""
best_score = 0
for chunk in retrieved_chunks:
for sentence in chunk.split("."):
sentence = sentence.strip()
if len(sentence) < 10:
continue
words = set(sentence.lower().split())
overlap = len(query_words & words)
if overlap > best_score:
best_score = overlap
best_sentence = sentence
return best_sentence if best_sentence else "I don't have enough information."
class RAGPipeline:
def __init__(self, chunk_size=200, overlap=50, top_k=5):
self.chunk_size = chunk_size
self.overlap = overlap
self.top_k = top_k
self.chunks = []
self.embeddings = []
self.vocab = []
self.idf = []
self.sources = []
def index(self, documents, source_names=None):
all_chunks = []
all_sources = []
for i, doc in enumerate(documents):
doc_chunks = chunk_text(doc, self.chunk_size, self.overlap)
all_chunks.extend(doc_chunks)
name = source_names[i] if source_names else f"doc_{i}"
all_sources.extend([name] * len(doc_chunks))
self.chunks = all_chunks
self.sources = all_sources
self.vocab = build_vocabulary(all_chunks)
self.idf = compute_idf(all_chunks, self.vocab)
self.embeddings = [
tfidf_embed(chunk, self.vocab, self.idf)
for chunk in all_chunks
]
return len(all_chunks)
def query(self, question, top_k=None):
k = top_k or self.top_k
query_emb = tfidf_embed(question, self.vocab, self.idf)
results = search(query_emb, self.embeddings, k)
retrieved = []
for idx, score in results:
retrieved.append({
"chunk": self.chunks[idx],
"source": self.sources[idx],
"score": score,
"index": idx
})
chunk_texts = [r["chunk"] for r in retrieved]
prompt = build_rag_prompt(question, chunk_texts)
answer = simple_generate(prompt, chunk_texts)
return {
"question": question,
"answer": answer,
"prompt": prompt,
"retrieved": retrieved
}
SAMPLE_DOCUMENTS = [
"""Acme Corp Refund Policy.
All standard plan customers are eligible for a full refund within 30 days of purchase.
Enterprise plan customers receive an extended 60-day refund window with pro-rated refunds
calculated from the date of cancellation. Refunds are processed within 5-7 business days
and returned to the original payment method. No refunds are available after the refund
window closes. Customers must submit refund requests through the support portal or by
contacting their account manager directly. Annual subscriptions that are cancelled mid-term
will receive a pro-rated credit for the remaining months.""",
"""Acme Corp Product Overview.
Acme Corp offers three product tiers: Starter, Professional, and Enterprise.
The Starter plan includes basic features for individual users at $29 per month.
The Professional plan adds team collaboration, advanced analytics, and priority
support for $99 per month per user. The Enterprise plan includes everything in
Professional plus custom integrations, dedicated account management, SSO,
audit logs, and a 99.99% uptime SLA. Enterprise pricing is custom and starts
at $500 per month for up to 50 users. All plans include a 14-day free trial
with no credit card required.""",
"""Acme Corp Security Practices.
Acme Corp maintains SOC 2 Type II compliance and undergoes annual third-party
security audits. All data is encrypted at rest using AES-256 and in transit
using TLS 1.3. Customer data is stored in isolated tenants within AWS
us-east-1 and eu-west-1 regions. Data residency can be configured per
organization for Enterprise customers. Backups are performed every 6 hours
with 30-day retention. Acme Corp does not sell or share customer data with
third parties. Enterprise customers can request data deletion within 24 hours.
Bug bounty program available through HackerOne.""",
"""Acme Corp API Documentation.
The Acme API uses REST with JSON request and response bodies. Authentication
is via Bearer tokens issued through OAuth 2.0. Rate limits are 100 requests
per minute for Starter, 1000 for Professional, and 10000 for Enterprise.
Rate limit headers are included in every response: X-RateLimit-Limit,
X-RateLimit-Remaining, and X-RateLimit-Reset. Exceeding the rate limit
returns HTTP 429 with a Retry-After header. The API supports pagination
via cursor-based pagination using the next_cursor field. Webhooks are
available for real-time event notifications on Professional and Enterprise
plans. API versioning uses date-based versions in the URL path.""",
"""Acme Corp Uptime and Reliability.
Acme Corp guarantees 99.9% uptime for Professional plans and 99.99% uptime
for Enterprise plans. Uptime is calculated monthly excluding scheduled
maintenance windows which are announced 72 hours in advance. If uptime
falls below the guaranteed level, customers receive service credits:
10% credit for each 0.1% below the SLA threshold, up to a maximum of
30% of the monthly fee. Service credits must be requested within 30 days
of the incident. Status page updates are posted at status.acme.com
within 5 minutes of any detected incident. Post-incident reports are
published within 48 hours for any outage exceeding 15 minutes."""
]
if __name__ == "__main__":
print("=" * 60)
print("STEP 1: Document Chunking")
print("=" * 60)
sample = SAMPLE_DOCUMENTS[0]
chunks = chunk_text(sample, chunk_size=30, overlap=10)
print(f" Document length: {len(sample.split())} words")
print(f" Chunk size: 30 words, overlap: 10 words")
print(f" Number of chunks: {len(chunks)}")
for i, chunk in enumerate(chunks):
print(f"\n Chunk {i}: ({len(chunk.split())} words)")
print(f" {chunk[:100]}...")
print("\n" + "=" * 60)
print("STEP 2: TF-IDF Embedding")
print("=" * 60)
mini_docs = [
"The cat sat on the mat",
"The dog sat on the rug",
"Machine learning is a branch of artificial intelligence"
]
vocab = build_vocabulary(mini_docs)
idf = compute_idf(mini_docs, vocab)
print(f" Vocabulary size: {len(vocab)}")
print(f" Sample words and IDF scores:")
for word, score in sorted(zip(vocab, idf), key=lambda x: x[1], reverse=True)[:8]:
print(f" {word:20s} IDF={score:.3f}")
emb1 = tfidf_embed(mini_docs[0], vocab, idf)
emb2 = tfidf_embed(mini_docs[1], vocab, idf)
emb3 = tfidf_embed(mini_docs[2], vocab, idf)
print(f"\n Embedding dimensions: {len(emb1)}")
print(f" Non-zero entries in 'cat sat on mat': {sum(1 for v in emb1 if v > 0)}")
print(f" Non-zero entries in 'dog sat on rug': {sum(1 for v in emb2 if v > 0)}")
print(f" Non-zero entries in 'machine learning': {sum(1 for v in emb3 if v > 0)}")
print("\n" + "=" * 60)
print("STEP 3: Cosine Similarity")
print("=" * 60)
sim_12 = cosine_similarity(emb1, emb2)
sim_13 = cosine_similarity(emb1, emb3)
sim_23 = cosine_similarity(emb2, emb3)
print(f" 'cat on mat' vs 'dog on rug': {sim_12:.4f} (similar structure)")
print(f" 'cat on mat' vs 'machine learning': {sim_13:.4f} (unrelated)")
print(f" 'dog on rug' vs 'machine learning': {sim_23:.4f} (unrelated)")
print(f"\n As expected: similar sentences score higher.")
print("\n" + "=" * 60)
print("STEP 4: Full RAG Pipeline")
print("=" * 60)
rag = RAGPipeline(chunk_size=50, overlap=10, top_k=3)
source_names = [
"refund-policy.md",
"product-overview.md",
"security.md",
"api-docs.md",
"uptime-sla.md"
]
num_chunks = rag.index(SAMPLE_DOCUMENTS, source_names)
print(f" Indexed {len(SAMPLE_DOCUMENTS)} documents into {num_chunks} chunks")
print(f" Vocabulary size: {len(rag.vocab)} terms")
queries = [
"What is the refund policy for enterprise customers?",
"What are the API rate limits?",
"How is customer data encrypted?",
"What happens if uptime falls below the SLA?",
"How much does the Professional plan cost?"
]
for query in queries:
print(f"\n Query: {query}")
result = rag.query(query, top_k=3)
print(f" Answer: {result['answer']}")
print(f" Retrieved {len(result['retrieved'])} chunks:")
for r in result["retrieved"]:
preview = r["chunk"][:80].replace("\n", " ")
print(f" [{r['source']}] score={r['score']:.4f} | {preview}...")
print("\n" + "=" * 60)
print("STEP 5: Chunk Size Comparison")
print("=" * 60)
test_query = "What is the refund policy for enterprise customers?"
for chunk_size in [20, 50, 100, 200]:
rag_test = RAGPipeline(chunk_size=chunk_size, overlap=max(5, chunk_size // 5))
n = rag_test.index(SAMPLE_DOCUMENTS)
result = rag_test.query(test_query, top_k=3)
top_score = result["retrieved"][0]["score"] if result["retrieved"] else 0
print(f" chunk_size={chunk_size:>3d}: {n:>3d} chunks, "
f"top_score={top_score:.4f}, "
f"answer_len={len(result['answer'])}")
print("\n" + "=" * 60)
print("STEP 6: Prompt Inspection")
print("=" * 60)
result = rag.query("What encryption does Acme use?", top_k=2)
prompt_lines = result["prompt"].split("\n")
print(f" Prompt length: {len(result['prompt'])} chars")
print(f" Prompt lines: {len(prompt_lines)}")
print(f"\n First 5 lines of generated prompt:")
for line in prompt_lines[:5]:
print(f" {line}")
print(f" ...")
print(f" Last 3 lines of generated prompt:")
for line in prompt_lines[-3:]:
print(f" {line}")
print("\n" + "=" * 60)
print("SUMMARY")
print("=" * 60)
print(" RAG pipeline: Query -> Embed -> Search -> Augment -> Generate")
print(f" Documents indexed: {len(SAMPLE_DOCUMENTS)}")
print(f" Total chunks: {num_chunks}")
print(f" Vocabulary size: {len(rag.vocab)}")
print(f" Embedding dimensions: {len(rag.vocab)}")
print(" Similarity metric: cosine similarity")
print(" Embedding method: TF-IDF")
print("\n In production, replace TF-IDF with neural embeddings")
print(" (text-embedding-3-small) and the simple generator with")
print(" an actual LLM API call. The pipeline stays the same.")