RAG Solutions


Knowledge Check Answers

  1. RAG adds relevant documents to the prompt at query time — no training needed, easy to update. Fine-tuning retrains the model on your data — expensive, slow, but changes model behavior and tone.

  2. Chunking keeps each embedding focused on a single topic. A long document about "Tutoring, Financial Aid, and Counseling" would produce a muddy embedding. Chunking splits it so "Tutoring" gets its own vector, "Financial Aid" gets its own, etc.

  3. Cosine similarity measures the angle between two vectors. 1.0 = same direction (similar meaning), 0.0 = unrelated, -1.0 = opposite.

  4. 384 dimensions is a balance between accuracy and speed. More dimensions capture more nuance but use more storage and compute. 384 works well for sentence-level search.

  5. The AI falls back to its general knowledge. The RAG system still works — it just won't have relevant context. The prompt instructs the AI to say if the context doesn't help.


Challenge 1 Solution

# Switch to all-mpnet-base-v2 (768 dimensions)
MODEL_NAME = "all-mpnet-base-v2"
embedder = SentenceTransformer(MODEL_NAME)

# Update Document model
from pgvector.sqlalchemy import Vector

class Document(db.Model):
    ...
    embedding = db.Column(Vector(768), nullable=False)

# Re-index
db.drop_all()
db.create_all()
index_documents(db, Document)

Challenge 2 Solution

from sqlalchemy import text


def hybrid_search(db, query, table, top_k=5):
    query_vec = embed_text(query)

    # Vector search
    vector_results = (
        db.session.query(table, (1 - table.embedding.cosine_distance(query_vec)).label("score"))
        .order_by(table.embedding.cosine_distance(query_vec))
        .limit(top_k * 2)
        .all()
    )

    # Keyword search
    sql = text("""
        SELECT *, ts_rank(to_tsvector('english', content),
                 plainto_tsquery('english', :query)) as score
        FROM document
        WHERE to_tsvector('english', content) @@ plainto_tsquery('english', :query)
        ORDER BY score DESC
        LIMIT :limit
    """)
    kw_results = db.session.execute(sql, {"query": query, "limit": top_k * 2}).fetchall()

    # Merge with weights
    seen = set()
    merged = []
    for r in vector_results:
        seen.add(r[0].id)
        merged.append((r[0], r.score * 0.7))

    for r in kw_results:
        weight = r.score * 0.3 if r.id not in seen else 0
        merged.append((r, weight))

    merged.sort(key=lambda x: x[1], reverse=True)
    return merged[:top_k]

Challenge 3 Solution

from sentence_transformers import CrossEncoder

reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")


def search_with_rerank(db, query, table, top_k=20, rerank_k=5):
    results = search_similar_chunks(db, query, table, top_k=top_k)
    pairs = [(query, r["content"]) for r in results]
    scores = reranker.predict(pairs)
    for r, s in zip(results, scores):
        r["rerank_score"] = float(s)
    results.sort(key=lambda r: r["rerank_score"], reverse=True)
    return results[:rerank_k]

Challenge 4 Solution

import requests
from bs4 import BeautifulSoup

def scrape_clark_news():
    url = "https://www.clark.edu/news-and-media/24-7/"
    response = requests.get(url)
    soup = BeautifulSoup(response.text, "html.parser")

    articles = []
    for item in soup.select(".story-item"):
        title_el = item.select_one("h3 a")
        if not title_el:
            continue
        title = title_el.get_text(strip=True)
        url_path = title_el.get("href", "")
        excerpt_el = item.select_one(".description")
        excerpt = excerpt_el.get_text(strip=True) if excerpt_el else ""

        articles.append({
            "title": title,
            "content": excerpt,
            "source": f"https://www.clark.edu{url_path}",
            "category": "news",
        })

    return articles

Challenge 5 Solution

Modify the /api/rag-chat endpoint to include conversation history:

@api.route("/rag-chat", methods=["POST"])
def rag_chat():
    data = request.get_json()
    user_message = data.get("message", "")

    if not user_message:
        return jsonify({"error": "Message is required"}), 400

    user_msg = Message(content=user_message, sender="user")
    db.session.add(user_msg)
    db.session.commit()

    try:
        results = search_similar_chunks(db, user_message, Document, top_k=5)
        context = build_rag_context(results)

        recent = Message.query.order_by(Message.timestamp.desc()).limit(6).all()
        recent.reverse()

        messages = [{"role": "system", "content": "You are a helpful assistant for Clark College. Use the context to answer."}]
        if context:
            messages.append({"role": "system", "content": f"Context:\n{context}"})
        for msg in recent[:-1]:
            role = "user" if msg.sender == "user" else "assistant"
            messages.append({"role": role, "content": msg.content})
        messages.append({"role": "user", "content": user_message})

        response = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=messages,
        )
        ai_reply = response.choices[0].message.content

        ai_msg = Message(content=ai_reply, sender="ai")
        db.session.add(ai_msg)
        db.session.commit()

        return jsonify({
            "user_message": user_msg.to_dict(),
            "ai_message": ai_msg.to_dict(),
            "evidence": [{"title": r["title"], "similarity": round(r["similarity"], 3)} for r in results],
        })

    except Exception as e:
        return jsonify({"error": str(e)}), 500