Skip to content

Draft: feat: add multi-source RAG crawler demo#327

Open
amastbau wants to merge 6 commits into
ogx-ai:mainfrom
amastbau:feat/multi-source-rag-crawler
Open

Draft: feat: add multi-source RAG crawler demo#327
amastbau wants to merge 6 commits into
ogx-ai:mainfrom
amastbau:feat/multi-source-rag-crawler

Conversation

@amastbau

@amastbau amastbau commented Mar 27, 2026

Copy link
Copy Markdown

Summary

  • Adds demos/03_rag/06_multi_source_rag_crawler.py -- a new demo showing how to build a cross-source document crawler that follows links between platforms (GitHub, web pages) and indexes all discovered documents into a Llama Stack vector store for RAG queries
  • Demonstrates breadth-first crawling with configurable depth/document limits, content deduplication via SHA-256 hashing, crawl provenance tracking, and querying across the automatically-discovered knowledge graph
  • Uses the standard demo conventions: fire.Fire() CLI, shared utils, LlamaStackClient, Responses API with file_search, and try/finally cleanup

Motivation

Existing RAG demos (01-05) index documents from static/inline sources. In real-world scenarios, knowledge is spread across platforms -- a GitHub README links to design docs, which reference issues, which point to other repos. This demo fills that gap by showing how to automatically discover and index connected documents across sources.

The crawler architecture is extensible: new source types (GitLab, Confluence, Jira, etc.) can be added by registering a fetcher function for a new LinkType, without modifying the core crawl logic.

What's included

Component Description
LinkType enum + detect_links() Regex-based link detection for GitHub URLs and web pages
MultiSourceCrawler BFS crawler with depth limits, duplicate detection, per-source fetcher registry
CrawlTrace / CrawledDocument Provenance models tracking how each document was discovered
fetch_github_raw / fetch_web_page Built-in fetchers for GitHub (public + private via token) and plain web pages
index_crawled_documents() Chunks documents and uploads to Llama Stack vector store with embedded provenance
main() End-to-end demo: crawl -> index -> query with file_search

Usage

# Default: crawl from llama-stack repo README
python -m demos.03_rag.06_multi_source_rag_crawler localhost 8321

# Custom depth and document limits
python -m demos.03_rag.06_multi_source_rag_crawler localhost 8321 --max_depth=2 --max_docs=20

# Custom seed URLs
python -m demos.03_rag.06_multi_source_rag_crawler localhost 8321 \
  --seed_urls='["https://github.com/meta-llama/llama-stack","https://github.com/meta-llama/llama-stack-client-python"]'

Test plan

  • Run with default settings against a Llama Stack server with a chat model + embedding model
  • Verify crawl discovers linked documents at depth > 0
  • Verify vector store is created, documents are indexed, and cleanup deletes the store
  • Verify the Responses API returns an answer grounded in crawled content
  • Run with --max_depth=0 to confirm it only indexes seeds (no link following)
  • Run with custom --seed_urls pointing to a different public repo

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a demo for multi-source RAG crawling across GitHub, GitLab, Confluence, Jira, Google Docs and web pages, with breadth-first discovery, deduplication, configurable depth/doc limits, and automatic vector-store indexing for RAG queries.
    • Includes safe fetching and optional credential-based access via environment variables.
  • Documentation

    • Updated demo docs with supported sources, usage examples, CLI options, and learning objectives.

Add a cross-source document crawler demo that follows links between
platforms (GitHub, web) and indexes discovered documents into a Llama
Stack vector store for RAG queries. Demonstrates breadth-first crawling
with depth limits, content deduplication via hashing, crawl provenance
tracking, and querying across the automatically-discovered knowledge graph.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@amastbau amastbau changed the title feat: add multi-source RAG crawler demo Draft: feat: add multi-source RAG crawler demo Mar 27, 2026
Print file_search results (filename, score, source URL) after the RAG
query, matching the pattern from 05_hybrid_search.py. Improve the
system instructions to reference the embedded Source: and Crawl path:
metadata in each chunk so the LLM cites provenance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Mar 27, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds a new demo script demos/03_rag/06_multi_source_rag_crawler.py and README entries. The script implements an end-to-end multi-source RAG workflow: cross-platform link detection (detect_links), deduplicated DetectedLink objects, crawl models (CrawlTrace, CrawledDocument, CrawlStats), and a MultiSourceCrawler that performs breadth-first crawling from seed URLs with max_depth/max_docs limits, visited-url tracking, and content-hash deduplication. Built-in fetchers support GitHub, GitLab, Confluence, Jira, Google Docs, and generic web pages with guarded downloads and environment-gated credentials. Documents are chunked, indexed into a Llama Stack vector store with provenance, queried via client.responses.create, and a CLI entry is exposed via fire.Fire(main).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Issues

