<!-- generated by build_articles.py — edit content/answers/improve-basic-faiss-rag-pipeline.md instead -->
---
title: "Improve a basic FAISS RAG pipeline"
canonical: "https://xerj.org/answers/improve-basic-faiss-rag-pipeline"
updated: "2026-08-23"
source: "content/answers/improve-basic-faiss-rag-pipeline.md"
---

# How do I improve a basic FAISS RAG pipeline?

**TL;DR** — Load the chunk store into one XERJ index with a `semantic_text` field. Retrieve with the `hybrid` clause instead of vectors alone. Our 20-chunk migration indexed cleanly, and the two request shapes a FAISS user writes first both returned HTTP 400.

## Agent prompt

```text
Act as a coding agent. Read https://xerj.org/llms.txt, load an existing chunk store into one XERJ index with a semantic_text content field, then run retrieval as a hybrid clause nested inside query with fusion rrf. Report the exact 400 body for any request shape that fails.
```

## Commands

### Command 1

Note: Create the migration index. Port 9440 is the capture's Elasticsearch-compatible listener; the default is 9200.

```sh
curl -s -XPUT 'http://127.0.0.1:9440/faiss20' -H 'content-type: application/json' -d '{"mappings":{"properties":{"chunk_id":{"type":"keyword"},"source":{"type":"keyword"},"content":{"type":"semantic_text"},"chunk_index":{"type":"long"}}}}'
```

### Command 2

Note: The vector-only request a FAISS user writes first. This one returns HTTP 400.

```sh
curl -s -XPOST 'http://127.0.0.1:9440/faiss20/_search' -H 'content-type: application/json' -d '{"knn":{"field":"content_vector","query_vector_builder":{"text_embedding":{"model_text":"how does the index recover after a restart"}},"k":3,"num_candidates":20}}'
```

### Command 3

Note: Read back the companion content_vector field that XERJ added for the semantic_text field.

```sh
curl -s -XGET 'http://127.0.0.1:9440/faiss20/_mapping'
```

## Move the chunk store into one index

A basic FAISS pipeline keeps vectors in one place and the text somewhere else, which forces a join at query time. XERJ removes that join. The node embeds a `semantic_text` field at write time and keeps the companion `<field>_vector` in the same document.

Our capture created one index named `faiss20` with the mapping below, then bulk-loaded 20 chunks. The response reported `"errors": false` and 20 items with `"result": "created"`.

```json
{"mappings":{"properties":{
  "chunk_id":{"type":"keyword"},
  "source":{"type":"keyword"},
  "content":{"type":"semantic_text"},
  "chunk_index":{"type":"long"}}}}
```

## Both first attempts returned 400

Our capture then ran the two requests a FAISS user reaches for, and kept both refusals. Neither is a configuration mistake that a retry fixes.

The vector-only request passed a query string through the Elasticsearch `query_vector_builder` idiom.

```text
parse error: `knn` requires `query_vector` (or `vector`) as a float array
```

The hybrid request placed `hybrid` at the top level of the body, beside `size`.

```text
Unknown key for a START_OBJECT in [hybrid].
```

## The shapes that work

The `hybrid` clause is an extension that XERJ adds to the Elasticsearch DSL, and it belongs inside `query`. Each entry in `queries` is a `{query, weight}` pair, and `fusion` takes `rrf` or `linear`. XERJ rejects `fusion: "learned"` with an HTTP 400 at parse time, so there is no learned or trained option.

The kNN sub-query needs a literal embedding. Index the query text into a `semantic_text` field, read the companion `<field>_vector` value out of `_source`, and paste it into `query_vector`. The empty array below marks that position.

```json
{"query":{"hybrid":{"queries":[
  {"query":{"match":{"content":"how does the index recover after a restart"}},"weight":1.0},
  {"query":{"knn":{"field":"content_vector","k":3,"num_candidates":20,"query_vector":[]}},"weight":1.0}],
  "fusion":"rrf"}},"size":3}
```

## What vector-only retrieval missed

Our judged run used the same 20 documents in a second index and published every miss. On the query `car` the vector clause returned `Car servicing checklist`, `Coffee bean storage` and `Inverted index basics`, and it missed both judged synonyms. The default embedder explains that result: lexical feature hashing rather than a neural model.

On the question `how does the index recover after a restart`, BM25 alone ranked an irrelevant document first. The fused `rrf` response returned `d08`, `d04` and `d10`, with the judged-relevant `d08` in position 1.

| mode | precision at 3 | recall |
| --- | --- | --- |
| BM25 | 0.375 | 0.6875 |
| kNN only | 0.4167 | 0.7083 |
| hybrid `rrf` | 0.4167 | 0.7083 |
| hybrid `linear` | 0.4583 | 0.7708 |

## Fix the embedder before you tune the fusion

Fusion cannot recover a document that neither list returned. The same 8 queries under `--embed-mode neural` reached precision at 3 of 0.6667 and recall of 1.0, against 0.4167 and 0.7083 on the lexical default.

Change the embedder first, then tune weights. The neural embedder is a downloaded MiniLM-class model, runs CPU-only at about 15 docs/s on short strings, and makes an autoindex state non-resumable.

## Chunking and the limits of the hybrid clause

XERJ chunks a `semantic_text` field automatically at 512 characters with a 64-character overlap, splitting at sentence breaks first. The field must be top-level, because a nested `semantic_text` field is a mapping error.

The `hybrid` clause carries real limits worth knowing before you build on it. Sub-queries run sequentially. The sub-request zeroes `sort`, `highlight`, `collapse`, `rescore`, `min_score`, `explain`, `script_fields`, `fields` and `profile`, and the result has no aggregations and no `max_score`.

## What this capture does not show

The migration fixture is 20 chunks on one shared single-node host. The two failing requests are the whole of the `faiss20` retrieval evidence in this pass. The working hybrid response quoted here comes from the sibling judged index, which holds the same 20 documents under different field names.

## FAQ

### Why is my FAISS RAG pipeline missing obvious answers?

A vector-only pipeline has no term signal. On our judged set BM25 found literal tokens that the vector clause missed, and fusion recovered both.

### How do I migrate a chunk store into XERJ?

Create one index with a `semantic_text` content field, then `_bulk` the chunks in. Our 20-chunk migration reported `"errors": false` and 20 created items.

### Why does my kNN request return 400?

XERJ rejects `query_vector_builder` and needs a literal embedding in `query_vector`. Index the query text first, then read the companion vector out of `_source`.

### Why does my hybrid request return 400?

The `hybrid` clause must be nested inside `query`. A top-level `hybrid` key returns `Unknown key for a START_OBJECT in [hybrid].`

### Does XERJ chunk long documents for me?

Yes, for `semantic_text` fields: 512 characters with a 64-character overlap, split at sentence breaks first. The field must be top-level, not nested.

### Can I highlight results from a hybrid query?

No. The hybrid sub-request zeroes `highlight`, `sort`, `collapse`, `rescore`, `min_score`, `explain` and `profile`, and the result carries no aggregations.

### Will fusion fix a weak embedder?

Partly. Weighted `linear` fusion reached 0.4583 precision at 3 on the lexical default, against 0.6667 for every vector-backed mode under `--embed-mode neural`.

## Related

- [How do I build RAG without a vector database?](/answers/rag-without-vector-database)
- [What is Reciprocal Rank Fusion?](/answers/reciprocal-rank-fusion-when-to-use)
- [What's the actual difference between a vector database and full-text search when I have a folder?](/answers/vector-database-vs-full-text-search)
