Show HN:RAGless——类似 RAG,但运行时 LLM API 成本为 0 美元
英文标题:🔶 Show HN: RAGless – similar to RAG, but $0 LLM API costs at runtime
RAGless 是一个仅检索式问答系统,在运行时不调用 LLM:它预先将源文档转换为问答信息块,并通过本地 Qdrant 向量数据库检索原样返回答案。项目采用 Gemini 非对称嵌入、按 `answer_id` 聚合多个问题变体得分,以及可选的 LLM Judge 校验,以提高检索稳定性并降低离线生成阶段的错误风险。作者估算,在每天 1,000 次查询的场景下,该方案运行时 API 成本接近 0、单次延迟约 0.15 秒,相比经典生成式 RAG 可节省约 157.50 美元/月。其代价是无法实时综合新信息或动态改写答案,知识覆盖和更新都依赖重新执行数据摄取流程。
1.4 MB
908.3 KB
中文
RAGless——确定性的仅检索问答系统
RAGless 是一个仅检索式问答系统,在运行时零 LLM 调用。源文档会被转换为自包含的信息块,索引到本地向量数据库(Qdrant)中,并通过非对称 Gemini 嵌入进行查询。
运行时零幻觉。极低延迟。单次查询成本近乎为零。
该项目由三个独立脚本构成:
| 脚本 | 用途 |
|---|---|
prepare_data.py |
使用 Gemini 的 JSON 模式从源文档中提取问答块 |
ingest_to_qdrant.py |
生成嵌入并填充本地 Qdrant 向量数据库 |
chatbot.py |
为用户查询检索最相关答案的 CLI 聊天机器人 |
- 运行时不使用 LLM——聊天机器人完全依赖向量检索。用户交互期间不会产生昂贵的 API 调用。
- 通过
answer_id聚合实现稳健的 Q-Q 匹配——如果同一答案的多个问题变体与查询匹配,其得分将被相加。这使结果比经典的“top-1”检索稳定得多。 - 非对称 Gemini 嵌入——摄取时使用
RETRIEVAL_DOCUMENT,检索时使用RETRIEVAL_QUERY,遵循 Google 的推荐方式。 - 嵌入式 Qdrant——无需 Docker 服务器,也无需云服务。数据存储在本地磁盘(
./qdrant_data)上。 - 智能分块——仅当文档超过 Token 阈值时才会分块,Token 数量由模型实际使用的分词器计算。
- 可选的 Judge 校验——
prepare_data.py可启用第二轮 LLM 处理,丢弃不受源文本支持的信息块(--judge)。 - 未命中查询日志——低于阈值的查询会被自动记录到
missed_queries.log,供后续分析。 - 保证幂等性——每次运行
ingest_to_qdrant.py都会从头重建集合,防止出现隐藏的重复数据。
- Python 3.10+
- Gemini API Key(提供额度充足的免费层)
- Python 依赖项(见安装部分)
-
克隆或下载仓库,并进入项目文件夹。
-
创建虚拟环境(推荐):
python -m venv venv source venv/bin/activate # Linux/macOS # or venv\Scripts\activate # Windows -
安装依赖项:
pip install litellm qdrant-client pypdf python-dotenv tqdm -
配置 API Key:
cp .env.example .env # Edit .env and insert your GEMINI_API_KEY
.
├── source/ # 存放源文档(.pdf、.txt、.md)的文件夹
├── config.py # 集中式配置(模型、阈值、路径)
├── prepare_data.py # 脚本 1:问答块提取
├── ingest_to_qdrant.py # 脚本 2:嵌入与索引
├── chatbot.py # 脚本 3:CLI 聊天机器人
├── data.json # prepare_data.py 的输出(已校验的信息块)
├── qdrant_data/ # 本地向量数据库(自动创建)
├── failed_chunks/ # 未能生成有效 JSON 的分块(调试用途)
└── missed_queries.log # 低于阈值的查询日志
1. 准备数据(prepare_data.py)
将文档放入 source/ 文件夹(支持 .pdf、.txt、.md),然后运行:
使用可选的 Judge 校验(速度较慢,但准确性更高):
python prepare_data.py --judge
功能说明:
- 读取每个文件并统计 Token 数。
- 如果文档较短(≤ 10,000 Token),则将整篇文档发送给 LLM;否则将其拆分为多个分块。
- 提取包含
answer、questions、category、source_quote的 JSON 信息块。 - 校验信息块并将其保存至
data.json。
2. 索引到向量数据库(ingest_to_qdrant.py)
python ingest_to_qdrant.py
功能说明:
- 加载
data.json。 - 将每个信息块按其问题变体数量“展开”为多行。
- 通过 LiteLLM + Gemini 分批生成嵌入。
- 重建 Qdrant 集合,并使用确定性的 UUID5 插入向量。
3. 启动聊天机器人(chatbot.py)
可用选项:
python chatbot.py --threshold 0.75 # 修改聚合得分的最低阈值
python chatbot.py --debug # 显示内部得分和聚合表
交互:
You> How does check-in work?
[INFO] Found 2 relevant answers, showing top 1:
──────────────────────────────────────────────────────────────────────
--- Answer 1 (Pertinence: 1.85) ---
Check-in is available from 3:00 PM to 8:00 PM. If you arrive after 8:00 PM,
please contact reception in advance...
──────────────────────────────────────────────────────────────────────
Source: source/regulations.txt
输入 exit、quit 或 :q 可退出。
聊天机器人的核心是按 answer_id 聚合:
- 使用
task_type=RETRIEVAL_QUERY为用户查询生成嵌入。 - Qdrant 返回
TOP_K_RETRIEVAL个最相似的点(问题)。 - 指向相同
answer_id的点,其得分会被相加。 - 候选答案仅会在以下条件满足时展示:
- 聚合得分超过
DEFAULT_THRESHOLD或 - 最佳单个命中超过
SINGLE_HIT_THRESHOLD - 且最佳单个命中 > 0.68(最低质量要求)
- 聚合得分超过
该机制使系统更具鲁棒性:即使没有单个问题变体能完美匹配,指向同一答案的多个较弱匹配之和也可能让正确答案脱颖而出。
RAGless 与经典生成式 RAG 的对比
经典 RAG 会在运行时检索上下文,并提示 LLM 合成回答。**RAGless 完全取消了生成步骤。**答案在摄取期间预先生成,并在运行时原样检索。
| 优势 | 说明 |
|---|---|
| 管道简单得多 | 无需为答案生成进行提示工程,无需管理上下文窗口,也无需解析输出。只需嵌入、搜索、返回。 |
| Q-Q 匹配可靠性高得多 | 查询到问题(Q-Q)的匹配,在语义上比查询到文档分块(Q-D)或查询到答案(Q-A)更容易、更稳健。每个答案的多个问题变体提供了冗余。 |
| 确定性 | 相同的问题,始终得到相同的答案。行为可复现、可测试。 |
| 运行时零幻觉 | 查询时没有 LLM 生成答案。返回文本已预先生成且不可变。 |
| 单次查询成本为零 | 摄取完成后不再有 API 调用。检索完全是本地计算。 |
| 低延迟 | 查询嵌入 + 向量搜索。以毫秒计,而非秒。 |
| 答案可验证 | 每个信息块都包含 source_quote 和 source_file。具备完整审计链路。 |
| Bug 可复现 | 如果答案错误,它就是 100% 错误。易于定位和修复。 |
| 有限的输出空间 | 可能答案的数量是已知的(data.json)。可进行穷尽测试。 |
| 可在普通硬件上运行 | 使用嵌入式 Qdrant,无需在内存中运行 LLM。仅用 CPU 和数 GB RAM 即可工作。 |
| 完全隐私 | 摄取完成后,没有用户数据会离开机器。 |
| 没有依赖漂移 | 嵌入模型可以变化,但答案不会变化。不会受制于某个 LLM 提供商的可用性或定价。 |
权衡与限制
| 限制 | 说明 |
|---|---|
| 没有实时灵活性 | 无法合成新答案、跨信息块组合信息,或动态调整语气。摄取什么,就得到什么。 |
| 摄取成本更高 | 使用 LLM 生成问答块的成本高于单纯生成嵌入。见下方成本对比。 |
| 覆盖范围受摄取限制 | 如果某个主题没有在 prepare_data.py 中被提取,系统就无法回答。无法围绕知识缺口进行“推理”。 |
| 维护需要重新摄取 | 更新答案需要重新运行完整管道,而不只是修改一个提示词。 |
成本与性能对比
| 指标 | 经典生成式 RAG | Q-Q 系统(本项目) | 差异 / 优势 |
|---|---|---|---|
| 摄取成本(一次性) | 约 $0.01 (仅嵌入 100,000 Token) | 约 $1.50 (LLM 从 100,000 Token 中生成问答) | +$1.49(初始劣势,但成本可忽略) |
| 运行时 API 成本(每月) | 约 $157.50 (每天 1,000 次查询、约 1,000 个上下文 Token + 150 个输出 Token) | $0.00 (仅嵌入 1,000 个短查询,< $0.02/月) | 每月节省约 $157.50(零成本扩展性) |
| 延迟(每次查询) | 2.5–4 秒 (LLM 文本生成) | 约 0.15 秒 (纯向量搜索) | 快超过 15 倍(即时响应) |
| 幻觉率(运行时) | 低,但始终 > 0% | 0% | 风险被消除(静态、确定性输出) |
幻觉的权衡
RAGless 消除了运行时的幻觉——这是幻觉最危险的阶段,因为此时它们不可控。摄取期间的风险仍然存在,但已得到缓解;更重要的是,它发生在离线、受控的环境中,并且知识库投入生产前可以进行人工审核。
RAGless 将幻觉风险从运行时转移到摄取阶段。生成发生在 prepare_data.py 中,这也是增加可选 --judge 流程以及用于幂等性的确定性 UUID 的原因。
**这种权衡是:**你放弃实时灵活性,换取离线可验证性。对于高风险领域,我更愿意面对能在日志中捕获的幻觉,而不是那些无法预测的幻觉。
所有可调常量均位于 config.py:
| 参数 | 描述 | 默认值 |
|---|---|---|
LLM_MODEL |
用于提取和 Judge 的 Gemini 模型 | gemini/gemini-2.5-flash |
EMBEDDING_MODEL |
嵌入模型 | gemini/gemini-embedding-001 |
VECTOR_SIZE |
向量维度(Matryoshka) | 3072 |
MAX_TOKENS_DOC |
整篇发送文档的阈值 | 10_000 |
CHUNK_SIZE / OVERLAP |
分块大小和重叠量 | 8_000 / 500 |
TOP_K_RETRIEVAL |
从 Qdrant 检索的候选数量 | 10 |
DEFAULT_THRESHOLD |
最低聚合得分阈值 | 1.35 |
SINGLE_HIT_THRESHOLD |
最佳单个命中的后备阈值 | 0.75 |
EMBEDDING_BATCH_SIZE |
每次嵌入 API 调用中的问题数 | 100 |
QDRANT_UPSERT_BATCH |
每批 upsert 的点数量 | 256 |
| 问题 | 解决方案 |
|---|---|
找不到 GEMINI_API_KEY |
创建 .env 文件,并写入 GEMINI_API_KEY=... |
| 找不到集合 | 先运行 python ingest_to_qdrant.py |
| 分块中存在格式错误的 JSON | 检查 failed_chunks/ 文件夹中的原始文本 |
| LLM 返回空响应 | 可能触发 Gemini 安全拦截;尝试缩减或修改源文本 |
| Qdrant 锁文件 | 客户端会自动关闭;如发生崩溃,请手动删除 qdrant_data/ 中的锁文件 |
- LiteLLM 被用作统一代理,通过 Gemini 调用补全和嵌入能力。
path=模式下的 Qdrant Client 将所有内容存储在本地文件中:无需服务器进程。- Qdrant 点 ID 是确定性的 UUID5(
uuid5(NAMESPACE_DNS, answer_id:question_text)),因此重新运行摄取流程不会产生逻辑重复项。
本项目采用 **GNU Affero General Public License v3.0(AGPLv3)**许可。
详情请参阅 LICENSE。
English / 原文
RAGless — Deterministic Retrieval-Only Q&A System
RAGless is a retrieval-only question-answering system with zero LLM calls at runtime. Source documents are converted into self-contained informational blocks, indexed into a local vector database (Qdrant), and queried via asymmetric Gemini embeddings.
Zero hallucinations at runtime. Minimal latency. Near-zero cost per query.
The project consists of three independent scripts:
| Script | Purpose |
|---|---|
prepare_data.py |
Extracts Q&A blocks from source documents using Gemini in JSON mode |
ingest_to_qdrant.py |
Generates embeddings and populates the local Qdrant vector database |
chatbot.py |
CLI chatbot that retrieves the most relevant answers for a user query |
- No LLM at runtime — The chatbot relies purely on vector retrieval. No expensive API calls during user interaction.
- Robust Q-Q matching via
answer_idaggregation — If multiple question variants for the same answer match the query, their scores are summed. This makes the result far more stable than classic "top-1" retrieval. - Asymmetric Gemini embeddings —
RETRIEVAL_DOCUMENTat ingestion time andRETRIEVAL_QUERYat retrieval time, as recommended by Google. - Embedded Qdrant — No Docker server, no cloud service. Data is stored locally on disk (
./qdrant_data). - Smart chunking — Documents are chunked only if they exceed a token threshold, measured with the model's real tokenizer.
- Optional Judge verification —
prepare_data.pycan enable a second LLM pass to discard blocks not supported by the source text (--judge). - Missed query logging — Below-threshold queries are automatically logged to
missed_queries.logfor later analysis. - Guaranteed idempotency — Every run of
ingest_to_qdrant.pyrecreates the collection from scratch, preventing hidden duplicates.
- Python 3.10+
- Gemini API Key (free tier with generous limits)
- Python dependencies (see Installation section)
-
Clone or download the repository and navigate to the project folder.
-
Create a virtual environment (recommended):
python -m venv venv source venv/bin/activate # Linux/macOS # or venv\Scripts\activate # Windows -
Install dependencies :
pip install litellm qdrant-client pypdf python-dotenv tqdm -
Configure your API key :
cp .env.example .env # Edit .env and insert your GEMINI_API_KEY
.
├── source/ # Folder with source documents (.pdf, .txt, .md)
├── config.py # Centralized configuration (models, thresholds, paths)
├── prepare_data.py # Script 1: Q&A block extraction
├── ingest_to_qdrant.py # Script 2: embedding and indexing
├── chatbot.py # Script 3: CLI chatbot
├── data.json # Output of prepare_data.py (validated blocks)
├── qdrant_data/ # Local vector database (auto-created)
├── failed_chunks/ # Chunks that failed to produce valid JSON (debug)
└── missed_queries.log # Log of below-threshold queries
1. Prepare the data (prepare_data.py)
Place your documents in the source/ folder (supports .pdf, .txt, .md), then run:
With optional Judge verification (slower but more accurate):
python prepare_data.py --judge
What it does:
- Reads each file and counts tokens.
- If the document is short (≤ 10,000 tokens), sends it whole to the LLM; otherwise splits it into chunks.
- Extracts JSON blocks with
answer,questions,category,source_quote. - Validates blocks and saves them to
data.json.
2. Index into the vector database (ingest_to_qdrant.py)
python ingest_to_qdrant.py
What it does:
- Loads
data.json. - "Explodes" each block into as many rows as its question variants.
- Generates embeddings in batches via LiteLLM + Gemini.
- Recreates the Qdrant collection and inserts vectors with deterministic UUID5s.
3. Launch the chatbot (chatbot.py)
Available options:
python chatbot.py --threshold 0.75 # Change the minimum aggregated score threshold
python chatbot.py --debug # Show internal scores and aggregation table
Interaction:
You> How does check-in work?
[INFO] Found 2 relevant answers, showing top 1:
──────────────────────────────────────────────────────────────────────
--- Answer 1 (Pertinence: 1.85) ---
Check-in is available from 3:00 PM to 8:00 PM. If you arrive after 8:00 PM,
please contact reception in advance...
──────────────────────────────────────────────────────────────────────
Source: source/regulations.txt
Type exit, quit, or :q to leave.
The core of the chatbot is aggregation byanswer_id:
- The user query is embedded with
task_type=RETRIEVAL_QUERY. - Qdrant returns the
TOP_K_RETRIEVALmost similar points (questions). - Scores of points pointing to the same
answer_idare summed. - A candidate is shown only if:
- the aggregated score exceeds
DEFAULT_THRESHOLDOR - the best single hit exceeds
SINGLE_HIT_THRESHOLD - AND the best single hit is > 0.68 (minimum quality)
- the aggregated score exceeds
This mechanism makes the system robust: even if no single question variant is a perfect match, the sum of multiple weak matches on the same answer can make it emerge correctly.
RAGless vs. Classic Generative RAG
Classic RAG generates answers at runtime by retrieving context and prompting an LLM to synthesize a response. RAGless eliminates the generative step entirely. Answers are pre-generated during ingestion and retrieved verbatim at runtime.
| Advantage | Explanation |
|---|---|
| Much simpler pipeline | No prompt engineering for answer generation, no context window management, no output parsing. Just embed, search, return. |
| Q-Q matching is far more reliable | Matching query-to-question (Q-Q) is semantically easier and more robust than query-to-document-chunk (Q-D) or query-to-answer (Q-A). Multiple question variants per answer provide redundancy. |
| Determinism | Same question, same answer, always. Behavior is reproducible and testable. |
| Zero hallucinations at runtime | No LLM generates answers at query time. Returned text is pre-generated and immutable. |
| Zero cost per query | After ingestion, there are no API calls. Retrieval is purely local computation. |
| Low latency | Query embedding + vector search. Milliseconds, not seconds. |
| Verifiable answers | Every block has source_quote and source_file. Complete audit trail. |
| Reproducible bugs | If an answer is wrong, it is 100% wrong. Easy to find and fix. |
| Finite output surface | The number of possible answers is known (data.json). Exhaustively testable. |
| Runs on modest hardware | Embedded Qdrant, no LLM in memory. Works on CPU with a few GB of RAM. |
| Total privacy | No user data leaves the machine after ingestion. |
| No dependency drift | The embedding model can change, but the answers do not. You are not tied to an LLM provider's availability or pricing. |
Trade-offs and Limitations
| Limitation | Explanation |
|---|---|
| No real-time flexibility | Cannot synthesize novel answers, combine information across blocks, or adapt tone dynamically. What you ingest is what you get. |
| Higher ingestion cost | Using an LLM to generate Q&A blocks costs more than simple embedding. See cost comparison below. |
| Coverage bounded by ingestion | If a topic was not extracted during prepare_data.py, the system cannot answer it. No "reasoning" around gaps. |
| Maintenance requires re-ingestion | Updating answers requires re-running the full pipeline, not just editing a prompt. |
Cost & Performance Comparison
| Metric | Classic Generative RAG | Q-Q System (This Project) | Difference / Advantage |
|---|---|---|---|
| Ingestion Cost (One-time) | ~$0.01 (embedding 100,000 tokens only) | ~$1.50 (LLM generates Q &A from 100,000 tokens) | +$1.49 (Initial disadvantage, but negligible cost) |
| Runtime API Cost (Monthly) | ~$157.50 (1,000 queries/day, ~1,000 context tokens + 150 output tokens) | $0.00 (only embedding 1,000 short queries, < $0.02/month) | ~$157.50/month saved (Zero-cost scalability) |
| Latency (per query) | 2.5 – 4 seconds (LLM text generation) | ~0.15 seconds (pure vector search) | > 15x faster (Instant response) |
| Hallucination Rate (Runtime) | Low, but always > 0% | 0% | Risk eliminated (Static, deterministic output) |
The Hallucination Trade-off
RAGless eliminates hallucinations at runtime , where they are most dangerous because they are uncontrollable. The risk during ingestion still exists, but it is mitigated — and crucially — it happens offline, in a controlled environment, with the possibility of human review before the knowledge base goes to production.
RAGless shifts the hallucination risk from runtime to ingestion. Generation happens during prepare_data.py, which is why the optional --judge pass and deterministic UUIDs for idempotency were added.
The trade-off is: you give up real-time flexibility in exchange for offline verifiability. For high-risk domains, I prefer hallucinations I can catch in a log over ones I cannot predict.
All tunable constants are in config.py:
| Parameter | Description | Default |
|---|---|---|
LLM_MODEL |
Gemini model for extraction and judge | gemini/gemini-2.5-flash |
EMBEDDING_MODEL |
Embedding model | gemini/gemini-embedding-001 |
VECTOR_SIZE |
Vector dimension (Matryoshka) | 3072 |
MAX_TOKENS_DOC |
Threshold for sending whole document | 10_000 |
CHUNK_SIZE / OVERLAP |
Chunk size and overlap | 8_000 / 500 |
TOP_K_RETRIEVAL |
Candidates retrieved from Qdrant | 10 |
DEFAULT_THRESHOLD |
Minimum aggregated score threshold | 1.35 |
SINGLE_HIT_THRESHOLD |
Fallback threshold on best single hit | 0.75 |
EMBEDDING_BATCH_SIZE |
Questions per embedding API call | 100 |
QDRANT_UPSERT_BATCH |
Points per upsert batch | 256 |
| Problem | Solution |
|---|---|
GEMINI_API_KEY not found |
Create .env file with GEMINI_API_KEY=... |
Collection not found |
Run python ingest_to_qdrant.py first |
| Malformed JSON in chunks | Check the failed_chunks/ folder for raw text |
| Empty LLM response | Possible Gemini safety block; try reducing or modifying source text |
| Qdrant lockfile | Client closes automatically; in case of crash, manually remove the lock in qdrant_data/ |
- LiteLLM is used as a unified proxy to call Gemini for both completions and embeddings.
- Qdrant Client in
path=mode stores everything in local files: no server process is required. - Qdrant point IDs are deterministic UUID5s (
uuid5(NAMESPACE_DNS, answer_id:question_text)), so re-running ingestion does not create logical duplicates.
This project is licensed under the GNU Affero General Public License v3.0 (AGPLv3).
See LICENSE for details.
