This article examines when vector-only retrieval is enough for science and technology books, and when useful evidence can be missed. RAGLibrarian adds complementary retrieval methods, fuses their ranked candidates with hybrid RRF, and benchmarks whether extra retrieval improves evidence coverage.

Key Takeaways:

  • The candidate set comes first, and if evidence is missed, later RAG stages cannot bring it back.
  • Exact strings can be a weak signal for vectors. Broad concepts may be better represented by a full book rather than a chunk, so RAGLibrarian brings together several candidate sources and fuses them by rank.
  • The same filters and active-generation visibility rules apply throughout the retrieval paths. A document match still has to be hydrated into evidence. 
  • The tests show that the mechanism works, including lexical recovery and deterministic ordering. They don’t show that hybrid retrieval improves relevance
  • More candidate sources bring another index, an extra query, candidate budgets, and possible noise. For vector-only and hybrid retrieval, index construction and candidate budgets need to stay the same. Recall@5 is then used to test the improvement hypothesis. 

This article is Part 2 of a three-part series on improving RAG for science and technology books. Continue with Part 3: Validating LLM Citations in Application Code or return to Part 1: Improving RAG Retrieval with Chapter-Aware Chunking in Go. You can also explore RAGLibrarian on GitHub.

Vector search is an appealing default choice for RAG: embed the question, compare it with passage embeddings, and retrieve the closest passages. It works for paraphrases and concept similarity without making the reader remember the author’s wording exactly.

Science and technology books present issues:  error codes, API names, commands, acronyms, and version numbers. Exact spelling of such strings matters more than semantics, and they also may need to be retrieved without conceptual similarity. Some broad concepts can also be better represented in a full book than in a chunk.

The practical issue is whether a book retrieval system should rely on either chunk vectors or multiple retrieval views. The answer is that vector-based retrieval remains  the most basic solution, while a hybrid pipeline can provide complementary candidates without assuming that lexical, chunk-based, and document-based scores belong to the same scale. RAGLibrarian combines these candidates using reciprocal rank fusion (RRF), but it needs benchmarking to show any gains in retrieval quality.

Why Retrieval Is the Bottleneck to Improve

Retrieval imposes an upper bound on all future RAG stages. The assessor may discard an incorrect candidate and the answer model may generate an abstract for a correct candidate, but neither can quote any passage that was not included in the bounded candidate set. Should the initial top-k search fail to find the evidence, no better prompting will fix that omission.

The goal for enhancement, then, is candidate coverage: raise the probability that evidence relevant to the question ends up among the top five candidates without compromising filters, visibility, or deterministic ordering. For science and technology books, this can be difficult due to each one presenting multiple kinds of queries:

Query shapeWhy one view can missComplementary signalWorking hypothesis
Exact identifier such as REPLA short code carries little semantic context for an embeddingPostgreSQL lexical searchExact token matches may recover evidence omitted by dense top-k
Paraphrased conceptThe query and passage may share few literal wordsChunk-vector similaritySemantic proximity may recover vocabulary mismatches
Broad book-level topicNo single chunk represents the complete document wellDocument centroid plus bounded chunk hydrationA coarse document match may expose useful local passages
Evidence strong in several viewsIndependent lists may rank the same passage differentlyRank fusion by evidence IDAgreement across lists may help the passage survive the final cutoff

These are hypothetical statements about complementary failure modes, not empirical observations. This is why the system is designed to generate more candidates first, then reduce them. Recall@5 measure will show how often the extra candidate paths help retrieve relevant data and how often they contribute noise instead.

This is a different aspect of information retrieval, not a factor for choosing the winning backend. RAGLibrarian has three candidate sources:

Candidate sourceIndexed unitUseful signalMain limitation
Chunk vectorOne bounded passageSemantic similarity at evidence levelExact identifiers may be weak signals
Document vectorNormalized centroid for one indexed book generationBroad document-level similarityMust be hydrated with actual passages
PostgreSQL lexicalTitle, author, structure, and passage textExact terms and token combinationsVocabulary mismatch can hide paraphrases

