跪拜 Guibai
← Back to the summary

Ingesting a 1.2M-Word Novel into a Vector DB for RAG, Step by Step

Building a Tian Long Ba Bu Knowledge Base from Scratch: EPUB Loading → Text Splitting → Vector Embedding → Milvus Storage → RAG Q&A, One Complete Pipeline

Foreword

Turning an EPUB ebook into a queryable knowledge base seems simple, but when you actually start building it, you'll find the chain is long: loading documents, splitting text, generating vectors, storing them in a vector database, retrieving relevant fragments, assembling prompts, and calling an LLM to generate answers. Every step has details worth investigating.

This article dissects this process line by line based on a real, runnable project. We use Jin Yong's "Tian Long Ba Bu" as the material, ingest the entire novel into a Milvus vector database, and then implement natural language Q&A—"What martial arts does Duan Yu know?" "What martial arts does Jiumozhi know?"—and the system can find the answers from the book.


I. Overall Architecture and Data Flow

The entire project consists of three modules, corresponding to three stages of data processing:

┌──────────────┐      ┌──────────────┐      ┌──────────────┐
│   main.mjs   │      │  query.mjs   │      │   rag.mjs    │
│ Data Ingestion│      │ Pure Vector  │      │  Complete RAG│
│              │      │   Search     │      │              │
└──────┬───────┘      └──────┬───────┘      └──────┬───────┘
       │                     │                     │
       │  EPUB → Split       │  Query → Embed      │  Query → Embed
       │  → Embed → Insert   │  → Search           │  → Search → LLM
       │                     │                     │
       ▼                     ▼                     ▼
┌─────────────────────────────────────────────────────────┐
│                Milvus Vector Database                   │
│           Collection: ebook2 (1024 dims)                │
└─────────────────────────────────────────────────────────┘
Module File Responsibility
Data Ingestion main.mjs Load EPUB → Split by Chapter → Chunk → Vectorize → Insert into Milvus
Vector Search query.mjs Receive question → Vectorize → Milvus Search → Return similar fragments
Complete RAG rag.mjs Receive question → Retrieve fragments → Assemble Prompt → LLM generates answer

In terms of workflow, run main.mjs first to ingest the data, then either query.mjs or rag.mjs can complete the Q&A—the difference is that query.mjs only returns original text fragments, while rag.mjs has the LLM organize a complete answer based on the fragments.


II. Environment Setup: Why This Tech Stack

First, look at the configuration in the .env file:

MODEL_NAME=qwen-plus
EMBEDDINGS_MODEL_NAME=text-embedding-v3
OPENAI_API_KEY=sk-...
OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
MILVUS_ADDRESS=https://in03-...cloud.zilliz.com.cn
MILVUS_TOKEN=0d72573cd8...

There are two noteworthy design choices here:

First, both the Embedding model and the Chat model use Alibaba Cloud DashScope. Note that OPENAI_BASE_URL points to Alibaba Cloud's compatible endpoint. This means the code uses the OpenAI-compatible API format (because LangChain's OpenAIEmbeddings and ChatOpenAI natively interface with OpenAI), but the actual calls go to Alibaba Cloud's Tongyi Qianwen model. This practice of "borrowing OpenAI's shell, running a domestic model's soul" is very practical in domestic development—no SDK changes needed, just swap the baseURL and key.

Second, Milvus uses a Zilliz Cloud Serverless instance. This eliminates the operational cost of deploying Milvus locally, suitable for development and small-scale use. The connection method is identical to a self-hosted Milvus, only the address and token come from the cloud.

Thought: The essence of this combination is using the OpenAI-compatible protocol as a universal interface layer—the Embedding model, Chat model, and vector database are decoupled, and any component can be independently replaced. For example, if you later want to switch the Embedding model to text-embedding-ada-002, you only need to change the model name in .env, without touching a single line of code.


III. Data Ingestion (main.mjs): Turning a Book into Vectors