Review from line-level code quality to architectural design. Security-first. Be direct. Cite CWE/CVE IDs for security findings. Only flag actionable issues; no praise, no summaries.

  • CWE-ReDoS / CWE-1321: detect_links() regexes are too permissive and may be vulnerable to ReDoS and false positives (e.g., r'https?://[^\s]+'). Action: anchor/validate patterns, avoid catastrophic backtracking, and add URL parsing/validation with a robust library (urllib.parse or validators package).

  • CWE-611: fetchers parse external content without explicit XML/HTML parser hardening. Action: disable external entity resolution, use safe parsers, and validate content-type before parsing.

  • CWE-400: Resource exhaustion — fetchers lack explicit timeouts, per-request size limits, and rate limiting. Action: add timeouts, max-content-length checks, per-host rate limits, and enforce max_docs/max_depth at fetch level.

  • CWE-22: Path/filename handling for uploads (indexing) may allow unexpected filenames or path traversal if external metadata used. Action: sanitize filenames and treat uploaded content as opaque; avoid using raw URLs as file paths.

  • CWE-95 / CWE-94: External content and provenance are concatenated into chunks used for indexing and prompting without sanitization/escaping. Action: escape or redact control characters and any prompt-sensitive markup before embedding into model prompts or code-generation contexts.

  • CWE-326 / Authentication: Environment-variable gating for credentials lacks explicit failure behavior and fallback. Action: validate presence/expiry of tokens, surface clear errors for missing credentials, and implement exponential backoff for auth failures (e.g., GitHub rate limits).

  • Collision risk (design): content_hash is truncated SHA-256 causing higher collision probability. Action: use full SHA-256 or a longer truncation and document collision acceptance threshold.

  • Error handling / resilience: network and indexing operations lack retries and transactional guarantees (client.upload_file(), add_to_index()). Action: add retry with backoff, idempotency keys, and cleanup/rollback on partial failures.

  • Input validation: seed URLs and extracted identifiers are not validated for scheme/host allowlists. Action: validate seeds against an allowlist or require explicit opt-in for private/internal hosts to mitigate SSRF.

  • Logging/telemetry: progress printing is user-facing only and may leak sensitive URLs/identifiers. Action: redact sensitive fields in logs and provide a verbose flag.

  • Testing: no unit/integration tests included for fetchers, deduplication, or indexing. Action: add tests that mock network responses and verify deduplication, stats recording, and index lifecycle.