The fourth comparison arm is the hybrid pipeline, which runs all three sources, merges their ranked candidates, and applies the same downstream visibility and evidence rules.

The Design Pattern: Multi-Retriever Late Fusion

During indexing, chunk embeddings are indexed as chunk points in Qdrant, while their normalised centroid embedding is indexed as a document point. The evidence embeddings are saved in PostgreSQL, where a GIN index enables performing full-text search on those projections. All three reads run simultaneously within the request context.

The general approach in a modern retrieval strategy for RAG systems with multiple sources of truth is multi-retriever late fusion. Every retriever generates an ordered list based on the scoring model used by its underlying database (Qdrant or PostgreSQL full-text search). The application neither combines cosine similarity and ts_rank_cd early nor treats them as probabilities. Only after all sources have sorted their own candidates does the application combine them based on their positions rather than scores.

There are several software design patterns that can facilitate this scenario:

Building blockPattern or algorithmHow it is used here
Retriever interfacesPorts-and-adapters with Strategy-shaped portsQdrant and PostgreSQL details stay outside application orchestration
Chunk, document, and lexical readsFan-out/fan-inIndependent reads start concurrently and join before fusion
IndexVisibilityActive-generation integrity boundaryEvery candidate path is checked against the active indexed generation
Document centroidMean pooling followed by vector normalizationSelects promising books, which are then hydrated with bounded chunk evidence
Candidate fusionReciprocal Rank FusionAggregates ranked lists by stable evidence ID without score calibration

In Go, the Strategy shape is expressed through small interfaces and composition rather than an inheritance hierarchy:

// Shortened application ports.

type ChunkRetriever interface {
  Retrieve(context.Context, domain.SearchQuery, []float32, RetrievalPlan) ([]Evidence, error)
}
type DocumentRetriever interface {
  Retrieve(context.Context, domain.SearchQuery, []float32, RetrievalPlan) ([]DocumentResult, error)
}
type LexicalRetriever interface {
  Retrieve(context.Context, domain.SearchQuery, RetrievalPlan) ([]Evidence, error)
}
type CandidateFusion interface {
  Fuse(domain.SearchQuery, []Evidence, []DocumentResult, []Evidence) []Evidence
}

In the startup phase, the constructor will initialise the store-backed strategies and RRF; it’s not an algorithm selector on a per-request basis. The APIs make orchestration testable and ensure that Qdrant, PostgreSQL, or fusion expertise is confined to search usage only.

The APIs do different things. Search will validate the actor in question before fetching any candidates. Author, tag, and year are query constraints, not access constraints. IndexVisibility ensures lifecycle integrity by preventing any evidence from being taken outside of the currently active index generation; it’s not an access control list for the book.

One of the significant boundaries illustrated in the diagram is the fact that document vectors can never return any answer evidence by themselves. The SearchDocuments operation first gets document points and then executes a bounded batch search to get chunk points of those job IDs which are already indexed. Unusable documents are ignored.

The document vector is the normalised vector after taking the average of chunk vectors of book generation. The document vector is purposefully made to be a coarse discovery vector, and not proof that one point covers all the topics in the book. Hydration takes the coarse match and turns it into bounded passages.

Step 1: Keep the Lexical Index Simple

The lexical projection makes use of the simple text-search configuration for PostgreSQL. This prevents language stemming from altering the identifier name, and indexes bibliographic fields as well as passages.

Below is the current migration, stripped of the grant only:

CREATE INDEX IF NOT EXISTS retrieval_evidence_lexical_search_idx
ON retrieval.evidence
USING GIN (
    to_tsvector(
        'simple',
        title || ' ' || author || ' ' || chapter || ' ' || section || ' ' || passage
    )
);

During the search, websearch_to_tsquery parses the bounded user question and ts_rank_cd orders matches. The query joins the active book lifecycle and an indexed job, so a lexical hit from an obsolete or unfinished generation is not eligible.

WITH lexical_query AS (
    SELECT websearch_to_tsquery('simple', $1) AS tsquery
)
SELECT e.evidence_id,
       ts_rank_cd(lexical_document.tsvector, lexical_query.tsquery) AS lexical_rank