main.mjs is the cornerstone of the entire system. It uses a pipeline to complete the "Book → Segments → Vectors → Database" transformation. Below, we dissect it step by step according to the code execution order.

3.1 Path Resolution: Extracting the Book Name from the Filename

import { parse } from 'path'; // path parsing

const EPUB_FILE = './天龙八部.epub';
const { name: BOOK_NAME } = parse(EPUB_FILE);
console.log(BOOK_NAME);  // Output: 天龙八部

path.parse() is a built-in Node.js method that breaks a file path into an object containing five fields: root, dir, base, ext, name. Here, ES6 destructuring is used to extract name (the filename without the extension) and rename it to BOOK_NAME.

// parse('./天龙八部.epub') returns:
{
  root: '',
  dir: '.',
  base: '天龙八部.epub',
  ext: '.epub',
  name: '天龙八部'   // ← This is what we extract
}

This BOOK_NAME will later be stored in Milvus as the book_name field, used to distinguish data from different books. This is a good habit: don't hardcode the book name in the code; derive it from the filename. When switching to another book later, you only need to change the EPUB_FILE path, and the book name automatically follows.

Note: Node.js's path.parse() does not verify if the file exists—it purely performs string-level path parsing. Whether the file exists is the subsequent EPubLoader's concern.

3.2 Initializing the Embedding Model

const embeddings = new OpenAIEmbeddings({
  apiKey: process.env.OPENAI_API_KEY,
  model: process.env.EMBEDDINGS_MODEL_NAME,   // text-embedding-v3
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL       // Alibaba Cloud compatible endpoint
  },
  dimensions: VECTOR_DIM   // 1024
});

VECTOR_DIM is set to 1024 because text-embedding-v3 supports dynamically specifying dimensions. The core principle of an Embedding model is mapping a piece of text to a point in a high-dimensional space:

Why not the more common 1536 dimensions (text-embedding-ada-002)? Because text-embedding-v3 allows on-demand dimensionality reduction via the dimensions parameter. Reducing to 1024 results in less than a 2% precision drop but reduces vector storage by 33%. In a million-word novel scenario, this trade-off is very worthwhile.

async function getEmbedding(text) {
  const result = await embeddings.embedQuery(text);
  return result;  // Returns [0.023, -0.451, 0.789, ...] a total of 1024 floats
}

Although this wrapper is simple, it wraps embeddings.embedQuery with an extra layer, isolating the dependency. If you later want to add caching ("don't call the API again if the same text has already been vectorized"), you only need to modify it inside this function, and external callers remain unaware.

3.3 Milvus Collection Design

const COLLECTION_NAME = 'ebook2'; // Programming habit

The comment "Programming habit" refers to using a constant name instead of a magic string. The name 'ebook2' appears in multiple functions (creating, querying, writing to the table). If written directly as a string literal, changing it would require a global search and replace, making it easy to miss. Extracting it into a constant means you only need to change it in one place.

Next is the Collection's field design—this is equivalent to a CREATE TABLE statement in a traditional database:

fields: [
  { name: 'id', data_type: DataType.VarChar,
    max_length: 100, is_primary_key: true },
  { name: 'book_id', data_type: DataType.VarChar,
    max_length: 100 },
  { name: 'book_name', data_type: DataType.VarChar,
    max_length: 200 },
  // Which chapter
  { name: 'chapter_num', data_type: DataType.Int32 },
  // Which data chunk
  { name: 'index', data_type: DataType.Int32 },
  { name: 'content', data_type: DataType.VarChar,
    max_length: 10000 },
  { name: 'vector', data_type: DataType.FloatVector,
    dim: VECTOR_DIM }
]

Design intent for each field:

