A retrieval-augmented generation (RAG) system that indexes questions, not chunks.
Instead of embedding raw document text and hoping a user's query semantically matches it, QIndex uses an LLM to generate the questions each chunk answers during ingest, embeds those questions, then at query time matches the user's question against the stored question vectors. This produces dramatically better cosine similarity scores and more grounded answers.
Standard RAG embeds chunk text → matches against a user query. The gap: a user asking "command to install the package" rarely matches a chunk that says "Run the following npm command" — the vocabulary is different even though the intent is identical.
QIndex closes this gap:
Ingest:
chunk → LLM → ["how do I install the package?", "what is the npm install command?", ...]
↓ embed each question
Qdrant stores (question_vector → chunk_text as payload)
Query:
user question → embed → cosine search against question_vectors
↓ top-k matched questions → retrieve chunk payloads
LLM generates answer from chunks
The user's question is matched against a question, not a prose chunk. Semantically similar phrasings align far better in embedding space.
Most RAG implementations use token-based chunking: split every N tokens with an overlap. This breaks content at arbitrary positions — mid-sentence, mid-list, mid-code block. The result is chunks that lose their structural context.
QIndex uses a heading → paragraph → sentence cascade:
- H2/H3 boundaries first — each section becomes its own chunk, preserving topic coherence
- Paragraph split next — if a section is too large, split on double newlines (list blocks stay atomic)
- Sentence split last — only when a single paragraph exceeds the word limit
- Full-page shortcut — pages under 500 words are stored as a single chunk; no split needed
- Page summary prepended — every chunk gets
Source: URL / Page: H1 title / first paragraphprepended, so the chunk is self-contained even out of context
Why this matters for question generation: An LLM generating questions from a coherent section produces grounded, specific questions. A token-split chunk that starts mid-sentence often produces generic questions that fail the grounding check.
Grounding check: Every generated question must share ≥ 2 meaningful words (>4 chars) with the chunk it came from. Questions that don't pass are discarded. This prevents hallucinated generic questions from polluting the index.
Nav-heavy page detection: Raw markdown is preferred (preserves code blocks and install commands). Pages where raw word count exceeds 4× the fit word count are considered nav-heavy and use the filtered fit_markdown instead.
Tested against stl.fysk.dev (19 pages, ~80 indexed questions).
| Query | Standard RAG top score | QIndex top score |
|---|---|---|
| "how write a button in stl syntax" | 0.753 (wrong page — matched link docs) | 0.694 (correct page — button docs) |
| "what is the use of action attribute in button" | — | 0.873 |
| "what is the use of targetId in button" | — | 0.889 |
| "why should I use STL over other table plugins" | 0.853 (correct) | 0.854 (correct) |
Standard RAG example failure — query "how write a button in stl syntax" returned chunks about links, not buttons, because the link documentation page contains more link-related prose that happened to score higher. QIndex matched the question "how do I create a button element in STL syntax?" directly.
| Query | Top score | Answer quality |
|---|---|---|
| "command to install the structured-table package" | 0.807 | Correct: npm i structured-table |
| "how to install the structured-table" | 0.771 | Correct |
| "what is the use of targetId in button in STL syntax" | 0.889 | Correct |
| "what is the use of action attribute in button" | 0.873 | Correct |
| "how to add a button in the stl syntax" | 0.694 | Correct |
| "how can I use this in my project" | 0.724 | Correct (multi-framework answer) |
Confidence threshold (0.60): Results below threshold are shown but excluded from LLM generation. When all results fall below threshold, the system returns "I don't have enough information about that" rather than hallucinating.
Keyword/command queries still underperform vs. natural language questions:
"command to install the stl package" → top score: 0.560 (below threshold)
"how to install the structured-table" → top score: 0.771 (answers correctly)
Both ask the same thing. The fix (not yet implemented): a normalize_query() step that rewrites the user's query to a canonical question form before embedding.
qindex/
├── qindex_rag.py # API version (OpenAI + Anthropic Haiku)
├── qindex_rag_local.py # Local version (Ollama, no API keys)
├── chunker/
│ ├── __init__.py
│ └── context_chunker.py # Heading-aware chunker with page summary
└── requirements.txt
pip install -r requirements.txt
playwright install chromiumQdrant (local vector store):
docker run -p 6333:6333 qdrant/qdrantRequires Ollama running locally with the models pulled:
ollama pull nomic-embed-text
ollama pull qwen2.5:14b# Ingest a documentation site
python qindex_rag_local.py ingest https://docs.yoursite.com 30
# Ask a question
python qindex_rag_local.py ask 'how do I install the package'
# Filter to a specific site
python qindex_rag_local.py ask 'how do I install the package' --site docs.yoursite.comSet environment variables:
export OPENAI_API_KEY=sk-...
export ANTHROPIC_API_KEY=sk-ant-...# Ingest a documentation site
python qindex_rag.py ingest https://docs.yoursite.com 30
# Ask a question
python qindex_rag.py ask 'how do I install the package'
# Filter to a specific site
python qindex_rag.py ask 'how do I install the package' --site docs.yoursite.comModels used:
- Embedding:
text-embedding-3-small(1536-dim, ~$0.02/1M tokens) - LLM:
claude-haiku-4-5-20251001(question generation + answer generation)
All constants are at the top of each file:
| Constant | Default | Description |
|---|---|---|
COLLECTION_NAME |
qindex_docs_v1 / qindex_local_v1 |
Qdrant collection name |
EMBED_DIM |
1536 (API) / 768 (local) | Must match embedding model |
CHUNK_SIZE |
500 | Chunker uses chunk_size // 4 as max words |
CHUNK_OVERLAP |
100 | Overlap in characters between consecutive chunks |
TOP_K |
3 | Number of results to retrieve |
CONFIDENCE_THRESHOLD |
0.60 | Min cosine similarity to pass to LLM |
| Standard RAG | QIndex | |
|---|---|---|
| What is embedded | Chunk text | LLM-generated questions |
| Query match | Query ↔ prose | Question ↔ question |
| Chunking | Token-based | Heading/paragraph cascade |
| Hallucination guard | Prompt only | Confidence threshold + grounding check |
| Cost | Low | Higher (LLM call per chunk at ingest) |
QIndex trades higher ingest cost (one LLM call per chunk to generate questions) for higher retrieval precision. For a support knowledge base where ingest happens once and queries happen thousands of times, this is the right tradeoff.
I built QIndex while developing SupportGPT, a support chatbot that answers questions from your documentation. The core frustration was that standard RAG kept failing on exact same intent expressed with different words — "install command" vs "how to install" would return completely different results.
The solution felt obvious in retrospect: if you want question-to-question matching, index questions. An LLM can generate all the ways a user might ask about a chunk far better than any heuristic.
After I shared this publicly, my friend sent me a link to a 2025 Seoul National University research paper that describes a nearly identical approach — LLM-generated questions as the index representation. I had not seen the paper before building QIndex. This kind of parallel discovery is actually a strong signal: when two independent paths arrive at the same answer, the intuition is probably right.
I'm sharing QIndex openly because the idea deserves to be used, not hoarded.
MIT License — use it, build on it, ship it.
See LICENSE.