FROM retrieval.evidence e
JOIN retrieval.index_jobs j ON j.id = e.job_id
JOIN retrieval.book_lifecycle l
  ON l.book_id = e.book_id AND l.active_job_id = e.job_id
CROSS JOIN lexical_query
CROSS JOIN LATERAL (
    SELECT to_tsvector(
        'simple', e.title || ' ' || e.author || ' ' ||
        e.chapter || ' ' || e.section || ' ' || e.passage
    ) AS tsvector
) lexical_document
WHERE j.state = 'indexed'
  AND l.state IN ('active', 'reindexing')
  AND lexical_query.tsquery @@ lexical_document.tsvector
ORDER BY lexical_rank DESC, e.evidence_id;

This is not an additional and less-governed search API, but uses the same validated query and filters as the vector search API.

There is only one asymmetry in scoring. Dense chunk candidates and hydrated document evidence are required to score above MinimumVisibleScore, while lexical candidates do not follow this vector scoring criterion because cosine similarity and lexical ranking operate on different scales, and applying a 0.6 threshold to both is not productive. Lexical evidence is still subject to PostgreSQL query filtering and visibility checks.

Step 2: Enforce Filters in Every Candidate Source

A hybrid system can return incorrect results even when fusion is correct. If one backend forgets an author, tag, year, or lifecycle constraint, fusion may promote a candidate outside the requested filters or active index generation.

RAGLibrarian maps the same normalised filters into both stores:

ConstraintChunk vectorDocument vector and hydrationLexical search
Active indexed generationindexed=true payload plus visibility checkSame payload rule; hydration is bounded by job_idLifecycle joins and j.state=’indexed’
Authorauthor_normalized exact matchSame filter on document and chunk queriesNormalized exact comparison
TagsOne required match per normalized tagSame filter on document and chunk queriesOne EXISTS condition per tag
Publication yearInclusive Qdrant rangeSame inclusive range>= year_from and <= year_to
Vector kindchunkdocument, then chunk during hydrationNot applicable

The application layer applies an extra filter to each of the candidate types using the IndexVisibility port. The chunk-vector and lexical retriever continue paging with an allocated candidate budget even if hidden candidates use up the initial page. In document retrieval, only one limited page is filtered without any backfilling. The extra check for visibility is necessary when re-indexing occurs, as both the old and the new physical index exist temporarily, but only one generation works at a time.

Step 3: Retrieve Concurrently, but Fail as One Request

Since the candidates are independent retrievals, doing them in sequence would just sum up their response times. The application is using errgroup.WithContext to initiate all the retrievers simultaneously.

group, retrievalContext := errgroup.WithContext(ctx)
group.Go(func() error {
  var err error
  chunkCandidates, err = s.chunkRetriever.Retrieve(
    retrievalContext, query, vector, plan,
  )
  return err
})
group.Go(func() error {
  var err error
  documentCandidates, err = s.documentRetriever.Retrieve(
    retrievalContext, query, vector, plan,
  )
  return err
})
if s.lexicalRetriever != nil {
  group.Go(func() error {
    var err error
    lexicalCandidates, err = s.lexicalRetriever.Retrieve(
      retrievalContext, query, plan,
    )
    return err
  })
}
if err := group.Wait(); err != nil {
  return nil, nil, nil, err
}

The common context removes the sibling effort after an error. The implementation does not implicitly rename an incomplete result into a hybrid result. Candidate page sizes and budgets are part of SearchPolicy, not hidden constants within the loop.

Concurrency is a design issue, not a latency effect. This document has not evaluated the end-to-end latency for the four comparisons.

Step 4: Use Reciprocal Rank Fusion Instead of Raw Scores

Qdrant similarity and PostgreSQL ts_rank_cd do not have a common numerical value. Normalizing them with an arbitrary multiplier would make ranking depend on score distributions and model changes.

RRF is a well-known information-retrieval algorithm introduced by Cormack, Clarke, and Buettcher in the original paper. It is an unsupervised late-fusion method: it needs ordered result lists, but it does not need training labels or comparable backend scores.