Field Type Purpose Design Consideration
id VarChar(100) Primary key, format {bookId}_{chapter}_{index} String-concatenated primary key with built-in business meaning, easy to trace the source of each record
book_id VarChar(100) Book identifier Prepared for future expansion to multiple books
book_name VarChar(200) Book name Redundant storage, directly display the book name during search without needing a JOIN
chapter_num Int32 Chapter number 32-bit signed integer, range ±2.1 billion, more than enough for chapter counts
index Int32 Chunk sequence number Order of chunks within the same chapter, used to restore context
content VarChar(10000) Text content Each chunk is at most 500 characters (actually controlled by the splitter), 10000 is a safe upper limit
vector FloatVector(1024) Vector embedding Core field, mathematical representation of the text, used for cosine similarity retrieval

Design Thoughts:

The id format {bookId}_{chapterNum}_{chunkIndex} is a form of information encoding—just from the primary key, you can know which book, which chapter, and what position this fragment belongs to, without needing additional index lookups. This is very friendly for debugging and tracing.

chapter_num uses Int32 instead of Int8 or Int16 out of defensive programming: a novel might only have 50 chapters, but the code might be reused in other scenarios (like data by date, where a date number could be 20240101), leaving enough headroom to avoid overflow.

The dim: VECTOR_DIM for vector must strictly match the output dimension of the Embedding model. If these two values don't match—for example, the model outputs 1536 dimensions but Milvus defines 1024—the insertion will directly error. Here, using a single variable VECTOR_DIM passed to both OpenAIEmbeddings and the Collection definition ensures the numbers are always synchronized in both places.

3.4 Index Strategy: IVF_FLAT + COSINE

await client.createIndex({
  collection_name: COLLECTION_NAME,
  field_name: 'vector',
  // nlist is the number of clusters for K-Means
  index_type: IndexType.IVF_FLAT,
  metric_type: MetricType.COSINE,
  params: { nlist: 1024 }
})
// cosine high-dimensional similarity, not slow, when data volume is large

This step is key to retrieval performance.

The principle of IVF_FLAT index (Inverted File with Flat encoding) is a two-step process:

  1. During index building: Perform K-Means clustering on all vectors, dividing them into nlist clusters. Each cluster has a centroid.
  2. During querying: First calculate the distance from the query vector to each cluster centroid, then only perform brute-force full comparison within the nearest few clusters (controlled by the nprobe parameter).

This is equivalent to narrowing a full database scan down to a scan of a few candidate regions. For a data volume like "Tian Long Ba Bu" (the entire book is about 1.2 million characters, roughly 2400 records when chunked by 500 characters), this optimization is sufficient. The comment says "when data volume is large" an upgrade is needed, referring to considering more advanced indices like IVF_PQ or HNSW for million-plus record levels.

COSINE Cosine Similarity:

cosine(A, B) = (A·B) / (|A| × |B|)

The numerator is the vector dot product, and the denominator is the product of their respective magnitudes. This formula measures the closeness of the direction of two vectors, unaffected by vector length. Why choose COSINE over L2 (Euclidean distance)?

Intuitive understanding: Think of each vector as an arrow in high-dimensional space. L2 asks "how far is the arrow tip from me," Cosine asks "where does the arrow point." In semantic search, "where it points" is more important than "how far away it is."

