debateRAG is a Discord bot that answers a policy debate 1AC. Upload the affirmative's
speech document to the /oncase command. The bot replies with the blocks from your own
camp files that answer each of the aff's impact scenarios, sorted by how well they match
and labeled as defense or as turns.
It is a retrieval-augmented generation pipeline, but the generation step is small on purpose. A language model reads the 1AC and names the arguments. Vector search over your evidence does the rest. The bot never writes a card, because a debater cannot read a hallucinated citation out loud in a round.
The negative team gets a few minutes between receiving the 1AC document and standing up to give the 1NC. In that window you have to work out what the aff's terminal impacts are, then find the pages that answer them inside a camp file that runs to several thousand pages.
Ctrl-F is the standard tool and it is bad at this job. It matches strings, not claims. A block filed under "AT: Bioweapons" never surfaces when you search "engineered pandemic", and a block titled "No Warming Impact" never surfaces when you search "climate extinction". You end up remembering where things are. That works until you are tired, or the file is new, or the aff reads a scenario your squad has not hit before.
debateRAG does that lookup by meaning instead of by string.
Skip this section if you debate. Read it if you are here to look at the engineering, because the vocabulary drives every design decision below.
- Card. One piece of evidence: a claim headline, a citation, and quoted text from a source. Every debate document is built out of cards.
- Tag. The claim headline above a card, written by the debater. "Adaptation is guaranteed, zeroing the impact." The tag states the argument. The card body proves it.
- Block. A group of cards that answer one argument and get read together as a unit. "AT: Bioweapons" is a block.
- Hat and pocket. The two levels of heading above a block, naming the argument area and the file section it lives in.
- 1AC. The affirmative's first speech. A prepared document of cards, sent to the other team at the start of the round.
- 1NC. The negative's first speech, written in the few minutes after reading the 1AC.
- Impact. The terminal harm an argument claims: extinction, nuclear war, mass death.
- Impact defense. Evidence that the harm will not happen, or will be small.
- Impact turn. Evidence that the harm is good, or that the plan causes it instead of preventing it.
- Verbatim. The Microsoft Word add-in that nearly every debater uses. It enforces a heading hierarchy on documents. That hierarchy is the reason this project can parse debate files at all.
- You attach a 1AC as a
.docx. The bot rejects anything else. parse.pyreads the Word styles and pulls out every card.extract.pysends the tags, and only the tags, togpt-4o-mini. The model returns between one and six impact scenarios, each with a short label and the aff's terminal claim written as a positive sentence.search.pyembeds each claim and runs a nearest-neighbor query against your card corpus in Postgres.bot.pydrops any block further than 0.55 away, skips blocks it already posted under an earlier scenario, and formats the rest.- Discord gets the results, split across as many messages as the 2000-character limit requires.
Here is what the bot posts for a warming advantage. The tags and the citations are real rows from my corpus. The two distance numbers stand in for whatever a real query returns.
**═══ Warming ═══**
_Climate change causes human extinction._
🛡️ DEFENSE `0.31` — Warming › 2NC---Warming Defense---Cards
• Adaptation is guaranteed, zeroing the impact.
_Lomborg ’21---Dr. Bjorn; President of the Copenhagen Consensus Center, Former Director of the Danish_
• Even extreme emissions won’t cause extinction.
_Ord ’20---Dr Toby; Senior Research Fellow in Philosophy at Oxford University, DPhil in Philosophy fr_
• The ev is cherry-picked anecdotes or predictive models off by several orders of magnitude.
_Zycher ’21---Dr. Benjamin; Senior Fellow at the American Enterprise Institute, Doctorate in Economic_
• ‘Existential’ warming is the worst case that even the IPCC thinks is highly unlikely AND every model is ruined by systemic upward bias.
_Wade ’21---Robert; Professor of Global Political Economy at the London School of Economics, DPhil an_
🔄 TURN `0.44` — Warming Good › Warming Good---Trade
• Thousands of ships can sail through the Arctic by 2050 because of warming. That reduces cost, distance, and avoids dangerous waters while bo
_The Economist ’25 [January 23, 2025; “The Arctic: climate change’s great economic opportunity An eno_
• Arctic warming and the result of sea-ice reductions is materially beneficial for maritime interests.
_Min et al ‘22 [Chao Min, Qinhua Yang, Dake Chen, Yijun Yang, Xiangying Zhou, Qi Shu, Jiping Liu; May_
• The Northern Sea Route is key to world trade.
_Shetty ‘23 (Kanishk Shetty, “The Northern Sea route: A gamechanger or a road to hegemony?,” May 6, 2_
• Trade reduces conflicts.
_Lee ‘11 (Jong-Wha and Ju Hyun, “Does Trade Integration Contribute to Peace?”, September 2011, Econom_
The distance number is cosine distance, so lower is closer. A block at 0.31 is a direct answer. A block at 0.52 is worth a look. Anything past 0.55 never gets posted.
If you debate, the bot turns your camp files into something you can question in plain English. Point it at a 1AC and it tells you which blocks to pull. Give it a scenario you expect to hit and it tells you whether your files cover it. The empty answer carries information too. "No close matches in corpus" three rounds in a row on the same scenario is a prep assignment.
If you are evaluating this as engineering work, the interesting parts are not the API calls. They are the retrieval design decisions further down: what text gets embedded, what unit gets returned, how the query is phrased before it is embedded, and when the system refuses to answer. Those decisions separate a RAG pipeline that works from a demo.
The system runs in two halves. The first half runs once per card file. The second half runs once per debate round.
INGEST (once per card file)
word_files/*.docx
│ parse.py Word heading styles → one dict per card
▼
jsonl_files/*.jsonl
│ load.py gemini-embedding-001, RETRIEVAL_DOCUMENT, 1536 dims
▼
Postgres + pgvector, table `cards`
QUERY (once per /oncase)
1AC .docx attachment
│ parse.py same parser, in-memory, tags only
▼
list of tags
│ extract.py gpt-4o-mini, strict JSON schema
▼
1 to 6 impact scenarios
│ search.py gemini-embedding-001, RETRIEVAL_QUERY, cosine KNN
▼
ranked blocks
│ bot.py threshold, dedupe, chunk
▼
Discord messages
Five modules, 635 lines. Run wc -l impact-defense-ingest/*.py to check that number.
parse.py never pattern-matches on what a paragraph says. It reads
paragraph.style.name and branches on the heading level. Verbatim puts the pocket in
Heading 1, the hat in Heading 2, the block in Heading 3, and the tag in Heading 4, so
the document already carries a clean tree. The parser walks paragraphs in order, keeps a
dictionary of the current heading at each level, and clears the deeper levels whenever a
shallower heading appears.
Text-based parsing would have to guess. Style-based parsing reads the structure the debater already committed to. Every decision below depends on it.
get_highlighted() pulls the highlighted runs out separately, because debaters
highlight the words they actually read out loud, and that subset is the real argument.
Each card gets one embedded string, built as hat > block > tag:
Warming > 2NC---Warming Defense---Cards > Adaptation is guaranteed, zeroing the impact.
load.py stores the card body in body_full and never embeds it. In my corpus the median
embed_text is 97 characters and the median body_full is 3798. The string the loader
actually embeds is roughly 39 times shorter than the card it came from.
Cost is the small reason. Precision is the real one. A card body is several paragraphs of journal prose about many things, and its embedding lands in the average of all of them. The tag is one sentence that states the argument exactly, written by a human who knew what the card was for. Debate tags are, by accident of format, close to ideal retrieval keys. Embedding the bodies would bury that signal under the noise of the underlying evidence.
The trade-off is real and I accept it: a card whose tag is vague is close to unretrievable, no matter how good the evidence inside it is.
search_blocks() groups the nearest-neighbor results by block_id, scores each block
with MIN(embedding <=> query), then fetches every card in the winning blocks ordered
by position.
A single card is the wrong unit to hand a debater. Nobody reads one card in the 1NC. They read the block, in the order the block was written, because the cards build on each other. Scoring on the best card and returning the whole group matches how the evidence gets used.
The corpus decides how much this matters. In mine, most blocks hold one card and the largest holds 13, so on many queries block retrieval and card retrieval agree.
This is the least obvious decision in the project.
The extraction prompt in extract.py requires the model to write the aff's terminal
impact as a positive declarative sentence in the affirmative's own voice. "Climate
change causes human extinction." Not "answers to climate change". Not "climate change
does not cause extinction."
Embedding models encode negation weakly. "Warming causes extinction" and "warming does not cause extinction" sit close together in vector space, because they are about the same topic. Searching for the negated form therefore buys nothing, and searching for "answers to X" pulls the embedding toward documents about the word "answers".
Every card in the corpus is already negative evidence. The corpus supplies the negation. The query only has to name the topic, and it names it best by stating the aff's own claim.
MAX_DISTANCE in bot.py is 0.55. bot.py drops any block further away, and a
scenario with nothing left prints "No close matches in corpus."
Most retrieval systems always return the top k. That is the wrong behavior here. A debater who follows a bad suggestion loses the prep time twice, once reading the block and once realizing it does not apply. Silence costs nothing and is honest. The threshold is hand-tuned against one corpus. That is the weakest part of this design.
body_hash is sha256(filename + body_full), truncated to 16 hex characters, computed
over whitespace-normalized lowercase text. It is the primary key, and load.py upserts
with ON CONFLICT (body_hash) DO UPDATE.
Before embedding anything, load.py asks the database which hashes already have a
vector and skips them. Edit one card in a 300-card file, re-run the loader, and it
embeds one card. The normalization step matters more than it looks: Word rewrites
whitespace constantly, so a hash over raw text would treat untouched cards as new on
every save.
gemini-embedding-001 takes a task_type. load.py passes RETRIEVAL_DOCUMENT and
search.py passes RETRIEVAL_QUERY. The two task types put the stored cards and the
incoming question into one space through different projections. Both sides also pass
output_dimensionality=1536, and both constants have to match the vector(1536) column
or Postgres rejects the insert.
- Python 3.14.
.venvwas built with 3.14.6. - python-docx reads paragraph styles and per-run highlight colors out of
.docx. - OpenAI
gpt-4o-miniextracts impact scenarios. The call uses structured outputs with"strict": True, so the response either matches the schema or the API errors. No defensive parsing, no retry loop for malformed JSON. - Google
gemini-embedding-001produces 1536-dimension embeddings, task-typed per side, batched 20 at a time. - Postgres with pgvector stores the vectors and ranks them with the
<=>cosine distance operator. OneGROUP BYwithMIN()does the block-level scoring in the database instead of in Python. - psycopg 3 talks to Postgres.
DATABASE_URLis a standard connection string. - discord.py 2.7 provides the slash command.
oncase()callsinteraction.response.defer()first, because the pipeline takes longer than Discord's three-second reply window. - python-dotenv loads the four keys from
.env.
load.py handles rate limits by reading the retryDelay value out of Google's error
message and sleeping that long, and it falls back to exponential backoff when the error
carries no delay. After five failed attempts it raises RuntimeError instead of writing
partial data.
impact-defense-ingest/
parse.py 185 lines docx to card dicts
load.py 144 lines embed and upsert into Postgres
search.py 104 lines vector search
extract.py 88 lines 1AC tags to impact scenarios
bot.py 114 lines the Discord /oncase command
word_files/ your .docx camp files (gitignored)
jsonl_files/ parser output (gitignored)
sql/
schema.sql the cards table, run it once
.github/workflows/ci.yml ruff and a byte-compile check
ruff.toml lint config, shared by CI and local runs
requirements.txt
.env.example
LICENSE
Card files and parser output stay out of git. Camp files are not mine to redistribute,
and the .jsonl regenerates from them in seconds.
You need a Postgres database with the vector extension, a Gemini API key, an OpenAI
API key, a Discord bot token, and your camp files as Verbatim-formatted .docx.
psql "$DATABASE_URL" -f sql/schema.sqlsql/schema.sql creates the vector extension and the cards table, with exactly the
columns load.py writes and search.py reads. body_hash is the primary key, because
load.py upserts on it. There is no index on the embedding column, on purpose — see
the limits section.
Run these from the repository root:
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .envFill in all four values in .env: GEMINI_API_KEY, OPENAI_API_KEY, DISCORD_TOKEN,
and DATABASE_URL. Four modules call load_dotenv() and read what they need.
parse.py needs no keys.
Put your .docx files in impact-defense-ingest/word_files/, then add a line for each
one to SOURCES in parse.py:
SOURCES = {
"impact_defense.docx": {"prefix": "imp", "arg_type": "defense"},
"impact_turns.docx": {"prefix": "trn", "arg_type": "turn"},
}prefix starts every card_id from that file. arg_type is the fallback label for
cards whose block name does not say which kind they are. Block names win when they are
explicit: a block containing "turn", "good", "outweighs", or "solves" is labeled a turn,
and one containing "defense", "at:", "a2:", "no ", "doesn't", or "won't" is labeled
defense.
Run the parser:
python impact-defense-ingest/parse.pyIt writes one .jsonl per source and prints a summary per file:
impact_turns.docx
cards : 239
blocks : 119
turn / defense : 184 / 55
no highlighting: 49
A card count of 0 means the parser found no Heading 4 tags, so the document is either
not a Verbatim file or uses custom styles. A high no-highlighting count is harmless
today, because nothing reads body_highlighted yet.
python impact-defense-ingest/load.pyThe loader prints how many cards need embedding and its progress through the batches.
To re-embed everything, clear the embedding column, because existing_hashes() counts
a card as done only when its embedding is not null.
python impact-defense-ingest/bot.pyThe bot syncs its command tree on startup and prints the account it logged in as. The first global sync can take up to an hour to reach a Discord client.
To check what the corpus holds without going through Discord, run search.py directly:
python impact-defense-ingest/search.pyIt prompts for a query and prints the three closest blocks with their distances. Press
Enter on an empty line to quit. embed_query() caches every query for the life of the
process, so repeating one costs nothing.
search_blocks() also takes an arg_type argument to restrict results to turns or to
defense. bot.py does not use it.
- To return more or fewer blocks per scenario, change
BLOCKS_PER_SCENARIOinbot.py. - To accept looser matches, raise
MAX_DISTANCEinbot.pyabove 0.55. - To change how scenarios get extracted, edit
SYSTEM_PROMPTinextract.py. Keep rule 3, which is the positive-claim rule explained above. - To change the embedding model or the dimension count, edit
MODELandDIMSin bothload.pyandsearch.py, and change thevector(n)column to match. Changing either constant invalidates every embedding already stored.
extract.pysees tags, never card bodies. A 1AC with vague or cute tags extracts badly, and nothing downstream can recover from that.- Retrieval matches tags and block names, not evidence text. A well-researched card
filed under a lazy tag will not come back. Fixing this means a second embedding over
body_highlightedand a merge of the two rankings. - The
cardstable has no vector index. Every search is a sequential scan. At 300 cards that is the right call, and an HNSW index only starts to pay off in the tens of thousands. oncase()blocks the event loop.extract_scenarios()andsearch_blocks()are synchronous, and the command handler awaits nothing while they run. One/oncasein flight stalls the bot for every other user. Wrapping both calls inasyncio.to_threadfixes it. At the scale of one squad's server, it has not mattered yet.search_blocks()opens a new Postgres connection per call, so a six-scenario 1AC opens six connections in sequence. A connection pool is the fix.MAX_DISTANCEis hand-tuned against one corpus. There is no labeled evaluation set, so I cannot tell you the precision or the recall. Building one means marking, by hand, which blocks actually answer which scenarios across a set of real 1ACs. That is the next thing worth doing.- There are no tests. CI lints and byte-compiles, which catches typos and dead
imports and nothing else. Every real behavior in this repo needs either a
.docxfixture or a database, and neither exists yet.
Lint the way CI does, from the repository root:
pipx run ruff check .ruff.toml holds the rule set, so a local run and the GitHub Actions run agree. CI runs
that command plus python -m compileall on every push and pull request. It installs no
dependencies and makes no network calls, because every interesting path in this repo
needs an API key or a database.
MIT. See LICENSE.
The code is mine to give away. The camp files it reads are not — they stay out of this repository, and whatever you ingest is yours to keep to yourself.