The standard formula sums a reciprocal contribution from each list in which an item appears. RAGLibrarian uses the same formula:

contribution = 1 / (K + rank + 1)
fused score  = sum of contributions for the same evidence ID

Published definitions usually define ranking as one-based, while Go pieces are zero-based; hence the local rank + 1 is merely a representation issue rather than an additional formula. K is a constant for smoothing and not the end result: lower values emphasise differences at the top of the rankings more, whereas higher values downplay such differences. Here, K is backed by configuration, having 60 as the current default value.

For K=60, take into account this illustrative, not measurement-based, set of ranked candidates:

EvidenceChunk-vector rankLexical rankCalculationIllustrative fused score
e-repl201/63 + 1/610.03227
e-election01/610.01639
e-index11/620.01613

The candidate supported by two retrievers dominates those supported by just one. This is precisely why the RRF algorithm supports the improvement hypothesis: A candidate that is missed by one retriever can be brought in by another, while agreement between the retrievers strengthens the case for its presence at the top. The table illustrates how the formula works. It is not a retrieval outcome.

How RAGLibrarian Adapts the Textbook Algorithm

The reciprocal contribution is standard RRF. The way RAGLibrarian constructs candidate lists and returns evidence reflects several project-specific choices:

ConcernStandard RRF ideaRAGLibrarian choice
Candidate identityAccumulate contributions for the same ranked itemUse stable EvidenceID across all three paths
Document-level hitsUsually assumes flat ranked listsHydrate a document hit into real chunks before fusion
Hydrated chunk rankNot defined by canonical RRFUse len(chunkCandidates) + documentRank + evidenceRank
Returned scoreThe RRF score determines final orderingKeep fused score internal and return the numerically highest raw source score for the selected payload
Exact tiesImplementation-dependentCompare fused score, then raw score, then EvidenceID
Fusion constantChosen for the experiment or systemRead K from SearchPolicy instead of hardcoding it

Document evidence also contributes to fusion. Its rank is offset first by the number of chunk candidates, then by the document rank and the evidence rank within that document. That keeps hydrated document evidence from being treated as another rank-zero list. Lexical candidates use their own list rank. The document offset is a local ranking policy, not part of canonical RRF, and different document/evidence rank pairs can produce the same offset.

The core merge is small:

merge := func(candidate Evidence, rank int) {
  if candidate.EvidenceID == "" {
    return
  }
  contribution := 1.0 / float64(k+rank+1)
  current := merged[candidate.EvidenceID]
  current.score += contribution
  if current.evidence.EvidenceID == "" || candidate.Score > current.evidence.Score {
    current.evidence = candidate
  }
  merged[candidate.EvidenceID] = current
}

RRF controls ordering only. When the same evidence appears more than once, the returned payload is the one with the numerically highest raw source score. That does not make Qdrant and PostgreSQL scores comparable; it is only a deterministic payload-retention rule. The raw score is not overwritten with the fused score. Exact fused-score ties fall back to the raw score and then EvidenceID, making the order deterministic.

What the Tests Actually Prove

The focused application tests use synthetic, content-safe evidence. They validate the control flow and ordering invariants without making any claims about improvements in relevance. This reduced version of a production test demonstrates lexical recovery where the dense list is empty:

func TestSearcherUsesLexicalCandidatesWhenDenseCandidatesMiss(t *testing.T) {
  embedder := &stubEmbedder{
    vector: make([]float32, domain.EmbeddingDimensions),
  }
  store := &stubEvidenceStore{}
  lexicalStore := &stubLexicalEvidenceStore{
    results: []Evidence{{
      EvidenceID: "lexical-evidence-1",
      JobID:      "job-1",
      BookID:     "book-1",
      Passage:    "Exact protocol code NX-42 appears only here.",
      Score:      0.12,
    }},
  }
  searcher := newTestSearcherWithLexical(t, embedder, store, lexicalStore, visibleIndexes{}, 4)
  result, err := searcher.Search(context.Background(), domain.Actor{
      UserID: "user-1", Role: "reader", Status: "active",
    }, domain.SearchQueryInput{Question: "NX-42", Limit: 2})
  if err != nil {
    t.Fatal(err)
  }
  if len(result.Evidence) != 1 || result.Evidence[0].EvidenceID != "lexical-evidence-1" {
    t.Fatalf("unexpected evidence: %#v", result.Evidence)
  }
}