Addressing the items above will reduce attack surface and improve operational robustness.

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding a multi-source RAG crawler demo at demos/03_rag/06_multi_source_rag_crawler.py.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Allow users to override the default system prompt used during the RAG
query phase. Prints the active prompt so users can see what instructions
the model is operating under.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@demos/03_rag/06_multi_source_rag_crawler.py`:
- Around line 309-317: The crawler currently enqueues links returned by
detect_links into self._queue via _QueueItem without validating destination
addresses; add validation before appending: resolve each link.url (and any
redirect target when fetching) to IP(s) and reject addresses in
localhost/127.0.0.1, ::1, and RFC1918/RFC3927 private ranges (e.g., 10.0.0.0/8,
172.16.0.0/12, 192.168.0.0/16, link-local) and non-public reserved ranges, and
ensure redirects do not change host to a private address (reject cross-host
redirects); apply the same check wherever links are enqueued (the detect_links
-> _QueueItem path and the other enqueue sites noted) and log + skip unsafe URLs
instead of enqueueing or fetching them.
- Around line 357-361: The current code reads the entire HTTP response into
memory via resp.text and then slices it, which does not prevent oversized
downloads; change the fetch logic that uses _requests.get(...) and the resp
variable to call _requests.get(..., stream=True), first validate the
Content-Type header (accept only text/* or known safe charsets) and then stream
the body with resp.iter_content(chunk_size) accumulating bytes up to the 15_000
byte cap (stop and abort the request if the limit is exceeded), and return None
for non-text or oversize responses; apply the same streaming + content-type
check/cap fix to the other fetcher block referenced around the 383-387 region so
both places avoid loading full responses into memory.
- Around line 345-357: The code naively rewrites any URL containing "github.com"
using str.replace and may send GITHUB_TOKEN to attacker-controlled hosts; update
the logic around raw_url, headers, and the _requests.get call to (1) parse the
incoming url with urllib.parse (or equivalent) and verify the netloc is exactly
"github.com" or "www.github.com" before deriving a raw.githubusercontent.com URL
(do not use str.replace on the whole string), (2) construct raw_url only after
confirming the origin is GitHub (or when building a README fallback from
identifier, ensure identifier's repo portion is validated), and (3) only attach
the Authorization header (token) when the final raw_url's parsed netloc equals
"raw.githubusercontent.com"; reference the variables raw_url, url, identifier,
headers, token and the request call _requests.get when making these changes.
- Around line 92-95: The LinkType.GITHUB regex is too permissive and captures
repo roots inside non-repo URLs (e.g., /issues, /pull, /releases), causing the
crawler to collapse those links to the repo README; update the LinkType.GITHUB
pattern to only match either a bare repo root (ending at end-of-string or a
trailing slash) or explicit content paths you intend to follow (e.g.,
/(blob|tree)/...), avoiding matching paths like /issues, /pull, /releases, and
then adjust the conversion logic that currently reads the regex groups (the code
around the repo extraction used at lines 130-133) to only convert the match into
a repo identifier when the regex indicates a true repo root or a blob/tree
content path (i.e., when the appropriate capture group is present), otherwise
skip treating it as the repo root so issues/pull/release links are not refetched
as the repo root.
- Around line 505-515: The provenance and enriched chunk currently include raw
URLs (doc.url and entries from doc.trace.path) which may contain sensitive query
strings/fragments; sanitize by stripping query and fragment components before
building provenance and the "Source:" field: for example, canonicalize each URL
from doc.url and each element of doc.trace.path via a URL-parsing routine (used
by _chunk_text caller) that removes the query and fragment, then use those
sanitized values when computing provenance and composing enriched; update the
code paths that construct provenance and enriched (references: provenance,
doc.url, doc.trace.path, enriched) to use the sanitized URLs so no secrets are
persisted or printed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Central YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: b40e3e19-7b90-4627-9802-deabffdfb2e9

📥 Commits

Reviewing files that changed from the base of the PR and between 3df80eb and cbd5cf3.

📒 Files selected for processing (2)
  • demos/03_rag/06_multi_source_rag_crawler.py
  • demos/03_rag/README.md

Comment thread demos/03_rag/06_multi_source_rag_crawler.py
Comment thread demos/03_rag/06_multi_source_rag_crawler.py
Comment thread demos/03_rag/06_multi_source_rag_crawler.py Outdated
Comment thread demos/03_rag/06_multi_source_rag_crawler.py Outdated
Comment thread demos/03_rag/06_multi_source_rag_crawler.py Outdated
amastbau and others added 3 commits March 27, 2026 17:52
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extend the crawler to support all source types from the original
project. Each source has a dedicated fetcher that activates when
credentials are configured via environment variables. Seed URL source
type is auto-detected. The demo prints which sources are available
based on configured credentials.

Sources added:
- GitLab: fetch files via API with PRIVATE-TOKEN auth
- Confluence: fetch pages by ID with Bearer token auth
- Jira: fetch issues with description and comments
- Google Docs: fetch document text via Google API key

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. GitHub regex: add end-of-token anchor to prevent matching /issues/,
   /pull/, /releases/ URLs as repo roots (CWE-918)

2. SSRF guard: add _is_safe_url() that resolves hostnames and rejects
   private/loopback/link-local/reserved addresses before enqueueing
   or fetching web URLs (CWE-918)

3. Token leak prevention: validate parsed hostname is exactly
   "github.com" before rewriting URL, and only attach GITHUB_TOKEN
   when raw_url hostname is "raw.githubusercontent.com" (CWE-200)

4. Download size limits: add _safe_download() helper that uses
   stream=True, checks Content-Type and Content-Length headers, and
   reads at most MAX_DOWNLOAD_BYTES via resp.raw.read() instead of
   loading full response into memory then slicing (CWE-400)

5. URL redaction: add _redact_url() that strips query strings and
   fragments before persisting Source: and Crawl path: in indexed
   chunks, preventing signed tokens or secrets from being stored
   in the vector DB and echoed by the model (CWE-200)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
demos/03_rag/README.md (1)

83-91: Missing blank line before table.

Per MD058, tables should be surrounded by blank lines for consistent rendering.

Fix markdown formatting
 **Supported Sources**:
+
 | Source | Auth Env Vars | Seed URL Example |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@demos/03_rag/README.md` around lines 83 - 91, Add a blank line immediately