Why is nlist set to 1024? This is an empirical choice. nlist too small → too many vectors per cluster → brute-force search within the cluster is still slow; nlist too large → too many clusters → overhead of finding centroids increases → and it might lead to omissions (a query's nearest neighbor gets assigned to an unsearched cluster). The usual recommendation is nlist = 4 × sqrt(N). For about 2400 records, 4 × sqrt(2400) ≈ 196. 1024 is a bit large, but for a Serverless cloud service, the overhead of having a few more clusters is negligible.

3.5 Loading EPUB: The Choice of splitChapters

const loader = new EPubLoader(EPUB_FILE, {
  // After loading, generates multiple documents by chapter
  // Necessity driven by memory requirements
  splitChapters: true
});
const documents = await loader.load();
console.log(`Loading complete, total ${documents.length} chapters`);

EPubLoader is a document loader provided by the LangChain community package. It internally relies on the epub2 npm package to parse the EPUB format—an EPUB is essentially a ZIP archive containing HTML/XHTML files, each file usually corresponding to a chapter.

The single option splitchapters: true determines the granularity of all subsequent data processing:

splitChapters: false → documents = [the entire book as one Document]
splitChapters: true  → documents = [Chapter 1, Chapter 2, Chapter 3, ... Chapter 50]

The comment "Necessity driven by memory requirements" points out an engineering fact: if you read the entire 1.2 million-word novel into memory and then perform a full Embedding... first, the Embedding API would directly reject it (exceeding token limits), and second, even if it could process it, the floating-point operations on 1.2 million words would crash the memory. Splitting by chapter is an inevitable choice driven by both memory and business needs.

Deep dive: EPubLoader internally calls epub2 to parse the EPUB file structure. Each chapter parsed by epub2 is a Chapter object containing information like title, content (HTML string), etc. EPubLoader converts these into LangChain Document objects, each Document having pageContent (plain text body) and metadata (meta-info like chapter title). This is why we can later use chapter.pageContent to get the text of each chapter.

3.6 Text Splitting: RecursiveCharacterTextSplitter

const textSplitter = new RecursiveCharacterTextSplitter({
  // If no separator is passed, the default \n is used
  chunkSize: CHUNK_SIZE,    // 500
  chunkOverlap: 50,         // Overlap 50 characters to maintain contextual coherence
});

The parameter design of the splitter embodies a core contradiction: chunk too large → retrieval not precise enough; chunk too small → fragments lose context.

Why is overlap needed? Consider this original text:

...段誉心中一惊,暗想:"这老僧内功竟如此深厚。"他缓缓起身,
双手合十,说道:"大师在上,晚辈段誉有礼了。"那老僧微微一笑,
浑浊的眼中闪过一丝精光...

Without overlap, the split might cut exactly between "段誉心中一惊..." and "双手合十...". When a user searches for "the dialogue between Duan Yu and the old monk," the relevant context is split across two chunks, greatly diminishing retrieval effectiveness. The 50-character overlap ensures that critical information doesn't fall exactly on a chunk boundary.

The splitting strategy of RecursiveCharacterTextSplitter (implementation at the source code level):

  1. First split by \n\n (blank lines between paragraphs)
  2. If the resulting chunk is too long, split by \n (newline)
  3. If still too long, split by (period)
  4. Continue descending with , ,
  5. Final fallback: hard cut by character count

This is a recursive degradation strategy: starting from the "most natural semantic boundary," gradually degrading to "pure length-based splitting." Each level tries to cut at a semantically complete point. The comment "If no separator is passed, the default \n is used" refers to this default separator sequence.

3.7 Chapter-by-Chapter Processing and Batch Insertion

for (let chapterIndex = 0;
  chapterIndex < documents.length; chapterIndex++) {
  // Document for this chapter
  const chapter = documents[chapterIndex];
  const chapterContent = chapter.pageContent;
  console.log(`Processing chapter ${chapterIndex + 1} / ${documentLen}...`);

  const chunks = await textSplitter.splitText(chapterContent);
  console.log(`Split into ${chunks.length} fragments`);

  if (chunks.length === 0) {
    console.log(`Skipping empty chapter\n`);
    continue;  // Skip empty chapters to prevent subsequent Insert API errors from receiving an empty array
  }

  console.log(`Generating vectors and inserting...`);
  const insertedCount = await insertChunksBatch(
    chunks,
    bookId,
    chapterIndex + 1   // Chapter numbers start from 1, array index starts from 0
  );
  totalInserted += insertedCount;
}

Design Analysis:

The loop uses the count information of documents three times:

The comment "Document for this chapter" reminds the reader: chapter is a Document object, the basic unit produced by the previous step EPubLoader.load().

The continue for empty chapter checking is important. Some EPUBs have empty chapters (like those containing only illustrations or title pages). If not skipped, the subsequent insertChunksBatch might receive an empty array, causing an API call exception.

3.8 Core: Parallel Vectorization and Batch Insertion

// Insert a batch of chunks into the vector database
async function insertChunksBatch(chunks, bookId, chapterNum) {
  try {
    // If empty, nothing to do
    if (chunks.length === 0) {
      return 0;
    }

    const insertData = await Promise.all(
      chunks.map(async (chunk, chunkIndex) => {
        const vector = await getEmbedding(chunk);
        return {
          id: `${bookId}_${chapterNum}_${chunkIndex}`,
          book_id: bookId,
          book_name: BOOK_NAME,
          chapter_num: chapterNum,
          index: chunkIndex,
          content: chunk,
          vector: vector
        }
      })
    );

    const insertResult = await client.insert({
      collection_name: COLLECTION_NAME,
      data: insertData
    });

    // The function's return result must be predictable and consistent.
    return Number(insertResult.insert_cnt) || 0;

  } catch(err) {
    console.error(`Error inserting data for chapter ${chapterNum}:`, err.message);
    throw err;
  }
}

This function is the most core and most exquisite part of the entire system. Let's analyze it layer by layer.

Layer 1: Parallel Embedding

const insertData = await Promise.all(
  chunks.map(async (chunk, chunkIndex) => {
    const vector = await getEmbedding(chunk);
    // ...
  })
);

This segment issues all Embedding requests for all chunks simultaneously. Suppose a chapter is split into 20 chunks; the 20 Embedding API calls are initiated in parallel, and the total time ≈ the duration of a single call (rather than the cumulative time of 20 serial calls).

Why can they be parallel? Because the Embedding API is stateless—each request is independent and does not depend on the vector result of other chunks. This scenario is the most typical application of Promise.all: concurrent execution of independent IO operations.

Cost and Risk: Too high a degree of parallelism might trigger the API's rate limiting. However, for services like Alibaba Cloud DashScope, the default QPM limit is usually in the hundreds to thousands, and the concurrency of a few dozen chunks per chapter is well within the safe range.

Layer 2: Primary Key Design

id: `${bookId}_${chapterNum}_${chunkIndex}`,

This line of code isn't written in the comments but is worth pondering. Using underscores to concatenate three pieces of information into the primary key is a practice of embedding business semantics. When you find a result in Milvus and glance at the id: "1_3_7", you know without checking other fields: this is book 1, chapter 3, chunk 7. This design reduces the mental burden during debugging.

Layer 3: Defensive Handling of Return Value

// The function's return result must be predictable and consistent.
return Number(insertResult.insert_cnt) || 0;

This return statement deserves to be analyzed separately. The comment "The function's return result must be predictable, consistent" is a very important but often overlooked principle in programming.

The return type of Milvus SDK's insertResult.insert_cnt might differ across versions:

The two layers of processing in this line of code:

Step Input Output What it protects against
Number(...) "5" (string) 5 (number) Prevents string addition like "0" + "5" = "05"
Number(...) 5n (bigint) 5 (number) Prevents bigint + number type errors
` 0` NaN / undefined

The last point is the most critical: if insert_cnt is undefined, Number(undefined) = NaN, and NaN is the most "vicious" value in JavaScript—NaN + any number = NaN. Once it appears, the entire totalInserted counter permanently becomes NaN and cannot recover. The || 0 defense line blocks NaN at the gate.

Design Philosophy: A function called by an external module should not shift the burden of type conversion onto the caller. When the caller loadAndProcessEPubStreaming does totalInserted += insertedCount, it expects insertedCount to be a number that can be added normally. One of the function's responsibilities is to shield the caller from the uncertainty of its dependencies.


IV. Vector Search (query.mjs): "Search" Without an LLM

After the data is ingested, you can first perform a pure vector search without integrating an LLM to ensure the "Question → Vector → Search" chain works.

4.1 Establishing Connection and Loading Collection

import {
  MilvusClient,    // C/S B/S
  MetricType,      // Similarity calculation method
} from '@zilliz/milvus2-sdk-node';

The comment // C/S B/S is an annotation on the Milvus deployment architecture—Client-Server or Browser-Server, essentially both are remote connections. The Milvus SDK connects to the server, and all operations are RPC calls.

4.2 Initiating Search

const query = '段誉会什么武功?';
const queryVector = await getEmbedding(query);
const searchResult = await client.search({
  collection_name: COLLECTION_NAME,
  vector: queryVector,
  limit: 3,
  metric_type: MetricType.COSINE,
  output_fields: ["id", "book_id", "chapter_num", "index", "content"]
});

The entire data flow:

"What martial arts does Duan Yu know?"
       │
       ▼
  OpenAI Embedding API (text-embedding-v3)
       │
       ▼
  [0.023, -0.451, ..., 0.789]  ← 1024-dimensional float vector
       │
       ▼
  Milvus.search() → IVF_FLAT index → COSINE similarity top-3
       │
       ▼
  [{id: "1_1_5", content: "Duan Yu...", score: 0.92}, ...]

Why limit: 3 and not 1 or 10?

The advantage of output_fields: Only includes the needed fields in the returned results, omitting unnecessary ones (like the 1024-dimensional vector itself). For large fields like FloatVector, transmitting only the content portion significantly reduces network overhead.

4.3 Displaying Search Results

searchResult.results.forEach((item, index) => {
  console.log(`
  ${index + 1}.[Score:${item.score.toFixed(4)}]
  ID: ${item.id}
  BookId: ${item.book_id}
  Content: ${item.content}
  `)
})

item.score is the cosine similarity value, ranging between [-1, 1]. 1 indicates completely identical direction (identical semantics), 0 indicates orthogonal (irrelevant), -1 indicates completely opposite. In practice, similarity usually falls between 0.7-0.95.

toFixed(4) retains 4 decimal places for readability—0.92345678 and 0.9235 have no difference in visual judgment, but the former makes the log look cluttered.


V. Complete RAG (rag.mjs): Letting the LLM Answer Based on the Novel

rag.mjs is the final culmination. It strings together the retrieval and generation steps to form a complete RAG Q&A system.

5.1 Dual Model Configuration

const embeddings = new OpenAIEmbeddings({
  apiKey: process.env.OPENAI_API_KEY,
  model: process.env.EMBEDDINGS_MODEL_NAME,   // text-embedding-v3
  dimensions: VECTOR_DIM
});

const model = new ChatOpenAI({
  temperature: 0.1,
  model: process.env.MODEL_NAME,              // qwen-plus
  apiKey: process.env.OPENAI_API_KEY,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL
  }
});

The two models have distinct roles:

temperature: 0.1 is set very low. In a knowledge Q&A scenario, we don't want the LLM to "get creative"—the novel's plot must be faithful to the original text. A low temperature makes the output more deterministic, conservative, and less prone to hallucination. For creative writing scenarios (like "help me write a wuxia passage"), the temperature should be set to 0.7-0.9.

5.2 Retrieval Function: Single Responsibility

// RAG book business knowledge basification
// Function name readability
// One function, one function
// Only one return value
async function retrieveRelevantContent(question, k = 3) {
  try {
    const queryVector = await getEmbedding(question);
    const searchResult = await client.search({
      collection_name: COLLECTION_NAME,
      vector: queryVector,
      limit: k,
      metric_type: MetricType.COSINE,
      output_fields: ['id', 'book_id', 'chapter_num', 'index', 'content']
    });
    return searchResult.results;
  } catch(err) {
    console.error('Error retrieving content');
    return [];
  }
}

The three principles in the comments are a concentrated embodiment of the entire project's code quality:

Function name readability: retrieveRelevantContent literally translates to "retrieve relevant content." Compared to search or getData, this name clearly expresses what the function does and what it returns. A good function name should read like natural language when called—await retrieveRelevantContent(question, 3) reads like "wait to retrieve 3 relevant content items based on the question."

One function, one function: This function only does retrieval. It doesn't care how the Prompt is assembled or how the LLM answers. Decoupling means testability—you can independently test the precision of retrieval and the quality of generation.

Only one return value: The function body has only two return statements, but they are logically branches of the same path—"normal return results" and "exception return empty array." The caller always gets an array and doesn't need to check typeof result === 'undefined' or result === null.

Exception handling strategy: The catch block returns an empty array [] instead of null or undefined. This way, the caller can directly write if (result.length === 0) without worrying about null pointers. This is also a continuation of the "predictability" principle.

5.3 Answer Function: Prompt Engineering

async function answerEbookQuestion(question, k = 3) {
  const retrievedContent = await retrieveRelevantContent(question, k);

  if (retrievedContent.length === 0) {
    return "Sorry, I couldn't find relevant content from 'Tian Long Ba Bu'.";
  }

  const context = retrievedContent.map((item, i) => `
  [Fragment ${i + 1}]
  Chapter: Chapter ${item.chapter_num}
  Content: ${item.content}
  `).join('\n\n----\n\n');

  const prompt = `You are a professional 'Tian Long Ba Bu' novel assistant.
  Answer questions based on the novel, using accurate and detailed language.
  Please answer the user's question based on the following novel fragments:
  ${context}
  User Question: ${question}

  Answer Requirements:
  1. If there is relevant information in the fragments, provide a detailed and accurate answer based on the novel's content.
  2. You can synthesize content from multiple fragments to provide a complete answer.
  3. If there is no relevant information in the fragments, honestly inform the user.
  4. The answer must be accurate and consistent with the novel's plot and character settings.
  5. You can quote the original text to support your answer.
  AI Assistant's Answer:
  `;

  const response = await model.invoke(prompt);
  return response.content;
}

The design of this Prompt is worth examining in detail.

Role Setting (System Persona):

You are a professional 'Tian Long Ba Bu' novel assistant.

The first sentence anchors the LLM with a clear identity. It doesn't say "You are an AI assistant," but limits it to this specific novel. This constraint effectively reduces the probability of the LLM answering irrelevant content.

Context Injection:

[Fragment 1]
Chapter: Chapter 2
Content: Duan Yu followed Zhong Ling to the foot of Mount Wuliang...

----

[Fragment 2]
Chapter: Chapter 2
Content: The old monk sat cross-legged in the cave...

----

[Fragment 3]
Chapter: Chapter 3
Content: Duan Yu felt a surge of hot energy in his dantian...

Using \n\n----\n\n as a fragment separator visually distinguishes different sources clearly. The annotation of chapter numbers allows the LLM to say "According to the description in Chapter 2..." when quoting, increasing the credibility of the answer.

The 5 Constraint Instructions:

Requirement Purpose Corresponding Prompt Engineering Technique
Provide a detailed and accurate answer based on the novel's content Prevent vague, general answers Anchoring to context
Synthesize content from multiple fragments Prevent focusing only on the first fragment Global coverage
Honestly inform the user if no relevant info Prevent hallucination/fabrication Principle of honesty
Answer must be consistent with the novel's plot and character settings Prevent contradiction with the original text Consistency constraint
Can quote the original text Enhance answer credibility Evidence support

The last line AI Assistant's Answer: is an answer format trigger. In OpenAI's dialogue format training data, a System or User message ending immediately followed by Assistant: is a high-frequency pattern that prompts the LLM to start answering in a more natural assistant tone.

5.4 Integration Call

async function main() {
  await client.connectPromise;
  try {
    await client.loadCollection({
      collection_name: COLLECTION_NAME
    });
    console.log('Collection loaded successfully');
  } catch(err) {
    // Silent handling: the collection might already be in a loaded state
  }

  const result = await answerEbookQuestion('What martial arts does Jiumozhi know?', 5);
  console.log(result);
}

loadCollection is placed inside a try-catch for silent handling because the collection might already be in a loaded state from a previous operation, and loading it again would throw an error, but this error does not affect subsequent use—this is a "recoverable expected error."

k = 5 passes a larger value than the default, indicating the questioner anticipates that the information about "Jiumozhi's martial arts" might be scattered across multiple places in the book, requiring more context candidates.


VI. Complete Data Flow Summary

Stringing the three files together, the entire system's data flow looks like this:

┌──────────────────────────────────────────────────────────────────┐
│                        main.mjs (Data Ingestion)                 │
│                                                                  │
│  Tian Long Ba Bu.epub                                            │
│       │                                                          │
│       ▼  EPubLoader (splitChapters: true)                        │
│  50 Chapter Documents[]                                          │
│       │                                                          │
│       ▼  RecursiveCharacterTextSplitter (500/50)                 │
│  ~2400 Text Chunks                                               │
│       │                                                          │
│       ▼  OpenAIEmbeddings (text-embedding-v3, 1024d)             │
│  ~2400 Vectors [0.023, ...] × 1024                               │
│       │                                                          │
│       ▼  Milvus.insert()                                         │
│  Milvus Collection: ebook2                                       │
│                                                                  │
├──────────────────────────────────────────────────────────────────┤
│                      query.mjs / rag.mjs (Query)                 │
│                                                                  │
│  User Question "What martial arts does Jiumozhi know?"           │
│       │                                                          │
│       ▼  OpenAIEmbeddings                                        │
│  Question Vector [0.112, ...] × 1024                             │
│       │                                                          │
│       ▼  Milvus.search() - IVF_FLAT + COSINE, top_k=5            │
│  5 Most Similar Text Fragments + Similarity Scores               │
│       │                                                          │
│       │  ┌─── query.mjs: Display fragments directly              │
│       │  │                                                       │
│       ▼  ▼  rag.mjs: Assemble Prompt → ChatOpenAI → Natural Language Answer │
│  "Jiumozhi is proficient in Minor Non-Existence Skill,          │
│   Flame Blade, Flower-Pinching Finger Technique..."              │
│                                                                  │
└──────────────────────────────────────────────────────────────────┘

VII. Directions for Further Improvement

1. Multi-turn Dialogue Context

Currently, each Q&A is independent. If a user asks "How is Duan Yu's Six Meridians Divine Sword?" and then follows up with "When did he use it?", the LLM doesn't know who "he" refers to. Adding conversation history (ChatOpenAI supports passing a messages array) can solve this problem.

2. Hybrid Search

Pure vector search might miss exact string matches. For example, searching for "Eighteen Dragon Subduing Palms"—if the Embedding model's semantic modeling for this proper noun isn't good enough, it might be missed. Milvus supports hybrid search with vector + scalar filtering, which can combine keyword matching to improve recall.

3. Re-ranking

After retrieving 5 candidate fragments, a Cross-Encoder model can be used to perform a fine ranking of them, placing the most relevant ones at the front to feed to the LLM, improving accuracy. This is more effective than simply increasing top_k.

4. Embedding Caching

Once the same text from the same book has been embedded, the result can be cached to a local file or Redis. This way, when rebuilding the database, you don't need to call the API again, saving cost and time.


Afterword

Although this project has a small amount of code (the three files total less than 300 lines), every line is filled with engineering decisions—from the choice of splitChapters to the value of nlist, from the parallel strategy of Promise.all to the type defense of Number(...) || 0, from the 5 constraint instructions in the Prompt to the catch returning an empty array instead of null. RAG is not simply "search + ask LLM," but the result of a series of carefully designed engineering choices strung together.

Sorting out these details and writing them down is itself a process of deepening understanding. I hope this article helps you avoid some pitfalls.