The message and fixture texts have been reduced for clarity. Its dependencies and behaviour remain consistent with the production test. Other specific tests confirm that:

  • an evidence ID present s in the chunk, document, and lexical streams receives contributions and ranks first in the fixture;
  • the representation with the highest raw score is retained if the same evidence appears multiple times;
  • when exact ties occur in both RRF and raw scores, the evidence ID is used to determine the ranking across 100 tries;
  • the next page is retrieved only after the previous  page has been filtered for visibility;
  • the search continues through fused candidates until the required number of evidence items is reached.

When Vector-Only Retrieval Is Enough

Hybrid search increases complexity: a second index, a second query, additional candidate budgets, and fusion heuristics, all of which need to be justified. Vector-only indexing may be sufficient if the corpus is small, queries are mostly conceptual, identifiers are limited, and evaluation shows no measurable loss.

It also forms the basis for evaluating a hybrid approach. The benchmark should take into account the identical corpus snapshot, embedding function, chunking profile, filters, number of results, and relevance evaluations. If not, the fusion may turn out to benefit from differences in index construction or candidate budgets.

For RAGLibrarian, the primary metric will be macro-average Recall@5 of evidence IDs over a versioned science and technology book query set. For a single query, it is:

In a case where three evidence IDs are marked relevant, and two are seen in the first five, the Recall@5 is 2/3, i.e., around 0.67. Macro-averaging involves computing the Recall@5 value for each query and averaging the results, such that one query with many labels cannot distort the result. Queries where no evidence is marked relevant should be listed separately since the fraction has no denominator.

Recall@5 measures candidate coverage. It doesn’t differentiate between the first and the fifth rank and does not check the correctness of the generated answer. Therefore, the report needs to include per-query results, overlap between candidates and sources, as well as latency distribution for chunk-vector, document-vector, lexical, and hybrid RRF retrieval. Without such evidence, the implementation enables comparison, but cannot settle the question.

Limitations

There are well-defined limits to this system design:

  • PostgreSQL full-text search provides lexical retrieval, not typo-tolerant fuzzy searching or sparse retrieval.
  • A normalised document centroid might underrepresent an important but narrowly covered topic in a large book.
  • RRF is based on rewarding repetition of highly ranked items and does not care whether the passage is really relevant.
  • The way document rank is offset from the original rank is a design decision that should remain constant throughout an evaluation.
  • Fanout reduces sequential waiting time, but it also makes query completion dependent on all active candidates.
  • Validated filters limit the set of results returned, while visibility checks are fail-close when generation is inactive; neither mechanism replaces caller authorisation or semantic proof.

This is a list of motivations for measuring the system, not a reason to hide its design under a single “relevance score”.

Conclusion

Vector search is a good baseline system for science and technology books since it maps what a reader writes to semantically similar texts. Vector search is not the only useful signal. Exact identifiers, broad document topics, and paraphrases can require lexical search in PostgreSQL or book-level indexes.

RAGLibrarian uses all of these signals as complementary retrieval methods in a late-fusion retrieval pipeline. Parallel fan-out does not require a forced serialisation of the independent retrieval processes, active generation visibility checks are applicable to all retrieval paths, and standard RRF combines ranked results without assuming that different raw scores share a common relevance scale.

The improvement hypothesis is specific: if a relevant snippet ranks highly in at least one complementary stream, it is more likely to survive into the final top five. When retrieval streams share most of the same information, or the new stream contains noise rather than relevant information, Recall@5 might remain unchanged or decline while latency and cost increase. The current experiments prove the working mechanism and its deterministic limits; however, a comparison is needed to prove the assumption.

Continue to Part 3 of the series.

Visit our blog to read more articles.

If you need a reliable AI development partner, let’s connect.