before the markdown table that starts with the header "| Source | Auth Env Vars
| Seed URL Example |" (under the "**Supported Sources**:" section) so the table
is separated from the preceding paragraph, and ensure there's also a blank line
after the table block to satisfy MD058 and ensure consistent rendering.
demos/03_rag/06_multi_source_rag_crawler.py (1)

764-788: Double chunking with inconsistent parameters.

This file performs client-side character-based chunking (512 chars, 50 overlap) via _chunk_text(), then applies server-side token-based chunking (512 tokens, 64 overlap) at index time. Other RAG demos use only server-side chunking with max_chunk_size_tokens=256, chunk_overlap_tokens=32.

The double-chunking creates unpredictable boundaries and the parameter divergence may degrade retrieval quality.

Option: remove client-side chunking, align with other demos
 def index_crawled_documents(
     client: LlamaStackClient,
     vector_store_id: str,
     documents: list[CrawledDocument],
-    chunk_size: int = 512,
 ) -> int:
     ...
     for doc in documents:
         redacted_path = [*map(_redact_url, doc.trace.path), _redact_url(doc.url)]
         provenance = " -> ".join(redacted_path)
 
-        chunks = _chunk_text(doc.content, chunk_size)
-        for i, chunk in enumerate(chunks):
-            enriched = (
-                f"Source: {_redact_url(doc.url)}\n"
-                f"Crawl path: {provenance}\n"
-                f"Depth: {doc.depth}\n\n"
-                f"{chunk}"
-            )
+        enriched = (
+            f"Source: {_redact_url(doc.url)}\n"
+            f"Crawl path: {provenance}\n"
+            f"Depth: {doc.depth}\n\n"
+            f"{doc.content}"
+        )
 
-            filename = f"{doc.source_type.value}_{doc.content_hash}_chunk{i}.txt"
+        filename = f"{doc.source_type.value}_{doc.content_hash}.txt"
             ...
                 client.vector_stores.files.create(
                     ...
                     chunking_strategy={
                         "type": "static",
-                        "static": {"max_chunk_size_tokens": 512, "chunk_overlap_tokens": 64},
+                        "static": {"max_chunk_size_tokens": 256, "chunk_overlap_tokens": 32},
                     },
                 )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@demos/03_rag/06_multi_source_rag_crawler.py` around lines 764 - 788, The
client-side character chunker _chunk_text introduces double-chunking and
mismatched parameters; remove or disable its usage and rely on
server/token-based chunking with the same settings used by other demos
(max_chunk_size_tokens=256 and chunk_overlap_tokens=32). Locate all calls to
_chunk_text in this file (e.g., where documents are pre-split before indexing)
and either stop calling it (pass full text through) or replace it with a no-op
that returns the original text as a single item; ensure any local
chunk_size/overlap parameters are removed or updated to match the server-side
config so only token-based chunking is applied at index time.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@demos/03_rag/06_multi_source_rag_crawler.py`:
- Around line 513-560: fetch_gitlab currently derives base_url from the input
URL and will send the GITLAB_TOKEN to any host; before using token or building
base_url, validate the request host (parsed.hostname) is an allowed GitLab host
(e.g., "gitlab.com" or matches the hostname of the optional GITLAB_URL env var)
and return None if it is not allowed; update the logic in fetch_gitlab to
perform this hostname check (use variables parsed, base_url, token) so the token
is only sent to trusted GitLab hosts.

---

Nitpick comments:
In `@demos/03_rag/06_multi_source_rag_crawler.py`:
- Around line 764-788: The client-side character chunker _chunk_text introduces
double-chunking and mismatched parameters; remove or disable its usage and rely
on server/token-based chunking with the same settings used by other demos
(max_chunk_size_tokens=256 and chunk_overlap_tokens=32). Locate all calls to
_chunk_text in this file (e.g., where documents are pre-split before indexing)
and either stop calling it (pass full text through) or replace it with a no-op
that returns the original text as a single item; ensure any local
chunk_size/overlap parameters are removed or updated to match the server-side
config so only token-based chunking is applied at index time.

In `@demos/03_rag/README.md`:
- Around line 83-91: Add a blank line immediately before the markdown table that
starts with the header "| Source | Auth Env Vars | Seed URL Example |" (under
the "**Supported Sources**:" section) so the table is separated from the
preceding paragraph, and ensure there's also a blank line after the table block
to satisfy MD058 and ensure consistent rendering.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Central YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: 56e8772d-e40e-4cb2-9041-2b4b41456d79

📥 Commits

Reviewing files that changed from the base of the PR and between cbd5cf3 and f575aba.

📒 Files selected for processing (2)
  • demos/03_rag/06_multi_source_rag_crawler.py
  • demos/03_rag/README.md

Comment on lines +513 to +560
def fetch_gitlab(
identifier: str, url: str, trace: CrawlTrace
) -> CrawledDocument | None:
"""Fetch a file from a GitLab repository.

Requires ``GITLAB_TOKEN`` env var. Optionally set ``GITLAB_URL`` to override
the base URL (defaults to extracting it from the link URL).

Example env::

GITLAB_URL=https://gitlab.com
GITLAB_TOKEN=glpat-xxxxxxxxxxxxxxxxxxxx
"""
if _requests is None:
return None

token = os.environ.get("GITLAB_TOKEN")
if not token:
return None

# Determine GitLab base URL from the matched URL
parsed = urllib.parse.urlparse(url)
base_url = os.environ.get("GITLAB_URL", f"{parsed.scheme}://{parsed.netloc}")

repo = identifier.split(":")[0]
path = identifier.split(":", 1)[1] if ":" in identifier else "README.md"
project_encoded = urllib.parse.quote(repo, safe="")

# Try main branch, then master
for ref in ("main", "master"):
file_url = (
f"{base_url}/api/v4/projects/{project_encoded}"
f"/repository/files/{urllib.parse.quote(path, safe='')}/raw"
)
headers = {"PRIVATE-TOKEN": token}
content = _safe_download(f"{file_url}?ref={ref}", headers)
if content is not None:
web_url = f"{base_url}/{repo}/-/blob/{ref}/{path}"
return CrawledDocument(
url=web_url,
title=f"{repo}/{path}",
content=content,
source_type=LinkType.GITLAB,
trace=trace,
metadata={"repo": repo, "path": path, "ref": ref},
)

return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Token sent to arbitrary hosts matching GitLab regex pattern (CWE-200).

Unlike fetch_github which validates parsed.hostname == "github.com", this fetcher derives base_url from whatever URL matched the GitLab regex (line 188: gitlab[^/\s]*). A crawled link like https://gitlab.attacker.com/org/repo would send GITLAB_TOKEN to the attacker's server.

Remediation: validate against allowed GitLab hosts
 def fetch_gitlab(
     identifier: str, url: str, trace: CrawlTrace
 ) -> CrawledDocument | None:
     ...
     token = os.environ.get("GITLAB_TOKEN")
     if not token:
         return None
 
     # Determine GitLab base URL from the matched URL
     parsed = urllib.parse.urlparse(url)
+    # Only send token to explicitly configured GitLab host
+    allowed_host = urllib.parse.urlparse(os.environ.get("GITLAB_URL", "https://gitlab.com")).hostname
+    if parsed.hostname != allowed_host:
+        return None
     base_url = os.environ.get("GITLAB_URL", f"{parsed.scheme}://{parsed.netloc}")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@demos/03_rag/06_multi_source_rag_crawler.py` around lines 513 - 560,
fetch_gitlab currently derives base_url from the input URL and will send the
GITLAB_TOKEN to any host; before using token or building base_url, validate the
request host (parsed.hostname) is an allowed GitLab host (e.g., "gitlab.com" or
matches the hostname of the optional GITLAB_URL env var) and return None if it
is not allowed; update the logic in fetch_gitlab to perform this hostname check
(use variables parsed, base_url, token) so the token is only sent to trusted
GitLab hosts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant